Initial commit: caddy static file server workspace
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
443
caddy/www/qiji-luopan/app.js
Normal file
443
caddy/www/qiji-luopan/app.js
Normal file
@@ -0,0 +1,443 @@
|
||||
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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user