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( + `
${label}` + + `${site}
` + + `${meta.veg_label || meta.veg_type || ""}
`, + { 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";