787 lines
30 KiB
HTML
787 lines
30 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">
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.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: 210px; 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="toolbar">
|
|
<h1>EFAST PhenoCam analysis</h1>
|
|
<label>Year <select id="yearSel"></select></label>
|
|
</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> 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;
|
|
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(); });
|
|
|
|
// 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();
|
|
}
|
|
|
|
// ── 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));
|
|
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 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));
|
|
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: false, 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>
|