Added dropped sites.

This commit is contained in:
Felix Delattre 2026-06-17 12:04:27 +02:00
parent f188dd38ab
commit d55ee31e8d
2 changed files with 229 additions and 98 deletions

View file

@ -6,7 +6,8 @@ Inputs (``data/``, ``{year}`` = ``--evaluation-year``):
Outputs (``data/statistics_fusion_order/``): Outputs (``data/statistics_fusion_order/``):
- ``{year}.json`` paired Wilcoxon + t-test summary for NSE, RMSE, nRMSE, r - ``{year}.json`` paired Wilcoxon + t-test summary for NSE, RMSE, nRMSE, r;
includes ``dropped_sites`` (union) and per-metric ``dropped_sites`` lists
CLI: CLI:
@ -50,48 +51,48 @@ def _r4(v: float | None) -> float | None:
return round(v, 4) if v is not None else None return round(v, 4) if v is not None else None
def _load_site_metrics(year: int) -> list[dict[str, Any]]: def _load_site_metrics(year: int) -> list[tuple[str, dict[str, Any]]]:
"""Return parsed ``metrics.json`` payloads for every site under ``{year}``.""" """Return ``(sitename, metrics.json payload)`` for every site under ``{year}``."""
metrics_dir = DATA_DIR / "metrics" / str(year) metrics_dir = DATA_DIR / "metrics" / str(year)
if not metrics_dir.is_dir(): if not metrics_dir.is_dir():
return [] return []
payloads: list[dict[str, Any]] = [] payloads: list[tuple[str, dict[str, Any]]] = []
for site_dir in sorted(metrics_dir.iterdir()): for site_dir in sorted(metrics_dir.iterdir()):
if not site_dir.is_dir(): if not site_dir.is_dir():
continue continue
path = site_dir / "metrics.json" path = site_dir / "metrics.json"
if not path.is_file(): if not path.is_file():
continue continue
payloads.append(json.loads(path.read_text())) payloads.append((site_dir.name, json.loads(path.read_text())))
return payloads return payloads
def collect_pairs( def collect_pairs(
site_metrics: list[dict[str, Any]], metric: str site_metrics: list[tuple[str, dict[str, Any]]], metric: str
) -> tuple[list[float], list[float], int]: ) -> tuple[list[float], list[float], list[str]]:
"""Return paired BtI / ItB values for ``metric`` and count of dropped sites.""" """Return paired BtI / ItB values for ``metric`` and dropped site names."""
bti_vals: list[float] = [] bti_vals: list[float] = []
itb_vals: list[float] = [] itb_vals: list[float] = []
n_dropped = 0 dropped_sites: list[str] = []
for payload in site_metrics: for site, payload in site_metrics:
bti = payload.get("bti") bti = payload.get("bti")
itb = payload.get("itb") itb = payload.get("itb")
if not isinstance(bti, dict) or not isinstance(itb, dict): if not isinstance(bti, dict) or not isinstance(itb, dict):
n_dropped += 1 dropped_sites.append(site)
continue continue
bti_v = bti.get(metric) bti_v = bti.get(metric)
itb_v = itb.get(metric) itb_v = itb.get(metric)
if bti_v is None or itb_v is None: if bti_v is None or itb_v is None:
n_dropped += 1 dropped_sites.append(site)
continue continue
bti_vals.append(float(bti_v)) bti_vals.append(float(bti_v))
itb_vals.append(float(itb_v)) itb_vals.append(float(itb_v))
return bti_vals, itb_vals, n_dropped return bti_vals, itb_vals, dropped_sites
def _better_order( def _better_order(
@ -226,16 +227,20 @@ def main() -> None:
) )
metrics_out: dict[str, Any] = {} metrics_out: dict[str, Any] = {}
all_dropped: set[str] = set()
for metric in METRICS: for metric in METRICS:
bti_vals, itb_vals, n_dropped = collect_pairs(site_metrics, metric) bti_vals, itb_vals, dropped_sites = collect_pairs(site_metrics, metric)
summary = paired_test(bti_vals, itb_vals, metric, alpha) summary = paired_test(bti_vals, itb_vals, metric, alpha)
summary["n_dropped"] = n_dropped summary["n_dropped"] = len(dropped_sites)
summary["dropped_sites"] = dropped_sites
all_dropped.update(dropped_sites)
metrics_out[metric] = summary metrics_out[metric] = summary
payload = { payload = {
"year": year, "year": year,
"alpha": alpha, "alpha": alpha,
"n_sites_total": n_sites_total, "n_sites_total": n_sites_total,
"dropped_sites": sorted(all_dropped),
"metrics": metrics_out, "metrics": metrics_out,
} }

