Added dropped sites.
This commit is contained in:
parent
f188dd38ab
commit
d55ee31e8d
2 changed files with 229 additions and 98 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
292
index.html
292
index.html
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue