444 lines
12 KiB
JavaScript
444 lines
12 KiB
JavaScript
const tabsContainer = document.getElementById("tabs");
|
||
const flagsContainer = document.getElementById("flags");
|
||
const searchInput = document.getElementById("search");
|
||
const noResultEl = document.getElementById("no-result");
|
||
const mapTitleEl = document.getElementById("map-title");
|
||
const mapCoordEl = document.getElementById("map-coord");
|
||
|
||
let regionGroups = {};
|
||
let activeRegion = "Asia";
|
||
let currentFilter = "";
|
||
let map = null;
|
||
let marker = null;
|
||
let polygonsByCode = {};
|
||
let overlayLayer = null;
|
||
let popupPinned = false;
|
||
|
||
function formatCoord(lat, lng) {
|
||
return `座標:${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||
}
|
||
|
||
function normalizeLatLng(value) {
|
||
if (!Array.isArray(value)) return null;
|
||
|
||
const flattened = value.flat ? value.flat(Infinity) : flattenArray(value);
|
||
const coords = flattened.filter(num => typeof num === "number" && Number.isFinite(num));
|
||
if (coords.length < 2) return null;
|
||
|
||
const pickPair = (a, b) => {
|
||
let lat = a;
|
||
let lng = b;
|
||
if (Math.abs(lat) > 90 && Math.abs(lng) <= 90) {
|
||
[lat, lng] = [lng, lat];
|
||
}
|
||
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||
return [lat, lng];
|
||
}
|
||
return null;
|
||
};
|
||
|
||
let pair = pickPair(coords[0], coords[1]);
|
||
if (pair) return pair;
|
||
|
||
for (let i = 2; i < coords.length - 1; i++) {
|
||
pair = pickPair(coords[i], coords[i + 1]);
|
||
if (pair) return pair;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function flattenArray(arr) {
|
||
const out = [];
|
||
arr.forEach(item => {
|
||
if (Array.isArray(item)) {
|
||
out.push(...flattenArray(item));
|
||
} else {
|
||
out.push(item);
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function buildPopupContent(item) {
|
||
const codeLabel = `${item.code.toUpperCase()} ${item.nameZh}`;
|
||
const flagUrl = `flags/${item.code}.svg`;
|
||
return `
|
||
<div class="popup-flag">
|
||
<img src="${flagUrl}" alt="${codeLabel}" />
|
||
<div class="popup-code">${codeLabel}</div>
|
||
<div class="popup-name">${item.nameEn}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachMarkerInteractions(markerInstance) {
|
||
markerInstance.on("mouseover", () => {
|
||
if (!popupPinned) {
|
||
markerInstance.openPopup();
|
||
}
|
||
});
|
||
markerInstance.on("mouseout", () => {
|
||
if (!popupPinned) {
|
||
markerInstance.closePopup();
|
||
}
|
||
});
|
||
markerInstance.on("click", () => {
|
||
popupPinned = !popupPinned;
|
||
if (popupPinned) {
|
||
markerInstance.openPopup();
|
||
} else {
|
||
markerInstance.closePopup();
|
||
}
|
||
});
|
||
}
|
||
|
||
function initMap(defaultLatLng) {
|
||
const center = defaultLatLng || [23.6978, 120.9605]; // 台灣大致中心
|
||
map = L.map("map").setView(center, 4);
|
||
|
||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
maxZoom: 18,
|
||
attribution: "© OpenStreetMap contributors"
|
||
}).addTo(map);
|
||
|
||
// 初始 marker
|
||
marker = L.marker(center).addTo(map);
|
||
attachMarkerInteractions(marker);
|
||
marker.bindPopup("請點選上方任一國旗卡片");
|
||
mapTitleEl.textContent = "地圖:尚未選擇國家";
|
||
mapCoordEl.textContent = "";
|
||
}
|
||
|
||
function focusCountryOnMap(item) {
|
||
if (!map) return;
|
||
|
||
const coords = normalizeLatLng(item.latlng);
|
||
if (!coords) return;
|
||
|
||
const [lat, lng] = coords;
|
||
item.latlng = coords;
|
||
|
||
// 先清掉舊的 overlay(polygon / circle)
|
||
if (overlayLayer) {
|
||
map.removeLayer(overlayLayer);
|
||
overlayLayer = null;
|
||
}
|
||
|
||
const feat = polygonsByCode[item.code];
|
||
|
||
if (feat) {
|
||
// 有 polygon 的正常畫輪廓
|
||
overlayLayer = L.geoJSON(feat, {
|
||
style: {
|
||
color: "#ff6600",
|
||
weight: 2,
|
||
fillOpacity: 0.12
|
||
}
|
||
}).addTo(map);
|
||
|
||
const bounds = overlayLayer.getBounds();
|
||
if (bounds.isValid()) {
|
||
map.fitBounds(bounds, { padding: [12, 12] });
|
||
} else {
|
||
map.setView([lat, lng], 5);
|
||
}
|
||
} else if (item.code === "tw") {
|
||
// dataset 仍缺台灣 polygon 時,使用大圓圈當 fallback
|
||
overlayLayer = L.circle([lat, lng], {
|
||
radius: 250000, // 250km 左右,把台灣圈起來
|
||
color: "#ff6600",
|
||
weight: 2,
|
||
fillOpacity: 0.12
|
||
}).addTo(map);
|
||
map.fitBounds(overlayLayer.getBounds(), { padding: [12, 12] });
|
||
} else {
|
||
// 其他沒有 polygon 的國家,就只用中心點
|
||
map.setView([lat, lng], 5);
|
||
}
|
||
|
||
// Marker 移動到該國家位置並顯示國旗 popup
|
||
if (marker) {
|
||
marker.setLatLng([lat, lng]);
|
||
} else {
|
||
marker = L.marker([lat, lng]).addTo(map);
|
||
attachMarkerInteractions(marker);
|
||
}
|
||
|
||
marker.bindPopup(buildPopupContent(item));
|
||
popupPinned = false;
|
||
marker.closePopup();
|
||
|
||
mapTitleEl.textContent = `地圖:${item.code.toUpperCase()} - ${item.nameZh} / ${item.nameEn}`;
|
||
mapCoordEl.textContent = formatCoord(lat, lng);
|
||
}
|
||
|
||
async function loadCountryMeta() {
|
||
const map = {};
|
||
try {
|
||
const url =
|
||
"https://restcountries.com/v3.1/alpha?codes=" +
|
||
countryCodes.map(c => c.toUpperCase()).join(",") +
|
||
"&fields=cca2,name,region,subregion,latlng";
|
||
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
console.warn("無法從 restcountries 取得資料,status =", resp.status);
|
||
return map;
|
||
}
|
||
|
||
const data = await resp.json();
|
||
data.forEach(item => {
|
||
if (!item.cca2) return;
|
||
const code = item.cca2.toLowerCase();
|
||
const nameEn = item.name?.common || code.toUpperCase();
|
||
const nameZh = CUSTOM_ZH[code] || nameEn;
|
||
const latlng = normalizeLatLng(item.latlng);
|
||
|
||
map[code] = {
|
||
nameEn,
|
||
nameZh,
|
||
region: item.region || "Other",
|
||
subregion: item.subregion || "",
|
||
latlng
|
||
};
|
||
});
|
||
} catch (e) {
|
||
console.error("取得國家資訊失敗:", e);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// 讀取世界國家 GeoJSON,依 ISO3166-1 Alpha-2 建索引
|
||
async function loadTaiwanPolygon() {
|
||
try {
|
||
const resp = await fetch("https://raw.githubusercontent.com/g0v/twgeojson/master/json/twCounty2010.geo.json");
|
||
if (!resp.ok) {
|
||
console.warn("無法取得台灣 polygon 資料,status =", resp.status);
|
||
return null;
|
||
}
|
||
const gj = await resp.json();
|
||
if (Array.isArray(gj.features) && gj.features.length > 0) {
|
||
const polygons = [];
|
||
gj.features.forEach(feature => {
|
||
const geom = feature.geometry;
|
||
if (!geom || !geom.coordinates) return;
|
||
if (geom.type === "Polygon") {
|
||
polygons.push(geom.coordinates);
|
||
} else if (geom.type === "MultiPolygon") {
|
||
geom.coordinates.forEach(poly => polygons.push(poly));
|
||
}
|
||
});
|
||
if (polygons.length === 0) return null;
|
||
|
||
return {
|
||
type: "Feature",
|
||
properties: { ISO_A2: "TW" },
|
||
geometry: {
|
||
type: "MultiPolygon",
|
||
coordinates: polygons
|
||
}
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.error("取得台灣 polygon 失敗:", e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function loadCountryPolygons() {
|
||
const result = {};
|
||
try {
|
||
const url = "https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson";
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
console.warn("無法取得國家 polygon 資料,status =", resp.status);
|
||
} else {
|
||
const gj = await resp.json();
|
||
if (Array.isArray(gj.features)) {
|
||
gj.features.forEach(feat => {
|
||
const props = feat.properties || {};
|
||
const iso2 =
|
||
props["ISO_A2"] ||
|
||
props["ISO3166-1-Alpha-2"] ||
|
||
props.iso2;
|
||
if (iso2 && iso2 !== "-99") {
|
||
result[iso2.toLowerCase()] = feat;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("取得國家 polygon 失敗:", e);
|
||
}
|
||
|
||
if (!result.tw) {
|
||
const twFeat = await loadTaiwanPolygon();
|
||
if (twFeat) {
|
||
result.tw = twFeat;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function buildRegionGroups(metaMap) {
|
||
const groups = {};
|
||
REGION_ORDER.forEach(r => (groups[r] = []));
|
||
|
||
countryCodes.forEach(code => {
|
||
const meta = metaMap[code] || {
|
||
nameEn: code.toUpperCase(),
|
||
nameZh: CUSTOM_ZH[code] || code.toUpperCase(),
|
||
region: "Other",
|
||
subregion: "",
|
||
latlng: null
|
||
};
|
||
const region = REGION_ORDER.includes(meta.region) ? meta.region : "Other";
|
||
groups[region].push({
|
||
code,
|
||
nameEn: meta.nameEn,
|
||
nameZh: meta.nameZh,
|
||
region,
|
||
subregion: meta.subregion,
|
||
latlng: normalizeLatLng(meta.latlng)
|
||
});
|
||
});
|
||
|
||
Object.values(groups).forEach(list => {
|
||
list.sort((a, b) => a.nameEn.localeCompare(b.nameEn, "en"));
|
||
});
|
||
|
||
return groups;
|
||
}
|
||
|
||
function renderTabs() {
|
||
tabsContainer.innerHTML = "";
|
||
const regions = REGION_ORDER.filter(r => regionGroups[r] && regionGroups[r].length > 0);
|
||
|
||
regions.forEach(region => {
|
||
const btn = document.createElement("button");
|
||
btn.className = "tab" + (region === activeRegion ? " active" : "");
|
||
btn.textContent = REGION_LABELS[region] || region;
|
||
btn.dataset.region = region;
|
||
btn.addEventListener("click", () => {
|
||
if (activeRegion === region) return;
|
||
activeRegion = region;
|
||
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
renderFlagsForRegion(region);
|
||
});
|
||
tabsContainer.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function getFilteredList(region) {
|
||
const list = regionGroups[region] || [];
|
||
const q = currentFilter.trim().toLowerCase();
|
||
if (!q) return list;
|
||
|
||
return list.filter(item => {
|
||
const codeMatch = item.code.toLowerCase().includes(q);
|
||
const enMatch = item.nameEn.toLowerCase().includes(q);
|
||
const zhMatch = (item.nameZh || "").toLowerCase().includes(q);
|
||
return codeMatch || enMatch || zhMatch;
|
||
});
|
||
}
|
||
|
||
function renderFlagsForRegion(region) {
|
||
const list = getFilteredList(region);
|
||
flagsContainer.innerHTML = "";
|
||
|
||
if (list.length === 0) {
|
||
noResultEl.style.display = "block";
|
||
return;
|
||
} else {
|
||
noResultEl.style.display = "none";
|
||
}
|
||
|
||
list.forEach(item => {
|
||
const card = document.createElement("div");
|
||
card.className = "flag-card";
|
||
|
||
const img = document.createElement("img");
|
||
img.src = "flags/" + item.code + ".svg";
|
||
img.alt = item.nameEn + " (" + item.code.toUpperCase() + ")";
|
||
img.loading = "lazy";
|
||
img.onerror = () => {
|
||
img.style.opacity = "0.3";
|
||
const err = document.createElement("div");
|
||
err.className = "error";
|
||
err.textContent = "找不到圖片檔:" + item.code + ".svg";
|
||
card.appendChild(err);
|
||
};
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "meta";
|
||
|
||
const line1 = document.createElement("div");
|
||
const codeSpan = document.createElement("span");
|
||
codeSpan.className = "code";
|
||
codeSpan.textContent = item.code.toUpperCase();
|
||
|
||
const nameZhSpan = document.createElement("span");
|
||
nameZhSpan.className = "name-zh";
|
||
nameZhSpan.textContent = " " + item.nameZh;
|
||
|
||
line1.appendChild(codeSpan);
|
||
line1.appendChild(nameZhSpan);
|
||
|
||
const nameEnSpan = document.createElement("span");
|
||
nameEnSpan.className = "name-en";
|
||
nameEnSpan.textContent = item.nameEn;
|
||
|
||
meta.appendChild(line1);
|
||
meta.appendChild(nameEnSpan);
|
||
|
||
if (item.subregion) {
|
||
const sub = document.createElement("span");
|
||
sub.className = "subregion";
|
||
sub.textContent = item.subregion;
|
||
meta.appendChild(sub);
|
||
}
|
||
|
||
card.appendChild(img);
|
||
card.appendChild(meta);
|
||
|
||
// 點國旗 → 地圖移到該國家 + 顯示邊界
|
||
card.addEventListener("click", () => {
|
||
focusCountryOnMap(item);
|
||
});
|
||
|
||
flagsContainer.appendChild(card);
|
||
});
|
||
}
|
||
|
||
(async function init() {
|
||
// 同時抓國家基本資料 + polygon
|
||
const [metaMap, polyMap] = await Promise.all([
|
||
loadCountryMeta(),
|
||
loadCountryPolygons()
|
||
]);
|
||
|
||
regionGroups = buildRegionGroups(metaMap);
|
||
polygonsByCode = polyMap;
|
||
|
||
// Leaflet 地圖初始化(優先用台灣)
|
||
const twMeta = metaMap["tw"];
|
||
const defaultLatLng = normalizeLatLng(twMeta?.latlng) || [23.6978, 120.9605];
|
||
|
||
initMap(defaultLatLng);
|
||
|
||
if (!regionGroups[activeRegion] || regionGroups[activeRegion].length === 0) {
|
||
const firstRegion = REGION_ORDER.find(r => regionGroups[r] && regionGroups[r].length > 0);
|
||
if (firstRegion) activeRegion = firstRegion;
|
||
}
|
||
|
||
renderTabs();
|
||
renderFlagsForRegion(activeRegion);
|
||
|
||
searchInput.addEventListener("input", () => {
|
||
currentFilter = searchInput.value || "";
|
||
renderFlagsForRegion(activeRegion);
|
||
});
|
||
})();
|