efast-phenocam-validation/index.html
2026-06-17 12:04:27 +02:00

1299 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>EFAST PhenoCam validation</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<script src="common.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; color: #222; }
#toolbar {
position: sticky; top: 0; z-index: 2000;
background: #1a1a2e; color: #eee;
padding: 8px 16px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
}
#toolbar h1 { margin: 0; font-size: 15px; font-weight: 600; color: #7eb8f7; flex-shrink: 0; }
#toolbar label { color: #bbb; display: flex; align-items: center; gap: 4px; }
#toolbar select, #toolbar label select { font-size: 13px; }
#layout { display: flex; height: calc(100vh - 38px); }
#sidebar {
width: 230px; flex-shrink: 0; background: #fff;
border-right: 1px solid #ddd; overflow-y: auto;
}
#siteList { list-style: none; margin: 0; padding: 0; }
#siteList li {
padding: 6px 11px; cursor: pointer; border-bottom: 1px solid #f0f0f0;
display: flex; align-items: baseline; gap: 5px;
}
#siteList li:hover { background: #f0f7ff; }
#siteList li.active { background: #dceeff; font-weight: 600; }
.veg-badge {
font-size: 10px; padding: 1px 4px; border-radius: 3px;
background: #e8f5e9; color: #2e7d32; flex-shrink: 0;
}
#main { flex: 1; overflow-y: auto; padding: 14px 16px; }
/* top row: three columns — site info | map | photo */
#topRow { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
#siteInfo { flex: 0 0 200px; display: flex; flex-direction: column; gap: 4px; }
#siteName { margin: 0 0 6px; font-size: 16px; font-weight: 600; line-height: 1.2; }
#siteMeta .site-meta-table { width: 100%; border-collapse: collapse; font-size: 12px; }
#siteMeta .site-meta-table td { padding: 3px 0; vertical-align: top; }
#siteMeta .meta-label { color: #999; font-size: 11px; width: 42%; padding-right: 8px; }
#siteMeta .meta-val { color: #222; }
#siteMeta .meta-species { font-size: 11px; color: #666; font-style: italic; line-height: 1.35; margin-top: 6px; }
#miniMap { flex: 1; min-width: 0; height: 260px; border: 1px solid #ccc; border-radius: 4px; }
#photoPane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
#phenoPhoto { width: 100%; height: 236px; object-fit: contain; border: 1px solid #ccc; border-radius: 4px 4px 0 0; background: #111; display: block; }
#photoDateBar { font-size: 11px; color: #555; text-align: center; padding: 3px 0; background: #f9f9f9; border: 1px solid #ccc; border-top: none; border-radius: 0 0 4px 4px; }
/* GCC + slider */
#gccSection { margin-bottom: 10px; }
#gccCanvas { width: 100%; height: 190px; border: 1px solid #ccc; border-radius: 4px 4px 0 0; background: #fff; display: block; cursor: crosshair; }
#sliderRow {
display: flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid #ccc; border-top: 1px solid #e8e8e8;
border-radius: 0 0 4px 4px; padding: 5px 8px;
}
#dateSlider { flex: 1; }
#dateLabel { font-size: 11px; color: #555; white-space: nowrap; min-width: 76px; text-align: right; }
#legend { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 4px; font-size: 11px; }
.leg { display: flex; align-items: center; gap: 4px; }
.leg-swatch { width: 18px; height: 3px; display: inline-block; border-radius: 2px; }
/* metrics table */
#metricsSection { margin-bottom: 10px; display: none; }
#metricsTable { width: 100%; border-collapse: collapse; font-size: 12px; }
#metricsTable th, #metricsTable td { padding: 3px 8px; text-align: right; border-bottom: 1px solid #eee; }
#metricsTable th:first-child, #metricsTable td:first-child { text-align: left; }
#metricsTable tr:nth-child(even) td { background: #f9f9f9; }
.best { font-weight: 600; color: #1a6e2e; }
/* compare section */
#compareSection { display: none; }
#compareHeader {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
font-size: 12px; color: #555;
}
#compareHeader b { color: #222; }
.ftab {
padding: 2px 9px; border: 1px solid #ccc; border-radius: 3px;
cursor: pointer; font-size: 11px; background: #fff;
}
.ftab.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
#maps { display: flex; gap: 8px; }
.map-col { flex: 1; min-width: 0; }
.map-col h4 { margin: 0 0 3px; font-size: 11px; color: #555; text-align: center; }
.map-date { font-size: 10px; color: #999; text-align: center; margin-top: 2px; min-height: 14px; }
.rmap { height: 340px; border: 1px solid #ccc; border-radius: 4px; }
/* shared leaflet tweaks */
.leaflet-control-attribution { display: none !important; }
.leaflet-image-layer { image-rendering: pixelated; }
#empty { color: #999; padding: 40px; text-align: center; }
/* raw data inspector */
#inspectorSection { margin-top: 14px; }
.inspector-block { margin-bottom: 8px; }
.inspector-block summary { font-size: 12px; font-weight: 600; color: #333; cursor: pointer; padding: 3px 0; }
.inspector-chart { width: 100%; height: 90px; display: block; margin: 4px 0 6px; border: 1px solid #eee; border-radius: 3px; background: #fff; }
.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 buttons */
.toolbar-btn {
font-size: 13px; padding: 4px 10px; border-radius: 4px;
border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
cursor: pointer;
}
.toolbar-btn:hover { background: #3a5278; }
#repoLink {
margin-left: auto;
font-size: 12px;
color: #7eb8f7;
text-decoration: none;
white-space: nowrap;
}
#repoLink:hover { color: #9ecef8; text-decoration: underline; }
/* overlays (world map + statistics) */
.overlay {
display: none; position: fixed; inset: 0; z-index: 3000;
background: rgba(0, 0, 0, 0.55);
}
.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);
}
.overlay-header {
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: #1a1a2e; color: #eee; flex-shrink: 0; flex-wrap: wrap;
}
.overlay-header h2 {
margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex-shrink: 0;
}
.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;
}
.overlay-close:hover { background: rgba(255, 255, 255, 0.08); }
#worldMap { flex: 1; min-height: 0; }
/* 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; }
.stat-badge {
font-size: 11px; padding: 2px 7px; border-radius: 10px; font-weight: 600;
background: #e8f5e9; color: #1a6e2e;
}
.stat-badge.itb { background: #fff3e0; color: #c75c00; }
.stat-badge.bti { background: #e3f2fd; color: #0d47a1; }
.stat-badge.none { background: #f5f5f5; color: #777; font-weight: 400; }
.stat-badge.insuf { background: #fce4ec; color: #b71c1c; font-weight: 400; }
.stat-row-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.stat-row-table td { padding: 3px 0; vertical-align: top; }
.stat-row-table .slabel { color: #888; width: 46%; }
.stat-row-table .sval { color: #222; font-variant-numeric: tabular-nums; }
.stat-pval { display: inline-block; font-family: monospace; }
.stat-pval.sig { color: #1a6e2e; font-weight: 600; }
.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; }
</style>
</head>
<body>
<div id="toolbar">
<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" 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" 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>
</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 id="layout">
<aside id="sidebar">
<ul id="siteList"></ul>
</aside>
<main id="main">
<div id="empty">Select a site.</div>
<div id="siteContent" style="display:none">
<div id="topRow">
<div id="siteInfo">
<h2 id="siteName"></h2>
<div id="siteMeta"></div>
</div>
<div id="miniMap"></div>
<div id="photoPane">
<img id="phenoPhoto" alt="PhenoCam">
<div id="photoDateBar"></div>
</div>
</div>
<div id="gccSection">
<canvas id="gccCanvas"></canvas>
<div id="sliderRow">
<input type="range" id="dateSlider" min="0" max="364" value="0">
<span id="dateLabel">2025-01-01</span>
</div>
<div id="legend">
<div class="leg"><span class="leg-swatch" style="background:#2d7a3e"></span>PhenoCam</div>
<div class="leg"><span class="leg-swatch" style="background:#8a8f98"></span>S2</div>
<div class="leg"><span class="leg-swatch" style="background:transparent;border-top:2px dashed #7c3aed;height:0;width:20px"></span>Whittaker</div>
<div class="leg"><span class="leg-swatch" style="background:#0066cc"></span>BtI</div>
<div class="leg"><span class="leg-swatch" style="background:#cc6600"></span>ItB</div>
<div class="leg"><span class="leg-swatch" style="background:#d81b60"></span>S3</div>
<div class="leg"><span class="leg-swatch" style="background:#cc0000"></span>S3 smooth</div>
</div>
</div>
<div id="metricsSection">
<table id="metricsTable"></table>
</div>
<div id="compareSection">
<div id="compareHeader">
<b>Raster comparison</b> &nbsp;Fusion:
<div class="ftab active" data-mode="bti">BtI REFL</div>
<div class="ftab" data-mode="itb">ItB GCC</div>
</div>
<div id="maps">
<div class="map-col">
<h4>S2</h4>
<div id="mapS2" class="rmap"></div>
<div id="dateS2" class="map-date"></div>
</div>
<div class="map-col">
<h4 id="fusionLabel">Fusion — BtI REFL</h4>
<div id="mapFusion" class="rmap"></div>
<div id="dateFusion" class="map-date"></div>
</div>
<div class="map-col">
<h4>S3</h4>
<div id="mapS3" class="rmap"></div>
<div id="dateS3" class="map-date"></div>
</div>
</div>
</div>
<div id="inspectorSection" style="display:none">
<div id="inspectorTables"></div>
</div>
</div>
</main>
</div>
<script>
// ── proj4: all UTM zones ──
for (let z = 1; z <= 60; z++) {
proj4.defs(`EPSG:${32600+z}`, `+proj=utm +zone=${z} +datum=WGS84 +units=m +no_defs`);
proj4.defs(`EPSG:${32700+z}`, `+proj=utm +zone=${z} +south +datum=WGS84 +units=m +no_defs`);
}
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
// ── state ──
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;
let statsOverlayOpen = false;
let statsTab = "comparison";
let statsData = null;
let statsYear = null;
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
const overlays3 = {}; // current ImageOverlay per map
const markers3 = {}; // site dot markers per map
let syncing = false;
let loadToken = 0; // debounce raster loads
const qs = s => document.querySelector(s);
const fmtDate8 = d => `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
const TS_FILES = {
phenocam: "gcc_phenocam.json",
s2: "gcc_s2.json",
s2_whittaker: "gcc_s2_whittaker.json",
s3: "gcc_s3.json",
s3_smooth: "gcc_s3_smooth.json",
bti: "gcc_fusion_bti.json",
itb: "gcc_fusion_itb.json",
phenocam_images: "phenocam_images.json",
bands_s2: "bands_s2.json",
bands_s3: "bands_s3.json",
};
const SERIES_LABELS = {
bti: "BtI fusion", itb: "ItB fusion",
s2_whittaker: "Whittaker S2", s3_smooth: "S3 smooth",
s2: "S2 raw", s3: "S3 raw",
};
const METRIC_META = {
nse: { label: "NSE", full: "NashSutcliffe Efficiency", better: "higher" },
rmse: { label: "RMSE", full: "Root Mean Square Error", better: "lower" },
nrmse: { label: "nRMSE", full: "Normalised RMSE", better: "lower" },
r: { label: "r", full: "Pearson correlation", better: "higher" },
};
const STAT_METRICS = ["nse", "rmse", "nrmse", "r"];
const BADGE_CLASS = {
itb: "itb",
bti: "bti",
"no significant difference": "none",
"insufficient data": "insuf",
};
const INSPECTOR_SERIES = [
{ key: "phenocam", label: "PhenoCam", cols: [{ h: "gcc_90", k: "gcc_90" }] },
{ key: "bands_s2", label: "S2 reflectance", cols: ["B02","B03","B04"].map(b => ({ h: b, k: b })) },
{ key: "bands_s3", label: "S3 reflectance", cols: ["Oa04","Oa06","Oa08","Oa17"].map(b => ({ h: b, k: b })) },
{ key: "s2", label: "S2 GCC", cols: [{ h: "gcc", k: "gcc" }] },
{ key: "s3", label: "S3 GCC", cols: [{ h: "gcc", k: "gcc" }] },
{ key: "s2_whittaker", label: "Whittaker S2", cols: [{ h: "gcc", k: "gcc" }] },
{ key: "s3_smooth", label: "S3 smooth", cols: [{ h: "gcc", k: "gcc" }] },
{ key: "bti", label: "BtI fusion", cols: [{ h: "gcc", k: "gcc" }] },
{ key: "itb", label: "ItB fusion", cols: [{ h: "gcc", k: "gcc" }] },
];
const INSPECTOR_LINE_COLORS = {
gcc_90: "#2d7a3e", gcc: "#444",
B02: "#0066cc", B03: "#2d7a3e", B04: "#cc0000",
Oa04: "#0066cc", Oa06: "#2d7a3e", Oa08: "#cc6600", Oa17: "#888",
};
function drawInspectorChart(canvas, pts, cols) {
const W = canvas.parentElement?.clientWidth || canvas.offsetWidth || 800;
const H = 90;
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const pad = { t: 6, b: 16, l: 34, r: 6 };
const pw = W - pad.l - pad.r, ph = H - pad.t - pad.b;
ctx.clearRect(0, 0, W, H);
if (!pts.length) return;
const year = new Date(pts[0].date).getFullYear();
const minD = new Date(year, 0, 1), maxD = new Date(year, 11, 31);
const dRange = maxD - minD || 1;
let minV = Infinity, maxV = -Infinity;
for (const col of cols)
for (const p of pts)
if (p[col.k] != null) { minV = Math.min(minV, p[col.k]); maxV = Math.max(maxV, p[col.k]); }
if (!isFinite(minV)) return;
const vp = (maxV - minV) * 0.08 || 0.01;
minV -= vp; maxV += vp;
const vRange = maxV - minV;
const xOf = d => pad.l + ((new Date(d) - minD) / dRange) * pw;
const yOf = v => pad.t + ph - ((v - minV) / vRange) * ph;
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
const y = yOf(minV + vRange * i / 3);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + pw, y); ctx.stroke();
}
ctx.strokeStyle = "#ccc";
ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + ph); ctx.lineTo(pad.l + pw, pad.t + ph); ctx.stroke();
ctx.fillStyle = "#888"; ctx.font = "8px sans-serif"; ctx.textAlign = "right";
ctx.fillText(maxV.toFixed(3), pad.l - 3, pad.t + 8);
ctx.fillText(minV.toFixed(3), pad.l - 3, pad.t + ph);
ctx.textAlign = "center"; ctx.fillStyle = "#bbb";
for (let m = 0; m < 12; m += 3)
ctx.fillText("JFMAMJJASOND"[m], xOf(new Date(year, m, 15)), pad.t + ph + 11);
for (const col of cols) {
const series = pts.filter(p => p[col.k] != null);
if (!series.length) continue;
ctx.strokeStyle = INSPECTOR_LINE_COLORS[col.k] || "#666";
ctx.lineWidth = cols.length > 1 ? 1.2 : 1.5;
ctx.beginPath();
series.forEach((p, i) => { const x = xOf(p.date), y = yOf(p[col.k]); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
ctx.stroke();
ctx.fillStyle = ctx.strokeStyle;
series.forEach(p => { ctx.beginPath(); ctx.arc(xOf(p.date), yOf(p[col.k]), 1.5, 0, Math.PI * 2); ctx.fill(); });
}
}
function renderInspector(ts) {
const section = qs("#inspectorSection");
const container = qs("#inspectorTables");
container.innerHTML = "";
let any = false;
for (const { key, label, cols } of INSPECTOR_SERIES) {
const pts = ts?.[key] ?? [];
if (!pts.length) continue;
any = true;
const block = document.createElement("details");
block.className = "inspector-block";
block.innerHTML = `<summary>${label} <span style="font-weight:400;color:#999">(${pts.length} rows)</span></summary>`;
const canvas = document.createElement("canvas");
canvas.className = "inspector-chart";
block.appendChild(canvas);
const heads = cols.map(c => `<th>${c.h}</th>`).join("");
const rows = pts.map(p =>
`<tr><td>${p.date}</td>${cols.map(c => `<td>${p[c.k]?.toFixed(4) ?? "—"}</td>`).join("")}</tr>`
).join("");
block.insertAdjacentHTML("beforeend",
`<table class="inspector-table">
<thead><tr><th>Date</th>${heads}</tr></thead>
<tbody>${rows}</tbody>
</table>`);
container.appendChild(block);
drawInspectorChart(canvas, pts, cols);
block.addEventListener("toggle", () => { if (block.open) drawInspectorChart(canvas, pts, cols); });
}
section.style.display = any ? "block" : "none";
}
const RASTER_FILES = {
s2: "rasters_s2_refl.json",
s3: "rasters_s3_composite.json",
s2_gcc: "rasters_s2_gcc.json",
s3_gcc: "rasters_s3_gcc.json",
bti: "rasters_fusion_bti_refl.json",
itb: "rasters_fusion_itb_gcc.json",
};
async function fetchJson(url) {
const r = await fetch(url);
return r.ok ? r.json() : [];
}
async function loadMetrics(year, site) {
const base = `data/metrics/${year}/${site}`;
const [tsEntries, rasterEntries, metricsData, covariatesData] = await Promise.all([
Promise.all(Object.entries(TS_FILES).map(async ([k, f]) => [k, await fetchJson(`${base}/${f}`)])),
Promise.all(Object.entries(RASTER_FILES).map(async ([k, f]) => [k, await fetchJson(`${base}/${f}`)])),
fetch(`${base}/metrics.json`).then(r => r.ok ? r.json() : {}),
fetch(`${base}/covariates.json`).then(r => r.ok ? r.json() : {}),
]);
return {
ts: Object.fromEntries(tsEntries),
rasters: Object.fromEntries(rasterEntries),
metrics: metricsData,
covariates: covariatesData,
};
}
function renderMetricsTable(metricsData) {
const section = qs("#metricsSection");
const tbody = qs("#metricsTable");
if (!metricsData || !Object.keys(metricsData).length) { section.style.display = "none"; return; }
const COLS = [
{ key: "rmse", label: "RMSE", dir: "min", fmt: v => v.toFixed(4) },
{ key: "nrmse", label: "nRMSE", dir: "min", fmt: v => v.toFixed(4) },
{ key: "nse", label: "NSE", dir: "max", fmt: v => v.toFixed(3) },
{ key: "r", label: "r", dir: "max", fmt: v => v.toFixed(3) },
];
// find best value per column (ignoring nulls)
const best = {};
for (const col of COLS) {
const vals = Object.values(metricsData).map(m => m?.[col.key]).filter(v => v != null);
best[col.key] = vals.length ? (col.dir === "min" ? Math.min(...vals) : Math.max(...vals)) : null;
}
const rows = Object.entries(SERIES_LABELS).map(([key, label]) => {
const m = metricsData[key];
const n = m?.n ?? "—";
const cells = COLS.map(col => {
const v = m?.[col.key];
if (v == null) return `<td>—</td>`;
const cls = v === best[col.key] ? ' class="best"' : "";
return `<td${cls}>${col.fmt(v)}</td>`;
}).join("");
return `<tr><td>${label}</td><td>${n}</td>${cells}</tr>`;
}).join("");
tbody.innerHTML =
`<thead><tr><th>Series</th><th>n</th>${COLS.map(c => `<th>${c.label}</th>`).join("")}</tr></thead>` +
`<tbody>${rows}</tbody>`;
section.style.display = "block";
}
function renderSitePanel(meta, cov) {
const fmt = (v, decimals = 0, unit = "") =>
v != null ? `${typeof v === "number" ? v.toFixed(decimals) : v}${unit}` : "—";
const tr = (label, val) =>
`<tr><td class="meta-label">${label}</td><td class="meta-val">${val ?? "—"}</td></tr>`;
const rows = [
tr("Type", meta.veg_label || meta.veg_type),
tr("Lat", meta.lat?.toFixed(4)),
tr("Lon", meta.lon?.toFixed(4)),
];
if (meta.snr != null) rows.push(tr("SNR", meta.snr.toFixed(1)));
if (meta.n_gcc_points != null) rows.push(tr("GCC pts", meta.n_gcc_points));
if (cov && Object.keys(cov).length) {
rows.push(
tr("S2 scenes", fmt(cov.s2_scene_count)),
tr("Mean S2 gap", fmt(cov.s2_mean_gap_days, 1, " d")),
tr("Max S2 gap", fmt(cov.s2_max_gap_days, 0, " d")),
tr("S3 composites", fmt(cov.s3_composite_count)),
tr("GCC CV", fmt(cov.spatial_gcc_cv, 3)),
tr("GCC std", fmt(cov.spatial_gcc_std, 3)),
);
}
const species = meta.dominant_species
? `<div class="meta-species">${meta.dominant_species}</div>` : "";
qs("#siteMeta").innerHTML =
`<table class="site-meta-table"><tbody>${rows.join("")}</tbody></table>${species}`;
}
function fmtStat(v, decimals = 4) {
return v != null ? v.toFixed(decimals) : "—";
}
function fmtPval(p, alpha) {
if (p == null) return "—";
const cls = p < alpha ? "stat-pval sig" : "stat-pval";
return `<span class="${cls}">${p.toFixed(4)}</span>`;
}
function betterOrderLabel(order) {
if (order === "itb") return "ItB better";
if (order === "bti") return "BtI better";
if (order === "no significant difference") return "No significant difference";
if (order === "insufficient data") return "Insufficient data";
return order;
}
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("#statsTabComparison");
const alpha = data.alpha ?? 0.05;
const cards = STAT_METRICS.map(key => {
const meta = METRIC_META[key];
const m = data.metrics?.[key] || {};
const badgeCls = BADGE_CLASS[m.better_order] || "none";
const badge = `<span class="stat-badge ${badgeCls}">${betterOrderLabel(m.better_order)}</span>`;
const row = (label, val) =>
`<tr><td class="slabel">${label}</td><td class="sval">${val}</td></tr>`;
return `<div class="stat-card">
<h3>${meta.label} <span style="font-weight:400;color:#888;font-size:12px">${meta.full}</span> ${badge}</h3>
<div style="font-size:11px;color:#999;margin-bottom:8px">${meta.better} is better</div>
<table class="stat-row-table">
${row("BtI mean", fmtStat(m.bti_mean))}
${row("BtI median", fmtStat(m.bti_median))}
${row("ItB mean", fmtStat(m.itb_mean))}
${row("ItB median", fmtStat(m.itb_median))}
${row("Diff (ItB BtI) mean", fmtStat(m.mean_diff))}
${row("Diff (ItB BtI) median", fmtStat(m.median_diff))}
</table>
<hr class="stat-divider">
<table class="stat-row-table">
${row("Wilcoxon W", m.wilcoxon?.statistic ?? "—")}
${row("Wilcoxon p", fmtPval(m.wilcoxon?.p_value, alpha))}
${row("Paired t", m.ttest?.statistic ?? "—")}
${row("Paired t p", fmtPval(m.ttest?.p_value, alpha))}
</table>
<hr class="stat-divider">
<table class="stat-row-table">
${row("Paired sites", m.n_pairs ?? "—")}
${row("Dropped sites", m.n_dropped ?? "—")}
</table>
</div>`;
}).join("");
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>` +
droppedSitesList(data);
updateStatsMeta();
}
async function loadStatsPanel() {
const panel = qs("#statsTabComparison");
panel.innerHTML = '<div class="stat-nodata">Loading…</div>';
try {
const data = await fetch(`data/statistics_fusion_order/${currentYear}.json`)
.then(r => { if (!r.ok) throw new Error(); return r.json(); });
statsData = data;
statsYear = currentYear;
renderStatsPanel(data);
} catch {
statsData = null;
statsYear = null;
panel.innerHTML =
'<div class="stat-nodata">No statistics file found — run 6-statistics-fusion-order.py first.</div>';
updateStatsMeta();
}
}
const STATS_TAB_PANELS = {
comparison: "#statsTabComparison",
gcc: "#statsTabGcc",
sites: "#statsTabSites",
};
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 updateStatsMeta();
} else {
updateStatsMeta();
}
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 ──
async function init() {
try {
manifest = await fetch("data/metrics/manifest.json").then(r => r.json());
} catch {
qs("#empty").textContent = "manifest.json not found — run 5-metrics.py first.";
return;
}
const years = manifest.years || [];
const yearSel = qs("#yearSel");
yearSel.innerHTML = years.map(y => `<option value="${y}">${y}</option>`).join("");
currentYear = years[years.length - 1] || null;
if (currentYear) yearSel.value = currentYear;
yearSel.addEventListener("change", () => {
currentYear = +yearSel.value;
statsData = null;
statsYear = null;
buildSiteList();
if (worldOverlayOpen) buildWorldMap();
if (statsOverlayOpen && statsTab === "comparison") loadStatsPanel();
});
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
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") return;
if (statsOverlayOpen) closeStatsOverlay();
else if (worldOverlayOpen) closeWorldOverlay();
});
window.addEventListener("hashchange", onHashChange);
// global date slider
qs("#dateSlider").addEventListener("input", onDateSlider);
// fusion mode tabs
document.querySelectorAll(".ftab").forEach(tab =>
tab.addEventListener("click", () => {
document.querySelectorAll(".ftab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
fusionMode = tab.dataset.mode;
qs("#fusionLabel").textContent = `Fusion — ${fusionMode === "bti" ? "BtI REFL" : "ItB GCC"}`;
loadRasters();
})
);
buildSiteList();
onHashChange();
}
// ── hash routing (#worldwide, #statistics/…, #2025/sitename) ──
function parseHash() {
const raw = location.hash.replace(/^#/, "").trim();
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]), statsTab: null };
return { view: null, year: null, site: null, statsTab: null };
}
function setHash(view, year, site, tab = "comparison") {
let hash = "";
if (view === "worldwide") hash = "worldwide";
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, statsTab: hashTab } = parseHash();
if (view === "worldwide") {
openWorldOverlay(false);
return;
}
if (view === "statistics") {
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;
buildSiteList();
} else {
selectSite(site);
}
return;
}
if (worldOverlayOpen) closeWorldOverlay(false);
if (statsOverlayOpen) closeStatsOverlay(false);
}
// ── worldwide map overlay ──
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");
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(
`<div class="world-popup"><b>${label}</b>` +
`<span>${site}</span><br>` +
`<span class="veg">${meta.veg_label || meta.veg_type || ""}</span></div>`,
{ 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" });
}
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);
return d.toISOString().slice(0, 10); // YYYY-MM-DD
}
function sliderToDate8(val) {
return sliderToDate(val).replace(/-/g, ""); // YYYYMMDD
}
function dateToSlider(dateStr) {
const [y, m, d] = dateStr.split("-").map(Number);
return Math.round((new Date(y, m-1, d) - new Date(y, 0, 1)) / 86400000);
}
function nearestItem(items, date8) {
if (!items?.length) return null;
const t = parseInt(date8);
return items.reduce((best, it) => Math.abs(parseInt(it.date) - t) < Math.abs(parseInt(best.date) - t) ? it : best);
}
// ── sidebar ──
function buildSiteList() {
const sites = manifest.sites[currentYear] || {};
const list = qs("#siteList");
list.innerHTML = "";
const entries = Object.entries(sites)
.filter(([, m]) => m.has_fusion)
.sort(([a], [b]) => a.localeCompare(b));
qs("#sitesCount").textContent = `${entries.length} site${entries.length === 1 ? "" : "s"}`;
if (!entries.length) { list.innerHTML = '<li style="color:#999;padding:10px">No fusion sites yet</li>'; return; }
for (const [site, meta] of entries) {
const li = document.createElement("li");
li.dataset.site = site;
li.innerHTML = `<span>${site}</span><span class="veg-badge">${meta.veg_type}</span>`;
li.addEventListener("click", () => selectSite(site));
list.appendChild(li);
}
const h = parseHash();
if (h.view === "worldwide" || h.view === "statistics") 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]);
}
// ── site selection ──
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";
qs("#siteContent").style.display = "block";
qs("#siteName").textContent = meta.description || site;
renderSitePanel(meta, null);
initMiniMap(meta.lat, meta.lon);
ts = null; rasters = null;
if (meta.has_fusion) {
const loaded = await loadMetrics(currentYear, site);
ts = loaded.ts; rasters = loaded.rasters;
renderMetricsTable(loaded.metrics);
renderSitePanel(meta, loaded.covariates);
renderInspector(loaded.ts);
} else {
ts = await loadCsvTs(`data/phenocam/${currentYear}/${site}_1day.csv`);
qs("#metricsSection").style.display = "none";
renderInspector(null);
}
// reset slider to Jan 1
const slider = qs("#dateSlider");
slider.max = isLeap(currentYear) ? 365 : 364;
slider.value = 0;
drawGCC(0);
updatePhoto(sliderToDate(0));
qs("#compareSection").style.display = meta.has_fusion ? "block" : "none";
if (meta.has_fusion) {
initCompareMaps(meta.lat, meta.lon);
loadRasters();
}
}
function isLeap(y) { return (y%4===0 && y%100!==0) || y%400===0; }
// ── mini map ──
function initMiniMap(lat, lon) {
if (!miniMapInst) {
miniMapInst = L.map("miniMap", { zoomControl: true, attributionControl: false, scrollWheelZoom: false })
.addLayer(L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"));
}
miniMapInst.setView([lat, lon], 11);
if (miniMarker) miniMarker.setLatLng([lat, lon]);
else miniMarker = L.circleMarker([lat, lon],
{ radius: 6, color: "#e53935", fillColor: "#e53935", fillOpacity: 1 }).addTo(miniMapInst);
}
// ── comparison maps ──
function initCompareMaps(lat, lon) {
syncing = true;
const mapIds = { s2: "mapS2", fusion: "mapFusion", s3: "mapS3" };
for (const [id, elId] of Object.entries(mapIds)) {
if (!maps3[id]) {
maps3[id] = L.map(elId, { zoomControl: id === "s2", attributionControl: false, scrollWheelZoom: false })
.addLayer(L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { opacity: 0.4 }));
maps3[id].on("moveend zoomend", () => syncMaps(id));
}
maps3[id].setView([lat, lon], 12);
if (overlays3[id]) { maps3[id].removeLayer(overlays3[id]); overlays3[id] = null; }
if (markers3[id]) markers3[id].setLatLng([lat, lon]);
else markers3[id] = L.circleMarker([lat, lon], {
radius: 5, color: "#fff", weight: 1.5,
fillColor: "#e53935", fillOpacity: 1, pane: "markerPane",
}).addTo(maps3[id]);
}
syncing = false;
for (const id of Object.keys(mapIds)) maps3[id]?.invalidateSize();
}
function syncMaps(source) {
if (syncing || !maps3[source]) return;
syncing = true;
const c = maps3[source].getCenter(), z = maps3[source].getZoom();
for (const id of ["s2", "fusion", "s3"]) {
if (id !== source && maps3[id]) maps3[id].setView(c, z, { animate: false });
}
syncing = false;
}
// ── date slider handler ──
function onDateSlider() {
const val = +qs("#dateSlider").value;
const dateStr = sliderToDate(val);
const date8 = dateStr.replace(/-/g, "");
qs("#dateLabel").textContent = dateStr;
drawGCC(val);
updatePhoto(dateStr);
if (rasters) scheduleRasterLoad(date8);
}
// ── photo ──
function updatePhoto(dateStr) {
const images = ts?.phenocam_images || [];
if (!images.length) { qs("#phenoPhoto").src = ""; qs("#photoDateBar").textContent = "—"; return; }
const target = new Date(dateStr);
const img = images.reduce((best, im) =>
Math.abs(new Date(im.date) - target) < Math.abs(new Date(best.date) - target) ? im : best);
qs("#phenoPhoto").src = img.url;
qs("#photoDateBar").textContent = img.date;
}
// ── GCC canvas ──
const SERIES = [
{ key: "phenocam", val: "gcc_90", color: "#2d7a3e", width: 2, dash: [] },
{ key: "s2", val: "gcc", color: "#8a8f98", width: 1, dash: [] },
{ key: "s2_whittaker",val: "gcc", color: "#7c3aed", width: 1.5, dash: [4,3] },
{ key: "bti", val: "gcc", color: "#0066cc", width: 1.5, dash: [] },
{ key: "itb", val: "gcc", color: "#cc6600", width: 1.5, dash: [] },
{ key: "s3", val: "gcc", color: "#d81b60", width: 1.2, dash: [] },
{ key: "s3_smooth", val: "gcc", color: "#cc0000", width: 1.5, dash: [] },
];
function drawGCC(sliderVal) {
const canvas = qs("#gccCanvas");
canvas.width = canvas.offsetWidth || 800;
canvas.height = 190;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = 190;
const pad = { t: 8, b: 22, l: 38, r: 6 };
const pw = W - pad.l - pad.r, ph = H - pad.t - pad.b;
ctx.clearRect(0, 0, W, H);
if (!ts) return;
const allPts = ts.phenocam || [];
if (!allPts.length) return;
const year = new Date(allPts[0].date).getFullYear();
const minD = new Date(year, 0, 1), maxD = new Date(year, 11, 31);
const dRange = maxD - minD || 1;
let minV = Infinity, maxV = -Infinity;
for (const s of SERIES)
for (const pt of (ts[s.key] || []))
if (pt[s.val] != null) { minV = Math.min(minV, pt[s.val]); maxV = Math.max(maxV, pt[s.val]); }
if (!isFinite(minV)) return;
const vp = (maxV - minV) * 0.08 || 0.01;
minV -= vp; maxV += vp;
const vRange = maxV - minV;
const xOf = d => pad.l + ((new Date(d) - minD) / dRange) * pw;
const yOf = v => pad.t + ph - ((v - minV) / vRange) * ph;
// grid & axes
ctx.strokeStyle = "#e8e8e8"; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = yOf(minV + vRange * i / 4);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + pw, y); ctx.stroke();
}
ctx.strokeStyle = "#ccc";
ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t+ph); ctx.lineTo(pad.l+pw, pad.t+ph); ctx.stroke();
ctx.fillStyle = "#888"; ctx.font = "9px sans-serif"; ctx.textAlign = "right";
for (let i = 0; i <= 4; i++) {
const v = minV + vRange * i / 4;
ctx.fillText(v.toFixed(3), pad.l - 3, yOf(v) + 3);
}
ctx.textAlign = "center"; ctx.fillStyle = "#bbb"; ctx.font = "9px sans-serif";
for (let m = 0; m < 12; m++) {
const x = xOf(new Date(year, m, 15));
ctx.fillText("JFMAMJJASOND"[m], x, pad.t + ph + 14);
}
// data series
for (const s of SERIES) {
const pts = (ts[s.key] || []).filter(p => p[s.val] != null);
if (!pts.length) continue;
ctx.save();
ctx.strokeStyle = s.color; ctx.lineWidth = s.width; ctx.setLineDash(s.dash);
ctx.beginPath();
pts.forEach((p, i) => { const x = xOf(p.date), y = yOf(p[s.val]); i ? ctx.lineTo(x,y) : ctx.moveTo(x,y); });
ctx.stroke();
ctx.restore();
}
// cursor
const curDate = sliderToDate(sliderVal ?? +qs("#dateSlider").value);
const cx = xOf(curDate);
ctx.save();
ctx.strokeStyle = "rgba(255,80,80,0.8)"; ctx.lineWidth = 1.5; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(cx, pad.t); ctx.lineTo(cx, pad.t+ph); ctx.stroke();
ctx.restore();
// value readouts at cursor
ctx.font = "bold 10px sans-serif"; ctx.textAlign = "left";
const tgt = new Date(curDate);
for (const s of SERIES) {
const pts = (ts[s.key] || []).filter(p => p[s.val] != null);
if (!pts.length) continue;
const nearest = pts.reduce((b, p) => Math.abs(new Date(p.date)-tgt) < Math.abs(new Date(b.date)-tgt) ? p : b);
if (!nearest) continue;
const vv = nearest[s.val];
const tx = cx + 4, ty = yOf(vv);
ctx.fillStyle = s.color;
ctx.fillText(vv.toFixed(3), tx, ty);
}
}
// ── raster loading (debounced) ──
function scheduleRasterLoad(date8) {
const token = ++loadToken;
setTimeout(() => { if (token === loadToken) loadRastersForDate(date8); }, 120);
}
function loadRasters() {
const date8 = sliderToDate8(+qs("#dateSlider").value);
loadRastersForDate(date8);
}
async function loadRastersForDate(date8) {
if (!rasters) return;
const itb = fusionMode === "itb";
await Promise.all([
loadOverlay("s2", nearestItem(itb ? rasters.s2_gcc : rasters.s2, date8), qs("#dateS2")),
loadOverlay("fusion",nearestItem(rasters[fusionMode], date8), qs("#dateFusion")),
loadOverlay("s3", nearestItem(itb ? rasters.s3_gcc : rasters.s3, date8), qs("#dateS3")),
]);
}
async function loadOverlay(id, item, dateEl) {
if (!item || !maps3[id]) return;
dateEl.textContent = fmtDate8(item.date);
try {
const buf = await fetch(item.path).then(r => r.arrayBuffer());
const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
let bounds;
if (crsCode === "EPSG:4326") {
bounds = [[bbox[1],bbox[0]],[bbox[3],bbox[2]]];
} else {
const sw = proj4(crsCode,"EPSG:4326",[bbox[0],bbox[1]]);
const ne = proj4(crsCode,"EPSG:4326",[bbox[2],bbox[3]]);
bounds = [[sw[1],sw[0]],[ne[1],ne[0]]];
}
if (overlays3[id]) maps3[id].removeLayer(overlays3[id]);
overlays3[id] = L.imageOverlay(dataUrl, bounds, { opacity: 0.9 }).addTo(maps3[id]);
maps3[id].fitBounds(bounds);
} catch(e) { console.warn(id, e); }
}
// ── CSV fallback (non-fusion sites) ──
async function loadCsvTs(url) {
try {
const text = await fetch(url).then(r => r.ok ? r.text() : "");
if (!text) return null;
const lines = text.split("\n").filter(l => !l.startsWith("#") && l.trim());
const h = lines[0].split(",");
const [iDate, iGcc, iYr, iFn] = ["date","gcc_90","year","midday_filename"].map(k => h.indexOf(k));
const phenocam = [], images = [];
for (let i = 1; i < lines.length; i++) {
const c = lines[i].split(",");
if (c[iYr] !== String(currentYear)) continue;
const date = c[iDate], gcc = parseFloat(c[iGcc]);
if (!isNaN(gcc)) phenocam.push({ date, gcc_90: gcc });
const fn = c[iFn]?.trim();
if (fn && fn !== "NA") images.push({ date, url: `https://phenocam.nau.edu/data/archive/${currentSite}/${currentYear}/${date.slice(5,7)}/${fn}` });
}
return { phenocam, phenocam_images: images };
} catch { return null; }
}
window.addEventListener("resize", () => drawGCC(+qs("#dateSlider").value));
init();
</script>
</body>
</html>