diff --git a/index.html b/index.html
index c470ea9..12c68c6 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,10 @@
EFAST PhenoCam analysis
+
+
+
@@ -111,6 +114,41 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
.inspector-table { width: 100%; border-collapse: collapse; font-size: 11px; font-family: monospace; }
.inspector-table th, .inspector-table td { padding: 2px 8px; text-align: right; border-bottom: 1px solid #f0f0f0; }
.inspector-table th:first-child, .inspector-table td:first-child { text-align: left; }
+
+/* toolbar button */
+#worldMapBtn {
+ font-size: 13px; padding: 4px 10px; border-radius: 4px;
+ border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
+ cursor: pointer;
+}
+#worldMapBtn:hover { background: #3a5278; }
+
+/* worldwide map overlay */
+#worldOverlay {
+ display: none; position: fixed; inset: 0; z-index: 3000;
+ background: rgba(0, 0, 0, 0.55);
+}
+#worldOverlay.open { display: flex; align-items: stretch; justify-content: center; }
+#worldPanel {
+ flex: 1; margin: 12px; max-width: 1400px; display: flex; flex-direction: column;
+ background: #fff; border-radius: 6px; overflow: hidden;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
+}
+#worldHeader {
+ display: flex; align-items: center; gap: 12px; padding: 10px 14px;
+ background: #1a1a2e; color: #eee; flex-shrink: 0;
+}
+#worldHeader h2 { margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex: 1; }
+#worldHeader .world-meta { font-size: 12px; color: #aaa; }
+#worldClose {
+ font-size: 13px; padding: 4px 12px; border-radius: 4px;
+ border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
+}
+#worldClose:hover { background: rgba(255, 255, 255, 0.08); }
+#worldMap { flex: 1; min-height: 0; }
+.world-popup { font-size: 12px; line-height: 1.35; }
+.world-popup b { display: block; margin-bottom: 2px; }
+.world-popup .veg { color: #2e7d32; font-size: 11px; }
@@ -118,6 +156,18 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
EFAST PhenoCam analysis
+
+
+
+
@@ -207,6 +257,8 @@ let manifest = null, currentYear = null, currentSite = null;
let ts = null, rasters = null;
let fusionMode = "bti"; // bti | itb
let miniMapInst = null, miniMarker = null;
+let worldMapInst = null, worldCluster = null;
+let worldOverlayOpen = false;
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
const overlays3 = {}; // current ImageOverlay per map
const markers3 = {}; // site dot markers per map
@@ -454,7 +506,21 @@ async function init() {
currentYear = years[years.length - 1] || null;
if (currentYear) yearSel.value = currentYear;
- yearSel.addEventListener("change", () => { currentYear = +yearSel.value; buildSiteList(); });
+ yearSel.addEventListener("change", () => {
+ currentYear = +yearSel.value;
+ buildSiteList();
+ if (worldOverlayOpen) buildWorldMap();
+ });
+
+ qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
+ qs("#worldClose").addEventListener("click", () => closeWorldOverlay());
+ qs("#worldOverlay").addEventListener("click", e => {
+ if (e.target === qs("#worldOverlay")) closeWorldOverlay();
+ });
+ document.addEventListener("keydown", e => {
+ if (e.key === "Escape" && worldOverlayOpen) closeWorldOverlay();
+ });
+ window.addEventListener("hashchange", onHashChange);
// global date slider
qs("#dateSlider").addEventListener("input", onDateSlider);
@@ -471,6 +537,132 @@ async function init() {
);
buildSiteList();
+ onHashChange();
+}
+
+// ── hash routing (#worldwide, #2025/sitename) ──
+function parseHash() {
+ const raw = location.hash.replace(/^#/, "").trim();
+ if (!raw) return { view: null, year: null, site: null };
+ if (raw === "worldwide") return { view: "worldwide", year: null, site: null };
+ const parts = raw.split("/");
+ if (parts.length === 2 && /^\d{4}$/.test(parts[0]))
+ return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]) };
+ return { view: null, year: null, site: null };
+}
+
+function setHash(view, year, site) {
+ let hash = "";
+ if (view === "worldwide") hash = "worldwide";
+ else if (view === "site" && year && site)
+ hash = `${year}/${encodeURIComponent(site)}`;
+ const next = hash ? `#${hash}` : "";
+ if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next);
+}
+
+function onHashChange() {
+ const { view, year, site } = parseHash();
+ if (view === "worldwide") {
+ openWorldOverlay(false);
+ return;
+ }
+ if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) {
+ closeWorldOverlay(false);
+ if (year !== currentYear) {
+ currentYear = year;
+ qs("#yearSel").value = currentYear;
+ buildSiteList();
+ } else {
+ selectSite(site);
+ }
+ return;
+ }
+ if (worldOverlayOpen) closeWorldOverlay(false);
+}
+
+// ── worldwide map overlay ──
+function openWorldOverlay(updateHash = true) {
+ if (!manifest) return;
+ worldOverlayOpen = true;
+ const overlay = qs("#worldOverlay");
+ overlay.classList.add("open");
+ overlay.setAttribute("aria-hidden", "false");
+ if (updateHash) setHash("worldwide");
+ buildWorldMap();
+ requestAnimationFrame(() => worldMapInst?.invalidateSize());
+}
+
+function closeWorldOverlay(updateHash = true) {
+ worldOverlayOpen = false;
+ const overlay = qs("#worldOverlay");
+ overlay.classList.remove("open");
+ overlay.setAttribute("aria-hidden", "true");
+ if (updateHash && parseHash().view === "worldwide") {
+ if (currentSite) setHash("site", currentYear, currentSite);
+ else history.replaceState(null, "", location.pathname + location.search);
+ }
+}
+
+function buildWorldMap() {
+ if (!worldMapInst) {
+ worldMapInst = L.map("worldMap", { zoomControl: true, attributionControl: false })
+ .addLayer(L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"));
+ worldCluster = L.markerClusterGroup({
+ maxClusterRadius: 40,
+ spiderfyOnMaxZoom: true,
+ showCoverageOnHover: false,
+ });
+ worldMapInst.addLayer(worldCluster);
+ }
+
+ worldCluster.clearLayers();
+ const sites = manifest.sites[currentYear] || {};
+ const entries = Object.entries(sites)
+ .filter(([, m]) => m.has_fusion && m.lat != null && m.lon != null);
+
+ qs("#worldMeta").textContent =
+ `${entries.length} fusion site${entries.length === 1 ? "" : "s"} · ${currentYear}`;
+
+ const bounds = [];
+ for (const [site, meta] of entries) {
+ const lat = meta.lat, lon = meta.lon;
+ bounds.push([lat, lon]);
+ const isActive = site === currentSite;
+ const marker = L.circleMarker([lat, lon], {
+ radius: isActive ? 8 : 6,
+ color: isActive ? "#fff" : "#1a1a2e",
+ weight: isActive ? 2.5 : 1.5,
+ fillColor: isActive ? "#7eb8f7" : "#e53935",
+ fillOpacity: 0.95,
+ });
+ const label = meta.description || site;
+ marker.bindPopup(
+ ``,
+ { closeButton: false }
+ );
+ marker.on("click", () => pickSiteFromWorldMap(site));
+ worldCluster.addLayer(marker);
+ }
+
+ if (bounds.length) {
+ worldMapInst.fitBounds(bounds, { padding: [40, 40], maxZoom: 10 });
+ if (currentSite) {
+ const m = sites[currentSite];
+ if (m?.lat != null) worldMapInst.setView([m.lat, m.lon], Math.max(worldMapInst.getZoom(), 6));
+ }
+ } else {
+ worldMapInst.setView([20, 0], 2);
+ }
+}
+
+function pickSiteFromWorldMap(site) {
+ selectSite(site);
+ setHash("site", currentYear, site);
+ closeWorldOverlay(false);
+ const li = document.querySelector(`#siteList li[data-site="${CSS.escape(site)}"]`);
+ li?.scrollIntoView({ block: "nearest" });
}
// ── date helpers ──
@@ -507,6 +699,12 @@ function buildSiteList() {
li.addEventListener("click", () => selectSite(site));
list.appendChild(li);
}
+ const h = parseHash();
+ if (h.view === "worldwide") return;
+ if (h.view === "site" && h.year === currentYear && sites[h.site]?.has_fusion) {
+ selectSite(h.site);
+ return;
+ }
const keep = currentSite && sites[currentSite]?.has_fusion;
selectSite(keep ? currentSite : entries[0][0]);
}
@@ -516,6 +714,7 @@ async function selectSite(site) {
currentSite = site;
document.querySelectorAll("#siteList li").forEach(li =>
li.classList.toggle("active", li.dataset.site === site));
+ if (worldOverlayOpen) buildWorldMap();
const meta = manifest.sites[currentYear][site];
qs("#empty").style.display = "none";