View file

@ -115,13 +115,13 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
.inspector-table th, .inspector-table td { padding: 2px 8px; text-align: right; border-bottom: 1px solid #f0f0f0; } .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; } .inspector-table th:first-child, .inspector-table td:first-child { text-align: left; }
/* toolbar button */ /* toolbar buttons */
#worldMapBtn { .toolbar-btn {
font-size: 13px; padding: 4px 10px; border-radius: 4px; font-size: 13px; padding: 4px 10px; border-radius: 4px;
border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff; border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
cursor: pointer; cursor: pointer;
} }
#worldMapBtn:hover { background: #3a5278; } .toolbar-btn:hover { background: #3a5278; }
#repoLink { #repoLink {
margin-left: auto; margin-left: auto;
@ -132,37 +132,44 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
} }
#repoLink:hover { color: #9ecef8; text-decoration: underline; } #repoLink:hover { color: #9ecef8; text-decoration: underline; }
/* worldwide map overlay */ /* overlays (world map + statistics) */
#worldOverlay { .overlay {
display: none; position: fixed; inset: 0; z-index: 3000; display: none; position: fixed; inset: 0; z-index: 3000;
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
} }
#worldOverlay.open { display: flex; align-items: stretch; justify-content: center; } .overlay.open { display: flex; align-items: stretch; justify-content: center; }
#worldPanel { .overlay-panel {
flex: 1; margin: 12px; max-width: 1400px; display: flex; flex-direction: column; flex: 1; margin: 12px; max-width: 1400px; display: flex; flex-direction: column;
background: #fff; border-radius: 6px; overflow: hidden; background: #fff; border-radius: 6px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
} }
#worldHeader { .overlay-header {
display: flex; align-items: center; gap: 12px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: #1a1a2e; color: #eee; flex-shrink: 0; background: #1a1a2e; color: #eee; flex-shrink: 0; flex-wrap: wrap;
} }
#overlayTabs { display: flex; gap: 4px; flex: 1; } .overlay-header h2 {
.otab { margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex-shrink: 0;
padding: 4px 12px; border-radius: 4px; font-size: 13px; cursor: pointer;
border: 1px solid #555; background: transparent; color: #ccc;
} }
.otab.active { background: #2a3f5f; color: #dceeff; border-color: #4a6fa5; } .overlay-header .overlay-meta { font-size: 12px; color: #aaa; flex-shrink: 0; margin-left: auto; }
.otab:hover:not(.active) { background: rgba(255, 255, 255, 0.07); } .overlay-close {
#worldHeader .world-meta { font-size: 12px; color: #aaa; flex-shrink: 0; }
#worldClose {
font-size: 13px; padding: 4px 12px; border-radius: 4px; font-size: 13px; padding: 4px 12px; border-radius: 4px;
border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer; border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
#worldClose:hover { background: rgba(255, 255, 255, 0.08); } .overlay-close:hover { background: rgba(255, 255, 255, 0.08); }
#worldMap { flex: 1; min-height: 0; } #worldMap { flex: 1; min-height: 0; }
#statsPanel { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 24px; display: none; background: #f5f5f5; }
/* statistics overlay tabs */
#statsTabs { display: flex; gap: 4px; flex-wrap: wrap; }
.stab {
padding: 4px 12px; border-radius: 4px; font-size: 13px; cursor: pointer;
border: 1px solid #555; background: transparent; color: #ccc;
}
.stab.active { background: #2a3f5f; color: #dceeff; border-color: #4a6fa5; }
.stab:hover:not(.active) { background: rgba(255, 255, 255, 0.07); }
.stats-tab-panel {
flex: 1; min-height: 0; overflow-y: auto; padding: 20px 24px; background: #f5f5f5;
}
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; margin-top: 4px; } .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; margin-top: 4px; }
.stat-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 14px 16px; } .stat-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 14px 16px; }
.stat-card h3 { margin: 0 0 10px; font-size: 14px; color: #1a1a2e; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .stat-card h3 { margin: 0 0 10px; font-size: 14px; color: #1a1a2e; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@ -183,6 +190,20 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
.stat-divider { border: none; border-top: 1px solid #f0f0f0; margin: 8px 0; } .stat-divider { border: none; border-top: 1px solid #f0f0f0; margin: 8px 0; }
.stat-summary { font-size: 12px; color: #666; margin-bottom: 14px; } .stat-summary { font-size: 12px; color: #666; margin-bottom: 14px; }
.stat-nodata { color: #999; padding: 40px; text-align: center; font-size: 13px; } .stat-nodata { color: #999; padding: 40px; text-align: center; font-size: 13px; }
.stat-placeholder { color: #999; padding: 60px 24px; text-align: center; font-size: 14px; }
.stat-placeholder p { margin: 0 0 8px; color: #666; }
.stat-dropped {
margin-top: 18px; background: #fff; border: 1px solid #e0e0e0;
border-radius: 6px; padding: 14px 16px;
}
.stat-dropped h4 { margin: 0 0 6px; font-size: 13px; color: #333; }
.stat-dropped-note { margin: 0 0 10px; font-size: 11px; color: #888; }
.stat-dropped-list { display: flex; flex-wrap: wrap; gap: 6px 10px; }
.stat-site-link {
font-size: 12px; color: #1565c0; background: none; border: none; padding: 0;
cursor: pointer; text-decoration: underline; font-family: inherit;
}
.stat-site-link:hover { color: #0d47a1; }
.world-popup { font-size: 12px; line-height: 1.35; } .world-popup { font-size: 12px; line-height: 1.35; }
.world-popup b { display: block; margin-bottom: 2px; } .world-popup b { display: block; margin-bottom: 2px; }
.world-popup .veg { color: #2e7d32; font-size: 11px; } .world-popup .veg { color: #2e7d32; font-size: 11px; }
@ -194,22 +215,47 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
<h1>EFAST PhenoCam validation</h1> <h1>EFAST PhenoCam validation</h1>
<label>Year <select id="yearSel"></select></label> <label>Year <select id="yearSel"></select></label>
<span id="sitesCount" style="font-size:12px;color:#aaa;white-space:nowrap"></span> <span id="sitesCount" style="font-size:12px;color:#aaa;white-space:nowrap"></span>
<button type="button" id="worldMapBtn" title="World map of all sites">World map</button> <button type="button" id="worldMapBtn" class="toolbar-btn" title="World map of all sites">World map</button>
<button type="button" id="statsBtn" class="toolbar-btn" title="Sample-level statistics">Statistics</button>
<a id="repoLink" href="https://git.delattre.de/pantierra/efast-phenocam-validation" target="_blank" rel="noopener noreferrer">Source code</a> <a id="repoLink" href="https://git.delattre.de/pantierra/efast-phenocam-validation" target="_blank" rel="noopener noreferrer">Source code</a>
</div> </div>
<div id="worldOverlay" aria-hidden="true"> <div id="worldOverlay" class="overlay" aria-hidden="true">
<div id="worldPanel"> <div class="overlay-panel">
<div id="worldHeader"> <div class="overlay-header">
<div id="overlayTabs"> <h2>Worldwide sites</h2>
<button type="button" class="otab active" data-tab="map">Worldwide sites</button> <span class="overlay-meta" id="worldMeta"></span>
<button type="button" class="otab" data-tab="stats">Statistics</button> <button type="button" class="overlay-close" id="worldClose">Close</button>
</div>
<span class="world-meta" id="worldMeta"></span>
<button type="button" id="worldClose">Close</button>
</div> </div>
<div id="worldMap"></div> <div id="worldMap"></div>
<div id="statsPanel"></div> </div>
</div>
<div id="statsOverlay" class="overlay" aria-hidden="true">
<div class="overlay-panel">
<div class="overlay-header">
<h2>Statistics</h2>
<div id="statsTabs">
<button type="button" class="stab" data-tab="gcc">GCC suitability</button>
<button type="button" class="stab active" data-tab="comparison">ItB-BtI comparison</button>
<button type="button" class="stab" data-tab="sites">Site characteristics</button>
</div>
<span class="overlay-meta" id="statsMeta"></span>
<button type="button" class="overlay-close" id="statsClose">Close</button>
</div>
<div id="statsTabGcc" class="stats-tab-panel" style="display:none">
<div class="stat-placeholder">
<p>GCC suitability</p>
Coming soon.
</div>
</div>
<div id="statsTabComparison" class="stats-tab-panel"></div>
<div id="statsTabSites" class="stats-tab-panel" style="display:none">
<div class="stat-placeholder">
<p>Site characteristics</p>
Coming soon.
</div>
</div>
</div> </div>
</div> </div>
@ -302,7 +348,8 @@ let fusionMode = "bti"; // bti | itb
let miniMapInst = null, miniMarker = null; let miniMapInst = null, miniMarker = null;
let worldMapInst = null, worldCluster = null; let worldMapInst = null, worldCluster = null;
let worldOverlayOpen = false; let worldOverlayOpen = false;
let overlayTab = "map"; let statsOverlayOpen = false;
let statsTab = "comparison";
let statsData = null; let statsData = null;
let statsYear = null; let statsYear = null;
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
@ -572,22 +619,47 @@ function betterOrderLabel(order) {
return order; return order;
} }
function updateWorldMeta() { function updateStatsMeta() {
const sites = manifest?.sites?.[currentYear] || {}; if (statsTab !== "comparison" || !statsData) {
const n = Object.values(sites).filter(m => m.has_fusion).length; qs("#statsMeta").textContent = `${currentYear}`;
if (overlayTab === "stats") { return;
const nPairs = statsData?.metrics?.nse?.n_pairs;
qs("#worldMeta").textContent = nPairs != null
? `${nPairs} paired site${nPairs === 1 ? "" : "s"} · α=${statsData.alpha ?? 0.05} · ${currentYear}`
: `${n} fusion site${n === 1 ? "" : "s"} · ${currentYear}`;
} else {
qs("#worldMeta").textContent =
`${n} fusion site${n === 1 ? "" : "s"} · ${currentYear}`;
} }
const nPairs = statsData.metrics?.nse?.n_pairs;
const alpha = statsData.alpha ?? 0.05;
qs("#statsMeta").textContent = nPairs != null
? `${nPairs} paired site${nPairs === 1 ? "" : "s"} · α=${alpha} · ${currentYear}`
: `${currentYear}`;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function droppedSitesList(data) {
let dropped = data.dropped_sites;
if (!dropped?.length) {
const names = new Set();
for (const key of STAT_METRICS) {
for (const site of data.metrics?.[key]?.dropped_sites || []) names.add(site);
}
dropped = [...names].sort();
}
if (!dropped.length) return "";
const links = dropped.map(site =>
`<button type="button" class="stat-site-link" data-site="${escHtml(site)}">${escHtml(site)}</button>`
).join("");
return `<div class="stat-dropped">
<h4>Dropped sites (${dropped.length})</h4>
<p class="stat-dropped-note">Excluded from the paired test (missing BtI or ItB metrics for one or more measures).</p>
<div class="stat-dropped-list">${links}</div>
</div>`;
} }
function renderStatsPanel(data) { function renderStatsPanel(data) {
const panel = qs("#statsPanel"); const panel = qs("#statsTabComparison");
const alpha = data.alpha ?? 0.05; const alpha = data.alpha ?? 0.05;
const cards = STAT_METRICS.map(key => { const cards = STAT_METRICS.map(key => {
const meta = METRIC_META[key]; const meta = METRIC_META[key];
@ -625,12 +697,13 @@ function renderStatsPanel(data) {
panel.innerHTML = panel.innerHTML =
`<div class="stat-summary">Paired ItB vs BtI test across ${data.n_sites_total ?? "—"} site(s) with Step 5 metrics · significance α=${alpha}</div>` + `<div class="stat-summary">Paired ItB vs BtI test across ${data.n_sites_total ?? "—"} site(s) with Step 5 metrics · significance α=${alpha}</div>` +
`<div class="stat-grid">${cards}</div>`; `<div class="stat-grid">${cards}</div>` +
updateWorldMeta(); droppedSitesList(data);
updateStatsMeta();
} }
async function loadStatsPanel() { async function loadStatsPanel() {
const panel = qs("#statsPanel"); const panel = qs("#statsTabComparison");
panel.innerHTML = '<div class="stat-nodata">Loading…</div>'; panel.innerHTML = '<div class="stat-nodata">Loading…</div>';
try { try {
const data = await fetch(`data/statistics_fusion_order/${currentYear}.json`) const data = await fetch(`data/statistics_fusion_order/${currentYear}.json`)
@ -643,27 +716,53 @@ async function loadStatsPanel() {
statsYear = null; statsYear = null;
panel.innerHTML = panel.innerHTML =
'<div class="stat-nodata">No statistics file found — run 6-statistics-fusion-order.py first.</div>'; '<div class="stat-nodata">No statistics file found — run 6-statistics-fusion-order.py first.</div>';
updateWorldMeta(); updateStatsMeta();
} }
} }
function switchOverlayTab(tab, updateHash = true) { const STATS_TAB_PANELS = {
overlayTab = tab; comparison: "#statsTabComparison",
document.querySelectorAll(".otab").forEach(btn => gcc: "#statsTabGcc",
btn.classList.toggle("active", btn.dataset.tab === tab)); sites: "#statsTabSites",
qs("#worldMap").style.display = tab === "map" ? "block" : "none"; };
qs("#statsPanel").style.display = tab === "stats" ? "block" : "none";
if (tab === "stats") { function switchStatsTab(tab, updateHash = true) {
statsTab = tab;
document.querySelectorAll(".stab").forEach(btn =>
btn.classList.toggle("active", btn.dataset.tab === tab));
for (const [key, sel] of Object.entries(STATS_TAB_PANELS))
qs(sel).style.display = key === tab ? "block" : "none";
if (tab === "comparison") {
if (statsYear !== currentYear || !statsData) loadStatsPanel(); if (statsYear !== currentYear || !statsData) loadStatsPanel();
else updateWorldMeta(); else updateStatsMeta();
if (updateHash) setHash("statistics"); } else {
return; updateStatsMeta();
} }
buildWorldMap(); if (updateHash) setHash("statistics", null, null, tab);
requestAnimationFrame(() => worldMapInst?.invalidateSize()); }
if (updateHash) setHash("worldwide");
function openStatsOverlay(updateHash = true, tab = "comparison") {
if (!manifest) return;
if (worldOverlayOpen) closeWorldOverlay(false);
statsOverlayOpen = true;
const overlay = qs("#statsOverlay");
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
switchStatsTab(tab, updateHash);
}
function closeStatsOverlay(updateHash = true) {
statsOverlayOpen = false;
const overlay = qs("#statsOverlay");
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
const view = parseHash().view;
if (updateHash && view === "statistics") {
if (currentSite) setHash("site", currentYear, currentSite);
else history.replaceState(null, "", location.pathname + location.search);
}
} }
// ── init ── // ── init ──
@ -685,21 +784,31 @@ async function init() {
statsData = null; statsData = null;
statsYear = null; statsYear = null;
buildSiteList(); buildSiteList();
if (worldOverlayOpen) { if (worldOverlayOpen) buildWorldMap();
if (overlayTab === "stats") loadStatsPanel(); if (statsOverlayOpen && statsTab === "comparison") loadStatsPanel();
else buildWorldMap();
}
}); });
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay()); qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
document.querySelectorAll(".otab").forEach(btn => qs("#statsBtn").addEventListener("click", () => openStatsOverlay());
btn.addEventListener("click", () => switchOverlayTab(btn.dataset.tab))); document.querySelectorAll(".stab").forEach(btn =>
btn.addEventListener("click", () => switchStatsTab(btn.dataset.tab)));
qs("#statsTabComparison").addEventListener("click", e => {
const link = e.target.closest(".stat-site-link");
if (!link) return;
pickSiteFromStats(link.dataset.site);
});
qs("#worldClose").addEventListener("click", () => closeWorldOverlay()); qs("#worldClose").addEventListener("click", () => closeWorldOverlay());
qs("#statsClose").addEventListener("click", () => closeStatsOverlay());
qs("#worldOverlay").addEventListener("click", e => { qs("#worldOverlay").addEventListener("click", e => {
if (e.target === qs("#worldOverlay")) closeWorldOverlay(); if (e.target === qs("#worldOverlay")) closeWorldOverlay();
}); });
qs("#statsOverlay").addEventListener("click", e => {
if (e.target === qs("#statsOverlay")) closeStatsOverlay();
});
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.key === "Escape" && worldOverlayOpen) closeWorldOverlay(); if (e.key !== "Escape") return;
if (statsOverlayOpen) closeStatsOverlay();
else if (worldOverlayOpen) closeWorldOverlay();
}); });
window.addEventListener("hashchange", onHashChange); window.addEventListener("hashchange", onHashChange);
@ -721,40 +830,46 @@ async function init() {
onHashChange(); onHashChange();
} }
// ── hash routing (#worldwide, #statistics, #2025/sitename) ── // ── hash routing (#worldwide, #statistics/…, #2025/sitename) ──
function parseHash() { function parseHash() {
const raw = location.hash.replace(/^#/, "").trim(); const raw = location.hash.replace(/^#/, "").trim();
if (!raw) return { view: null, year: null, site: null }; if (!raw) return { view: null, year: null, site: null, statsTab: null };
if (raw === "worldwide") return { view: "worldwide", year: null, site: null }; if (raw === "worldwide") return { view: "worldwide", year: null, site: null, statsTab: null };
if (raw === "statistics") return { view: "statistics", year: null, site: null }; if (raw === "statistics" || raw.startsWith("statistics/")) {
const tab = raw.split("/")[1] || "comparison";
const validTab = ["comparison", "gcc", "sites"].includes(tab) ? tab : "comparison";
return { view: "statistics", year: null, site: null, statsTab: validTab };
}
const parts = raw.split("/"); const parts = raw.split("/");
if (parts.length === 2 && /^\d{4}$/.test(parts[0])) if (parts.length === 2 && /^\d{4}$/.test(parts[0]))
return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]) }; return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]), statsTab: null };
return { view: null, year: null, site: null }; return { view: null, year: null, site: null, statsTab: null };
} }
function setHash(view, year, site) { function setHash(view, year, site, tab = "comparison") {
let hash = ""; let hash = "";
if (view === "worldwide") hash = "worldwide"; if (view === "worldwide") hash = "worldwide";
else if (view === "statistics") hash = "statistics"; else if (view === "statistics") {
else if (view === "site" && year && site) hash = tab && tab !== "comparison" ? `statistics/${tab}` : "statistics";
} else if (view === "site" && year && site)
hash = `${year}/${encodeURIComponent(site)}`; hash = `${year}/${encodeURIComponent(site)}`;
const next = hash ? `#${hash}` : ""; const next = hash ? `#${hash}` : "";
if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next); if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next);
} }
function onHashChange() { function onHashChange() {
const { view, year, site } = parseHash(); const { view, year, site, statsTab: hashTab } = parseHash();
if (view === "worldwide") { if (view === "worldwide") {
openWorldOverlay(false, "map"); openWorldOverlay(false);
return; return;
} }
if (view === "statistics") { if (view === "statistics") {
openWorldOverlay(false, "stats"); openStatsOverlay(false, hashTab || "comparison");
return; return;
} }
if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) { if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) {
closeWorldOverlay(false); closeWorldOverlay(false);
closeStatsOverlay(false);
if (year !== currentYear) { if (year !== currentYear) {
currentYear = year; currentYear = year;
qs("#yearSel").value = currentYear; qs("#yearSel").value = currentYear;
@ -765,16 +880,20 @@ function onHashChange() {
return; return;
} }
if (worldOverlayOpen) closeWorldOverlay(false); if (worldOverlayOpen) closeWorldOverlay(false);
if (statsOverlayOpen) closeStatsOverlay(false);
} }
// ── worldwide map overlay ── // ── worldwide map overlay ──
function openWorldOverlay(updateHash = true, tab = "map") { function openWorldOverlay(updateHash = true) {
if (!manifest) return; if (!manifest) return;
if (statsOverlayOpen) closeStatsOverlay(false);
worldOverlayOpen = true; worldOverlayOpen = true;
const overlay = qs("#worldOverlay"); const overlay = qs("#worldOverlay");
overlay.classList.add("open"); overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false"); overlay.setAttribute("aria-hidden", "false");
switchOverlayTab(tab, updateHash); if (updateHash) setHash("worldwide");
buildWorldMap();
requestAnimationFrame(() => worldMapInst?.invalidateSize());
} }
function closeWorldOverlay(updateHash = true) { function closeWorldOverlay(updateHash = true) {
@ -782,8 +901,7 @@ function closeWorldOverlay(updateHash = true) {
const overlay = qs("#worldOverlay"); const overlay = qs("#worldOverlay");
overlay.classList.remove("open"); overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true"); overlay.setAttribute("aria-hidden", "true");
const view = parseHash().view; if (updateHash && parseHash().view === "worldwide") {
if (updateHash && (view === "worldwide" || view === "statistics")) {
if (currentSite) setHash("site", currentYear, currentSite); if (currentSite) setHash("site", currentYear, currentSite);
else history.replaceState(null, "", location.pathname + location.search); else history.replaceState(null, "", location.pathname + location.search);
} }
@ -851,6 +969,14 @@ function pickSiteFromWorldMap(site) {
li?.scrollIntoView({ block: "nearest" }); li?.scrollIntoView({ block: "nearest" });
} }
function pickSiteFromStats(site) {
selectSite(site);
setHash("site", currentYear, site);
closeStatsOverlay(false);
const li = document.querySelector(`#siteList li[data-site="${CSS.escape(site)}"]`);
li?.scrollIntoView({ block: "nearest" });
}
// ── date helpers ── // ── date helpers ──
function sliderToDate(val) { function sliderToDate(val) {
const d = new Date(currentYear, 0, 1 + +val); const d = new Date(currentYear, 0, 1 + +val);