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

@ -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:first-child, .inspector-table td:first-child { text-align: left; }
/* toolbar button */
#worldMapBtn {
/* toolbar buttons */
.toolbar-btn {
font-size: 13px; padding: 4px 10px; border-radius: 4px;
border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
cursor: pointer;
}
#worldMapBtn:hover { background: #3a5278; }
.toolbar-btn:hover { background: #3a5278; }
#repoLink {
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; }
/* worldwide map overlay */
#worldOverlay {
/* overlays (world map + statistics) */
.overlay {
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 {
.overlay.open { display: flex; align-items: stretch; justify-content: center; }
.overlay-panel {
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 {
.overlay-header {
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; }
.otab {
padding: 4px 12px; border-radius: 4px; font-size: 13px; cursor: pointer;
border: 1px solid #555; background: transparent; color: #ccc;
.overlay-header h2 {
margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex-shrink: 0;
}
.otab.active { background: #2a3f5f; color: #dceeff; border-color: #4a6fa5; }
.otab:hover:not(.active) { background: rgba(255, 255, 255, 0.07); }
#worldHeader .world-meta { font-size: 12px; color: #aaa; flex-shrink: 0; }
#worldClose {
.overlay-header .overlay-meta { font-size: 12px; color: #aaa; flex-shrink: 0; margin-left: auto; }
.overlay-close {
font-size: 13px; padding: 4px 12px; border-radius: 4px;
border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
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; }
#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-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; }
@ -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-summary { font-size: 12px; color: #666; margin-bottom: 14px; }
.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 b { display: block; margin-bottom: 2px; }
.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>
<label>Year <select id="yearSel"></select></label>
<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>
</div>
<div id="worldOverlay" aria-hidden="true">
<div id="worldPanel">
<div id="worldHeader">
<div id="overlayTabs">
<button type="button" class="otab active" data-tab="map">Worldwide sites</button>
<button type="button" class="otab" data-tab="stats">Statistics</button>
</div>
<span class="world-meta" id="worldMeta"></span>
<button type="button" id="worldClose">Close</button>
<div id="worldOverlay" class="overlay" aria-hidden="true">
<div class="overlay-panel">
<div class="overlay-header">
<h2>Worldwide sites</h2>
<span class="overlay-meta" id="worldMeta"></span>
<button type="button" class="overlay-close" id="worldClose">Close</button>
</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>
@ -302,7 +348,8 @@ let fusionMode = "bti"; // bti | itb
let miniMapInst = null, miniMarker = null;
let worldMapInst = null, worldCluster = null;
let worldOverlayOpen = false;
let overlayTab = "map";
let statsOverlayOpen = false;
let statsTab = "comparison";
let statsData = null;
let statsYear = null;
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
@ -572,22 +619,47 @@ function betterOrderLabel(order) {
return order;
}
function updateWorldMeta() {
const sites = manifest?.sites?.[currentYear] || {};
const n = Object.values(sites).filter(m => m.has_fusion).length;
if (overlayTab === "stats") {
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}`;
function updateStatsMeta() {
if (statsTab !== "comparison" || !statsData) {
qs("#statsMeta").textContent = `${currentYear}`;
return;
}
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) {
const panel = qs("#statsPanel");
const panel = qs("#statsTabComparison");
const alpha = data.alpha ?? 0.05;
const cards = STAT_METRICS.map(key => {
const meta = METRIC_META[key];
@ -625,12 +697,13 @@ function renderStatsPanel(data) {
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-grid">${cards}</div>`;
updateWorldMeta();
`<div class="stat-grid">${cards}</div>` +
droppedSitesList(data);
updateStatsMeta();
}
async function loadStatsPanel() {
const panel = qs("#statsPanel");
const panel = qs("#statsTabComparison");
panel.innerHTML = '<div class="stat-nodata">Loading…</div>';
try {
const data = await fetch(`data/statistics_fusion_order/${currentYear}.json`)
@ -643,27 +716,53 @@ async function loadStatsPanel() {
statsYear = null;
panel.innerHTML =
'<div class="stat-nodata">No statistics file found — run 6-statistics-fusion-order.py first.</div>';
updateWorldMeta();
updateStatsMeta();
}
}
function switchOverlayTab(tab, updateHash = true) {
overlayTab = tab;
document.querySelectorAll(".otab").forEach(btn =>
btn.classList.toggle("active", btn.dataset.tab === tab));
qs("#worldMap").style.display = tab === "map" ? "block" : "none";
qs("#statsPanel").style.display = tab === "stats" ? "block" : "none";
const STATS_TAB_PANELS = {
comparison: "#statsTabComparison",
gcc: "#statsTabGcc",
sites: "#statsTabSites",
};
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();
else updateWorldMeta();
if (updateHash) setHash("statistics");
return;
else updateStatsMeta();
} else {
updateStatsMeta();
}
buildWorldMap();
requestAnimationFrame(() => worldMapInst?.invalidateSize());
if (updateHash) setHash("worldwide");
if (updateHash) setHash("statistics", null, null, tab);
}
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 ──
@ -685,21 +784,31 @@ async function init() {
statsData = null;
statsYear = null;
buildSiteList();
if (worldOverlayOpen) {
if (overlayTab === "stats") loadStatsPanel();
else buildWorldMap();
}
if (worldOverlayOpen) buildWorldMap();
if (statsOverlayOpen && statsTab === "comparison") loadStatsPanel();
});
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
document.querySelectorAll(".otab").forEach(btn =>
btn.addEventListener("click", () => switchOverlayTab(btn.dataset.tab)));
qs("#statsBtn").addEventListener("click", () => openStatsOverlay());
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("#statsClose").addEventListener("click", () => closeStatsOverlay());
qs("#worldOverlay").addEventListener("click", e => {
if (e.target === qs("#worldOverlay")) closeWorldOverlay();
});
qs("#statsOverlay").addEventListener("click", e => {
if (e.target === qs("#statsOverlay")) closeStatsOverlay();
});
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);
@ -721,40 +830,46 @@ async function init() {
onHashChange();
}
// ── hash routing (#worldwide, #statistics, #2025/sitename) ──
// ── hash routing (#worldwide, #statistics/…, #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 };
if (raw === "statistics") return { view: "statistics", 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, statsTab: 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("/");
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 };
return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]), statsTab: null };
return { view: null, year: null, site: null, statsTab: null };
}
function setHash(view, year, site) {
function setHash(view, year, site, tab = "comparison") {
let hash = "";
if (view === "worldwide") hash = "worldwide";
else if (view === "statistics") hash = "statistics";
else if (view === "site" && year && site)
else if (view === "statistics") {
hash = tab && tab !== "comparison" ? `statistics/${tab}` : "statistics";
} 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();
const { view, year, site, statsTab: hashTab } = parseHash();
if (view === "worldwide") {
openWorldOverlay(false, "map");
openWorldOverlay(false);
return;
}
if (view === "statistics") {
openWorldOverlay(false, "stats");
openStatsOverlay(false, hashTab || "comparison");
return;
}
if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) {
closeWorldOverlay(false);
closeStatsOverlay(false);
if (year !== currentYear) {
currentYear = year;
qs("#yearSel").value = currentYear;
@ -765,16 +880,20 @@ function onHashChange() {
return;
}
if (worldOverlayOpen) closeWorldOverlay(false);
if (statsOverlayOpen) closeStatsOverlay(false);
}
// ── worldwide map overlay ──
function openWorldOverlay(updateHash = true, tab = "map") {
function openWorldOverlay(updateHash = true) {
if (!manifest) return;
if (statsOverlayOpen) closeStatsOverlay(false);
worldOverlayOpen = true;
const overlay = qs("#worldOverlay");
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
switchOverlayTab(tab, updateHash);
if (updateHash) setHash("worldwide");
buildWorldMap();
requestAnimationFrame(() => worldMapInst?.invalidateSize());
}
function closeWorldOverlay(updateHash = true) {
@ -782,8 +901,7 @@ function closeWorldOverlay(updateHash = true) {
const overlay = qs("#worldOverlay");
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
const view = parseHash().view;
if (updateHash && (view === "worldwide" || view === "statistics")) {
if (updateHash && parseHash().view === "worldwide") {
if (currentSite) setHash("site", currentYear, currentSite);
else history.replaceState(null, "", location.pathname + location.search);
}
@ -851,6 +969,14 @@ function pickSiteFromWorldMap(site) {
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 ──
function sliderToDate(val) {
const d = new Date(currentYear, 0, 1 + +val);