efast-phenocam-validation/index.html
Felix Delattre bf9c6111c9 foo
2026-06-11 15:44:32 +02:00

988 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>EFAST PhenoCam analysis</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 button */
#worldMapBtn {
font-size: 13px; padding: 4px 10px; border-radius: 4px;
border: 1px solid #4a6fa5; background: #2a3f5f; color: #dceeff;
cursor: pointer;
}
#worldMapBtn:hover { background: #3a5278; }
/* worldwide map overlay */
#worldOverlay {
display: none; position: fixed; inset: 0; z-index: 3000;
background: rgba(0, 0, 0, 0.55);
}
#worldOverlay.open { display: flex; align-items: stretch; justify-content: center; }
#worldPanel {
flex: 1; margin: 12px; max-width: 1400px; display: flex; flex-direction: column;
background: #fff; border-radius: 6px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
#worldHeader {
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: #1a1a2e; color: #eee; flex-shrink: 0;
}
#worldHeader h2 { margin: 0; font-size: 14px; font-weight: 600; color: #7eb8f7; flex: 1; }
#worldHeader .world-meta { font-size: 12px; color: #aaa; }
#worldClose {
font-size: 13px; padding: 4px 12px; border-radius: 4px;
border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
}
#worldClose:hover { background: rgba(255, 255, 255, 0.08); }
#worldMap { flex: 1; min-height: 0; }
.world-popup { font-size: 12px; line-height: 1.35; }
.world-popup b { display: block; margin-bottom: 2px; }
.world-popup .veg { color: #2e7d32; font-size: 11px; }
</style>
</head>
<body>
<div id="toolbar">
<h1>EFAST PhenoCam analysis</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>
</div>
<div id="worldOverlay" aria-hidden="true">
<div id="worldPanel">
<div id="worldHeader">
<h2>Worldwide sites</h2>
<span class="world-meta" id="worldMeta"></span>
<button type="button" id="worldClose">Close</button>
</div>
<div id="worldMap"></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:#aaa"></span>S2</div>
<div class="leg"><span class="leg-swatch" style="background:#333;border-top:2px dashed #555;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:#cc0000;opacity:.4"></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;
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 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}`;
}
// ── 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;
buildSiteList();
if (worldOverlayOpen) buildWorldMap();
});
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
qs("#worldClose").addEventListener("click", () => closeWorldOverlay());
qs("#worldOverlay").addEventListener("click", e => {
if (e.target === qs("#worldOverlay")) closeWorldOverlay();
});
document.addEventListener("keydown", e => {
if (e.key === "Escape" && worldOverlayOpen) closeWorldOverlay();
});
window.addEventListener("hashchange", onHashChange);
// global date slider
qs("#dateSlider").addEventListener("input", onDateSlider);
// 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, #2025/sitename) ──
function parseHash() {
const raw = location.hash.replace(/^#/, "").trim();
if (!raw) return { view: null, year: null, site: null };
if (raw === "worldwide") return { view: "worldwide", year: null, site: null };
const parts = raw.split("/");
if (parts.length === 2 && /^\d{4}$/.test(parts[0]))
return { view: "site", year: +parts[0], site: decodeURIComponent(parts[1]) };
return { view: null, year: null, site: null };
}
function setHash(view, year, site) {
let hash = "";
if (view === "worldwide") hash = "worldwide";
else if (view === "site" && year && site)
hash = `${year}/${encodeURIComponent(site)}`;
const next = hash ? `#${hash}` : "";
if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next);
}
function onHashChange() {
const { view, year, site } = parseHash();
if (view === "worldwide") {
openWorldOverlay(false);
return;
}
if (view === "site" && year && site && manifest?.sites?.[year]?.[site]?.has_fusion) {
closeWorldOverlay(false);
if (year !== currentYear) {
currentYear = year;
qs("#yearSel").value = currentYear;
buildSiteList();
} else {
selectSite(site);
}
return;
}
if (worldOverlayOpen) closeWorldOverlay(false);
}
// ── worldwide map overlay ──
function openWorldOverlay(updateHash = true) {
if (!manifest) return;
worldOverlayOpen = true;
const overlay = qs("#worldOverlay");
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
if (updateHash) setHash("worldwide");
buildWorldMap();
requestAnimationFrame(() => worldMapInst?.invalidateSize());
}
function closeWorldOverlay(updateHash = true) {
worldOverlayOpen = false;
const overlay = qs("#worldOverlay");
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
if (updateHash && parseHash().view === "worldwide") {
if (currentSite) setHash("site", currentYear, currentSite);
else history.replaceState(null, "", location.pathname + location.search);
}
}
function buildWorldMap() {
if (!worldMapInst) {
worldMapInst = L.map("worldMap", { zoomControl: true, attributionControl: false })
.addLayer(L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"));
worldCluster = L.markerClusterGroup({
maxClusterRadius: 40,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
});
worldMapInst.addLayer(worldCluster);
}
worldCluster.clearLayers();
const sites = manifest.sites[currentYear] || {};
const entries = Object.entries(sites)
.filter(([, m]) => m.has_fusion && m.lat != null && m.lon != null);
qs("#worldMeta").textContent =
`${entries.length} fusion site${entries.length === 1 ? "" : "s"} · ${currentYear}`;
const bounds = [];
for (const [site, meta] of entries) {
const lat = meta.lat, lon = meta.lon;
bounds.push([lat, lon]);
const isActive = site === currentSite;
const marker = L.circleMarker([lat, lon], {
radius: isActive ? 8 : 6,
color: isActive ? "#fff" : "#1a1a2e",
weight: isActive ? 2.5 : 1.5,
fillColor: isActive ? "#7eb8f7" : "#e53935",
fillOpacity: 0.95,
});
const label = meta.description || site;
marker.bindPopup(
`<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" });
}
// ── 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") 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: "#bbb", width: 1, dash: [] },
{ key: "s2_whittaker",val: "gcc", color: "#444", 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: "rgba(204,0,0,0.35)", width: 1, 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 === "rgba(204,0,0,0.35)" ? "#cc0000" : 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>