1173 lines
45 KiB
HTML
1173 lines
45 KiB
HTML
<!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 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; }
|
||
|
||
#repoLink {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #7eb8f7;
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
#repoLink:hover { color: #9ecef8; text-decoration: underline; }
|
||
|
||
/* 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;
|
||
}
|
||
#overlayTabs { display: flex; gap: 4px; flex: 1; }
|
||
.otab {
|
||
padding: 4px 12px; border-radius: 4px; font-size: 13px; cursor: pointer;
|
||
border: 1px solid #555; background: transparent; color: #ccc;
|
||
}
|
||
.otab.active { background: #2a3f5f; color: #dceeff; border-color: #4a6fa5; }
|
||
.otab:hover:not(.active) { background: rgba(255, 255, 255, 0.07); }
|
||
#worldHeader .world-meta { font-size: 12px; color: #aaa; flex-shrink: 0; }
|
||
#worldClose {
|
||
font-size: 13px; padding: 4px 12px; border-radius: 4px;
|
||
border: 1px solid #666; background: transparent; color: #ddd; cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
#worldClose:hover { background: rgba(255, 255, 255, 0.08); }
|
||
#worldMap { flex: 1; min-height: 0; }
|
||
#statsPanel { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 24px; display: none; 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; }
|
||
.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" title="World map of all sites">World map</button>
|
||
<a id="repoLink" href="https://git.delattre.de/pantierra/efast-phenocam-validation" target="_blank" rel="noopener noreferrer">Source code</a>
|
||
</div>
|
||
|
||
<div id="worldOverlay" aria-hidden="true">
|
||
<div id="worldPanel">
|
||
<div id="worldHeader">
|
||
<div id="overlayTabs">
|
||
<button type="button" class="otab active" data-tab="map">Worldwide sites</button>
|
||
<button type="button" class="otab" data-tab="stats">Statistics</button>
|
||
</div>
|
||
<span class="world-meta" id="worldMeta"></span>
|
||
<button type="button" id="worldClose">Close</button>
|
||
</div>
|
||
<div id="worldMap"></div>
|
||
<div id="statsPanel"></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> 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 overlayTab = "map";
|
||
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: "Nash–Sutcliffe 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 updateWorldMeta() {
|
||
const sites = manifest?.sites?.[currentYear] || {};
|
||
const n = Object.values(sites).filter(m => m.has_fusion).length;
|
||
if (overlayTab === "stats") {
|
||
const nPairs = statsData?.metrics?.nse?.n_pairs;
|
||
qs("#worldMeta").textContent = nPairs != null
|
||
? `${nPairs} paired site${nPairs === 1 ? "" : "s"} · α=${statsData.alpha ?? 0.05} · ${currentYear}`
|
||
: `${n} fusion site${n === 1 ? "" : "s"} · ${currentYear}`;
|
||
} else {
|
||
qs("#worldMeta").textContent =
|
||
`${n} fusion site${n === 1 ? "" : "s"} · ${currentYear}`;
|
||
}
|
||
}
|
||
|
||
function renderStatsPanel(data) {
|
||
const panel = qs("#statsPanel");
|
||
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>`;
|
||
updateWorldMeta();
|
||
}
|
||
|
||
async function loadStatsPanel() {
|
||
const panel = qs("#statsPanel");
|
||
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>';
|
||
updateWorldMeta();
|
||
}
|
||
}
|
||
|
||
function switchOverlayTab(tab, updateHash = true) {
|
||
overlayTab = tab;
|
||
document.querySelectorAll(".otab").forEach(btn =>
|
||
btn.classList.toggle("active", btn.dataset.tab === tab));
|
||
qs("#worldMap").style.display = tab === "map" ? "block" : "none";
|
||
qs("#statsPanel").style.display = tab === "stats" ? "block" : "none";
|
||
|
||
if (tab === "stats") {
|
||
if (statsYear !== currentYear || !statsData) loadStatsPanel();
|
||
else updateWorldMeta();
|
||
if (updateHash) setHash("statistics");
|
||
return;
|
||
}
|
||
|
||
buildWorldMap();
|
||
requestAnimationFrame(() => worldMapInst?.invalidateSize());
|
||
if (updateHash) setHash("worldwide");
|
||
}
|
||
|
||
// ── 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) {
|
||
if (overlayTab === "stats") loadStatsPanel();
|
||
else buildWorldMap();
|
||
}
|
||
});
|
||
|
||
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
|
||
document.querySelectorAll(".otab").forEach(btn =>
|
||
btn.addEventListener("click", () => switchOverlayTab(btn.dataset.tab)));
|
||
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, #statistics, #2025/sitename) ──
|
||
function parseHash() {
|
||
const raw = location.hash.replace(/^#/, "").trim();
|
||
if (!raw) return { view: null, year: null, site: null };
|
||
if (raw === "worldwide") return { view: "worldwide", year: null, site: null };
|
||
if (raw === "statistics") return { view: "statistics", year: null, site: null };
|
||
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 === "statistics") hash = "statistics";
|
||
else if (view === "site" && year && site)
|
||
hash = `${year}/${encodeURIComponent(site)}`;
|
||
const next = hash ? `#${hash}` : "";
|
||
if (location.hash !== next) history.replaceState(null, "", location.pathname + location.search + next);
|
||
}
|
||
|
||
function onHashChange() {
|
||
const { view, year, site } = parseHash();
|
||
if (view === "worldwide") {
|
||
openWorldOverlay(false, "map");
|
||
return;
|
||
}
|
||
if (view === "statistics") {
|
||
openWorldOverlay(false, "stats");
|
||
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, tab = "map") {
|
||
if (!manifest) return;
|
||
worldOverlayOpen = true;
|
||
const overlay = qs("#worldOverlay");
|
||
overlay.classList.add("open");
|
||
overlay.setAttribute("aria-hidden", "false");
|
||
switchOverlayTab(tab, updateHash);
|
||
}
|
||
|
||
function closeWorldOverlay(updateHash = true) {
|
||
worldOverlayOpen = false;
|
||
const overlay = qs("#worldOverlay");
|
||
overlay.classList.remove("open");
|
||
overlay.setAttribute("aria-hidden", "true");
|
||
const view = parseHash().view;
|
||
if (updateHash && (view === "worldwide" || view === "statistics")) {
|
||
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" || 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>
|