efast-phenocam-validation/webapp/index.html
Felix Delattre 374be6865d Foo
2026-05-16 18:01:46 +02:00

1076 lines
55 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html>
<head>
<title>Full</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>
<style>
body { margin: 0; font-family: sans-serif; }
.nav { margin-bottom: 10px; font-size: 14px; }
.nav a { margin-right: 12px; color: #0066cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav a.active { font-weight: bold; }
.slider-container { position: sticky; top: 0; background: white; padding: 20px; z-index: 1000; border-bottom: 1px solid #ccc; }
.scenario-selector { margin-bottom: 10px; }
.scenario-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector { margin-bottom: 10px; }
.site-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector label { margin-right: 5px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { display: flex; gap: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-top: 10px;padding-bottom: 20px;}
.header-col { flex: 1; }
.site-info h1 { margin: 0 0 10px 0; font-size: 24px; }
.site-info h2 { margin: 0 0 20px 0; font-size: 18px; color: #666; }
.phenocam-label { font-size: 12px; margin-bottom: 5px; color: #666; }
.phenocam-date { font-size: 11px; margin-top: 5px; color: #999; }
.timeseries-phenology { font-size: 11px; color: #444; line-height: 1.45; margin-top: 6px; white-space: pre-line; }
.phenocam-image { width: 100%; height: 200px; object-fit: contain; border: 1px solid #ccc; }
.sitemap { height: 200px; border: 1px solid #ccc; margin-top: 32px; }
#greennesstimeseries { margin-top: 0; }
#dateSlider { width: 100%; }
#dateDisplay { text-align: center; margin: 10px 0; font-size: 18px; }
.maps { display: flex; gap: 20px; }
.map-container { flex: 1; }
.map-container h3 { margin: 10px 0; text-align: center; }
.timeseries-label { font-size: 12px; margin-bottom: 5px; color: #666; }
.timeseries { width: 100%; height: 120px; border: 1px solid #ccc; margin-bottom: 10px; }
.map-label { font-size: 12px; margin-bottom: 5px; color: #666; }
.map-date { font-size: 11px; margin-top: 5px; color: #999; }
.map { height: 500px; border: 1px solid #ccc; }
.combined-plot { margin-top: 20px; }
.combined-plot-label { font-size: 12px; margin-bottom: 5px; color: #666; }
.combined-plot-canvas { width: 100%; height: 120px; border: 1px solid #ccc; }
.leaflet-image-layer { image-rendering: pixelated; }
.leaflet-control-attribution { display: none; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="index.html" class="active">Full</a>
<a href="preselection.html">Pre-selection</a>
<a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html">Metrics</a>
<a href="gap_validation.html">Gap validation</a>
<a href="phenology.html">Phenology</a>
</div>
<div class="slider-container">
<input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div>
</div>
<div class="header">
<div class="header-col site-info">
<h1 id="siteName">Innsbruck</h1>
<h2 id="season">2024</h2>
<div class="phenocam-label">PhenoCam</div>
<div id="phenocamdate" class="phenocam-date"></div>
<img id="phenocamimage" class="phenocam-image" alt="PhenoCam">
</div>
<div class="header-col">
<div class="timeseries-label">Greenness Index Timeseries</div>
<canvas id="greennesstimeseries" class="timeseries"></canvas>
<div id="phenocamPhenology" class="timeseries-phenology"></div>
</div>
<div class="header-col">
<div id="sitemap" class="sitemap"></div>
</div>
</div>
<div class="site-selector">
<label for="siteSelect">Site: </label>
<select id="siteSelect"></select>
<label for="seasonSelect">Season: </label>
<select id="seasonSelect"></select>
</div>
<div class="scenario-selector">
<label for="scenarioSelect">Scenario: </label>
<select id="scenarioSelect">
<option value="aggressive_20">Aggressive (σ=20)</option>
<option value="aggressive_30">Aggressive (σ=30)</option>
<option value="nonaggressive_20">Non-aggressive (σ=20)</option>
<option value="nonaggressive_30">Non-aggressive (σ=30)</option>
</select>
</div>
<div class="maps">
<div class="map-container">
<h3>S2</h3>
<div class="timeseries-label">NDVI Timeseries</div>
<canvas id="s2timeseries" class="timeseries"></canvas>
<div class="timeseries-label">Greenness Index Timeseries</div>
<canvas id="s2gcctimeseries" class="timeseries"></canvas>
<div class="map-label">RGB Composite</div>
<div id="s2rgbdate" class="map-date"></div>
<div id="s2map" class="map"></div>
</div>
<div class="map-container">
<h3>Fusion</h3>
<div class="timeseries-label">NDVI Timeseries</div>
<canvas id="fusiontimeseries" class="timeseries"></canvas>
<div class="timeseries-label">Greenness Index Timeseries</div>
<canvas id="fusiongcctimeseries" class="timeseries"></canvas>
<div class="map-label">RGB Composite</div>
<div id="fusionrgbdate" class="map-date"></div>
<div id="fusionmap" class="map"></div>
</div>
<div class="map-container">
<h3>S3</h3>
<div class="timeseries-label">NDVI Timeseries</div>
<canvas id="s3timeseries" class="timeseries"></canvas>
<div class="timeseries-label">Greenness Index Timeseries</div>
<canvas id="s3gcctimeseries" class="timeseries"></canvas>
<div class="map-label">RGB Composite</div>
<div id="s3rgbdate" class="map-date"></div>
<div id="s3map" class="map"></div>
</div>
</div>
<div class="combined-plot">
<div class="combined-plot-label">Greenness Index Timeseries (S2 & Fusion)</div>
<canvas id="combinedgcctimeseries" class="combined-plot-canvas"></canvas>
</div>
<div class="combined-plot">
<div class="combined-plot-label">GCC Comparison: All Scenarios</div>
<canvas id="scenariosgcctimeseries" class="combined-plot-canvas"></canvas>
</div>
<div class="combined-plot">
<div class="combined-plot-label">Metrics vs PhenoCam (fusion scenarios)</div>
<p style="margin:4px 0 8px; font-size:11px; color:#555; max-width:720px;">
<b>R² vs mean</b> (JSON <code>r_squared</code>): generalized R² vs predicting mean PhenoCam each day — same numeric value as <b>NSE_PC</b>, not (Pearson <i>r</i>)²; can be negative. <b>nRMSE</b> (RMSE / mean PhenoCam). <b>ΔNSE_PC</b> = NSE_PC(σ20)NSE_PC(σ30): positive → σ20 better; negative → σ30 better. <b>Mean residual</b> (fusedPhenoCam): positive → fusion high vs PhenoCam on average; negative → low; compare BtI vs ItB in the same row (closer to 0 = less mean bias). Tables at the top when <code>metrics.json</code> has <code>derived</code> (regenerate with <code>metrics_stats.py</code> / <code>run.py</code>).
</p>
<div id="metricsTable" style="overflow-x: auto; margin-top: 10px;"></div>
</div>
</div>
<script>
proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
let start = new Date(2024, 0, 1);
const slider = document.getElementById("dateSlider");
const dateDisplay = document.getElementById("dateDisplay");
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const osmOpts = { attribution: "OpenStreetMap", opacity: 0.4 };
const mapOpts = { zoomControl: false };
let sitePosition = [47.116171, 11.320308];
let siteName = "innsbruck";
let season = "2024";
let sitesData = null;
const urlParams = new URLSearchParams(location.search);
const strategy = urlParams.get("strategy") || "aggressive";
const sigma = urlParams.get("sigma") || "20";
function getFusionPath() {
return `processed_${strategy}_sigma${sigma}`;
}
let allScenariosGCC = {};
let metricsData = null;
const siteMap = L.map("sitemap", { zoomControl: false }).setView(sitePosition, 4).addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 1 }));
const siteMarker = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(siteMap);
const maps = {
s2: L.map("s2map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
fusion: L.map("fusionmap", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
s3: L.map("s3map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts))
};
const overlays = { s2: null, fusion: null, s3: null };
const markers = { s2: null, fusion: null, s3: null };
let timeseries = { s2: [], fusion: [], s3: [] };
let greennessTimeseries = { s2: [], fusion: [], s3: [] };
let phenocamGreennessTimeseries = [];
/** @type {{ green_up_50pct_date: string | null, green_down_50pct_date: string | null } | null} */
let phenocamPhenology = null;
// Add site marker to all maps
for (const source of ["s2", "fusion", "s3"]) {
markers[source] = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:5px;height:5px;background:red;border:1px solid white;border-radius:50%;box-shadow:0 0 1px rgba(0,0,0,0.5);'></div>", iconSize: [5, 5] }) }).addTo(maps[source]);
}
let syncing = false;
const allMaps = Object.values(maps);
function updatePhenocamPhenologyText() {
const el = document.getElementById("phenocamPhenology");
if (!el) return;
if (!phenocamPhenology || (phenocamPhenology.green_up_50pct_date == null && phenocamPhenology.green_down_50pct_date == null)) {
el.textContent = "";
return;
}
const up = phenocamPhenology.green_up_50pct_date || "—";
const dn = phenocamPhenology.green_down_50pct_date || "—";
el.textContent = `* Green-up: ${up}\n* Green-down: ${dn}`;
}
const PHENOLOGY_VLINE = "rgba(150, 200, 255, 0.95)";
function strokePhenologyVlines(ctx, minDate, maxDate, dateRange, plotW, pad, plotH) {
if (!phenocamPhenology) return;
for (const iso of [phenocamPhenology.green_up_50pct_date, phenocamPhenology.green_down_50pct_date]) {
if (!iso) continue;
const d = new Date(iso);
if (isNaN(d.getTime()) || d < minDate || d > maxDate) continue;
const px = pad + ((d - minDate) / dateRange) * plotW;
ctx.save();
ctx.setLineDash([4, 3]);
ctx.strokeStyle = PHENOLOGY_VLINE;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(px, pad);
ctx.lineTo(px, pad + plotH);
ctx.stroke();
ctx.restore();
}
}
const syncMaps = (sourceMap) => {
if (syncing) return;
syncing = true;
const center = sourceMap.getCenter();
const zoom = sourceMap.getZoom();
allMaps.forEach(m => {
if (m !== sourceMap) m.setView(center, zoom);
});
syncing = false;
};
allMaps.forEach(m => {
m.on("moveend zoomend", () => syncMaps(m));
});
async function loadTimeseries() {
metricsData = null;
const fusionPath = getFusionPath();
const [s2, fusion, s3, s2gcc, fusiongcc, s3gcc, phenocam, phenPhen] = await Promise.all([
fetch(`data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s2/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/${fusionPath}/ndvi/fusion/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s3/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/gcc/s2/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/${fusionPath}/gcc/fusion/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/gcc/s3/timeseries.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/raw/phenocam/phenocam_gcc.json`).then(r => r.json()).catch(() => []),
fetch(`data/${siteName}/${season}/raw/phenocam/phenocam_phenology.json`).then(r => r.ok ? r.json() : null).catch(() => null)
]);
timeseries = { s2, fusion, s3 };
greennessTimeseries = { s2: s2gcc, fusion: fusiongcc, s3: s3gcc };
phenocamGreennessTimeseries = phenocam;
phenocamPhenology = phenPhen && (phenPhen.green_up_50pct_date != null || phenPhen.green_down_50pct_date != null) ? phenPhen : null;
updatePhenocamPhenologyText();
// Load all scenario GCC timeseries for comparison
const scenarios = [
{ name: "Aggressive σ20", path: "processed_aggressive_sigma20" },
{ name: "Aggressive σ30", path: "processed_aggressive_sigma30" },
{ name: "Non-aggressive σ20", path: "processed_nonaggressive_sigma20" },
{ name: "Non-aggressive σ30", path: "processed_nonaggressive_sigma30" }
];
const scenarioPromises = scenarios.map(s =>
fetch(`data/${siteName}/${season}/${s.path}/gcc/fusion/timeseries.json`).then(r => r.json()).catch(() => [])
);
const scenarioData = await Promise.all(scenarioPromises);
scenarios.forEach((s, i) => { allScenariosGCC[s.name] = scenarioData[i]; });
// Load metrics
try {
const metricsRes = await fetch(`data/${siteName}/${season}/metrics.json`);
if (metricsRes.ok) metricsData = await metricsRes.json();
} catch {}
drawTimeseries();
drawGreennessTimeseries();
drawPhenocamGreennessTimeseries();
drawCombinedGreennessTimeseries();
drawScenariosComparison();
drawMetricsTable();
}
function drawGreennessTimeseries() {
const currentDate = dateFromDays(parseInt(slider.value));
for (const source of ["s2", "fusion", "s3"]) {
const canvas = document.getElementById(`${source}gcctimeseries`);
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 120;
const w = canvas.width, h = canvas.height;
const pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
let data = greennessTimeseries[source].filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined);
if (!data.length) continue;
const dates = data.map(t => new Date(t.date));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const values = data.map(t => t.greenness_index);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#00aa00";
ctx.beginPath();
let first = true;
for (const t of data) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
strokePhenologyVlines(ctx, minDate, maxDate, dateRange, plotW, pad, plotH);
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest && closest.greenness_index !== null) {
const yPos = y(closest.greenness_index);
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.greenness_index.toFixed(3), xPos + 5, yPos - 5);
}
}
}
function drawPhenocamGreennessTimeseries() {
const canvas = document.getElementById("greennesstimeseries");
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 120;
const w = canvas.width, h = canvas.height;
const pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const data = phenocamGreennessTimeseries.filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const values = data.map(t => t.greenness_index);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#00aa00";
ctx.beginPath();
let first = true;
for (const t of data) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
strokePhenologyVlines(ctx, minDate, maxDate, dateRange, plotW, pad, plotH);
const currentDate = dateFromDays(parseInt(slider.value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest && closest.greenness_index !== null) {
const yPos = y(closest.greenness_index);
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.greenness_index.toFixed(3), xPos + 5, yPos - 5);
}
}
function drawCombinedGreennessTimeseries() {
const canvas = document.getElementById("combinedgcctimeseries");
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 120;
const w = canvas.width, h = canvas.height;
const pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const s2data = greennessTimeseries.s2.filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined);
const fusiondata = greennessTimeseries.fusion.filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined);
const phenocamdata = phenocamGreennessTimeseries.filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined);
if (!s2data.length && !fusiondata.length && !phenocamdata.length) return;
const allDates = [...s2data.map(t => new Date(t.date)), ...fusiondata.map(t => new Date(t.date)), ...phenocamdata.map(t => new Date(t.date))];
const minDate = new Date(Math.min(...allDates));
const maxDate = new Date(Math.max(...allDates));
const dateRange = maxDate - minDate || 1;
const allValues = [...s2data.map(t => t.greenness_index), ...fusiondata.map(t => t.greenness_index), ...phenocamdata.map(t => t.greenness_index)];
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
if (s2data.length) {
ctx.strokeStyle = "#ff6600";
ctx.beginPath();
let first = true;
for (const t of s2data) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
}
if (fusiondata.length) {
ctx.strokeStyle = "#9900cc";
ctx.beginPath();
let first = true;
for (const t of fusiondata) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
}
if (phenocamdata.length) {
ctx.strokeStyle = "#00aa00";
ctx.beginPath();
let first = true;
for (const t of phenocamdata) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
}
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of [...s2data, ...fusiondata, ...phenocamdata]) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
strokePhenologyVlines(ctx, minDate, maxDate, dateRange, plotW, pad, plotH);
const legendX = pad + plotW - 80, legendY = pad + 5;
ctx.font = "9px sans-serif";
if (s2data.length) { ctx.strokeStyle = "#ff6600"; ctx.beginPath(); ctx.moveTo(legendX, legendY); ctx.lineTo(legendX + 15, legendY); ctx.stroke(); ctx.fillStyle = "#000"; ctx.fillText("S2", legendX + 18, legendY + 3); }
if (fusiondata.length) { ctx.strokeStyle = "#9900cc"; ctx.beginPath(); ctx.moveTo(legendX, legendY + 12); ctx.lineTo(legendX + 15, legendY + 12); ctx.stroke(); ctx.fillStyle = "#000"; ctx.fillText("Fusion", legendX + 18, legendY + 15); }
if (phenocamdata.length) { ctx.strokeStyle = "#00aa00"; ctx.beginPath(); ctx.moveTo(legendX, legendY + 24); ctx.lineTo(legendX + 15, legendY + 24); ctx.stroke(); ctx.fillStyle = "#000"; ctx.fillText("PhenoCam", legendX + 18, legendY + 27); }
const currentDate = dateFromDays(parseInt(slider.value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
}
function drawScenariosComparison() {
const canvas = document.getElementById("scenariosgcctimeseries");
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 120;
const w = canvas.width, h = canvas.height;
const pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const scenarios = Object.keys(allScenariosGCC);
const allData = scenarios.map(name => allScenariosGCC[name].filter(t => t.date && t.greenness_index !== null && t.greenness_index !== undefined));
if (!allData.some(d => d.length)) return;
const allDates = allData.flatMap(d => d.map(t => new Date(t.date)));
const minDate = new Date(Math.min(...allDates));
const maxDate = new Date(Math.max(...allDates));
const dateRange = maxDate - minDate || 1;
const allValues = allData.flatMap(d => d.map(t => t.greenness_index));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
const colors = ["#ff6600", "#9900cc", "#0066ff", "#00aa00"];
scenarios.forEach((name, i) => {
const data = allData[i];
if (data.length) {
ctx.strokeStyle = colors[i];
ctx.beginPath();
let first = true;
for (const t of data) {
const px = x(t.date), py = y(t.greenness_index);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
}
});
const legendX = pad + plotW - 120, legendY = pad + 5;
ctx.font = "9px sans-serif";
scenarios.forEach((name, i) => {
if (allData[i].length) {
ctx.strokeStyle = colors[i];
ctx.beginPath();
ctx.moveTo(legendX, legendY + i * 12);
ctx.lineTo(legendX + 15, legendY + i * 12);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.fillText(name, legendX + 18, legendY + i * 12 + 3);
}
});
strokePhenologyVlines(ctx, minDate, maxDate, dateRange, plotW, pad, plotH);
}
function drawMetricsTable() {
const container = document.getElementById("metricsTable");
const hasAnyData = timeseries.s2.length || timeseries.fusion.length || timeseries.s3.length || phenocamGreennessTimeseries.length;
if (!hasAnyData) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>No data for this site/season.</p>";
return;
}
if (!metricsData || !metricsData.temporal) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>Metrics not available. Run the pipeline (run.py) or metrics_stats.py to generate.</p>";
return;
}
const fmtD = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n.toFixed(3) : "—";
};
const d = metricsData.derived;
let html = "";
if (d && d.delta_nse_pc_sigma20_minus_sigma30) {
const dn = d.delta_nse_pc_sigma20_minus_sigma30;
let anyDelta = false;
for (const mode of ["bti", "itb"]) {
for (const strat of ["aggressive", "nonaggressive"]) {
if (Number.isFinite(Number(dn[mode]?.[strat]))) anyDelta = true;
}
}
html +=
"<p style='margin:0 0 2px; font-size:11px; font-weight:600;'>ΔNSE_PC (σ20 σ30)</p>";
html +=
"<p style='margin:0 0 6px; font-size:10px; color:#555; line-height:1.35;'>NSE_PC(σ20) NSE_PC(σ30): <b>+</b> → σ20 better, <b></b> → σ30 better.</p>";
html +=
"<table style='width:100%; border-collapse:collapse; font-size:11px; margin-bottom:10px;'><thead><tr style='background:#f5f5f5;'><th style='padding:6px; text-align:left;'>Mode</th><th style='padding:6px; text-align:left;'>Strategy</th><th style='padding:6px; text-align:right;'>ΔNSE_PC</th></tr></thead><tbody>";
for (const mode of ["bti", "itb"]) {
for (const strat of ["aggressive", "nonaggressive"]) {
const v = dn[mode]?.[strat];
html += `<tr style='border-bottom:1px solid #eee;'><td style='padding:6px;'>${mode.toUpperCase()}</td><td style='padding:6px;'>${strat}</td><td style='padding:6px; text-align:right; font-family:monospace;'>${fmtD(v)}</td></tr>`;
}
}
html += "</tbody></table>";
if (!anyDelta) {
html +=
"<p style='margin:-4px 0 10px; font-size:10px; color:#666;'>ΔNSE_PC needs both σ20 and σ30 rows in temporal (BtI and ItB) with nse_pc.</p>";
}
}
if (d && d.bti_vs_itb_mean_residual && d.bti_vs_itb_mean_residual.length) {
html +=
"<p style='margin:0 0 2px; font-size:11px; font-weight:600;'>Mean residual (fused PhenoCam): BtI vs ItB</p>";
html +=
"<p style='margin:0 0 6px; font-size:10px; color:#555; line-height:1.35;'>Each cell: mean(fusedPhenoCam) on matched dates. <b>+</b> overestimates, <b></b> underestimates; <b>~0</b> little mean bias (see R² vs mean / nRMSE / NSE_PC for overall fit). Same row: column closer to 0 → less systematic offset vs PhenoCam (RQ1.1).</p>";
html +=
"<table style='width:100%; border-collapse:collapse; font-size:11px; margin-bottom:10px;'><thead><tr style='background:#f5f5f5;'><th style='padding:6px;'>Strategy</th><th style='padding:6px;'>σ</th><th style='padding:6px; text-align:right;'>BtI</th><th style='padding:6px; text-align:right;'>ItB</th></tr></thead><tbody>";
for (const row of d.bti_vs_itb_mean_residual) {
html += `<tr style='border-bottom:1px solid #eee;'><td style='padding:6px;'>${row.strategy}</td><td style='padding:6px;'>${row.sigma}</td><td style='padding:6px; text-align:right; font-family:monospace;'>${fmtD(row.mean_residual_bti)}</td><td style='padding:6px; text-align:right; font-family:monospace;'>${fmtD(row.mean_residual_itb)}</td></tr>`;
}
html += "</tbody></table>";
}
const scenarios = ["aggressive_sigma20", "aggressive_sigma30", "nonaggressive_sigma20", "nonaggressive_sigma30"];
const scenarioNames = ["Aggressive σ20", "Aggressive σ30", "Non-aggressive σ20", "Non-aggressive σ30"];
const metrics = ["r_squared", "nrmse", "nse_pc"];
const metricLabels = { r_squared: "R² vs mean", nrmse: "nRMSE", nse_pc: "NSE_PC" };
html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";
html += "<thead><tr style='background:#f5f5f5; border-bottom:2px solid #ccc;'>";
html += "<th style='padding:8px; text-align:left;'>Scenario</th>";
metrics.forEach(m => html += `<th style='padding:8px; text-align:right;'>${metricLabels[m]}</th>`);
html += "</tr></thead><tbody>";
// Add S2 baseline row
if (metricsData.baseline && metricsData.baseline.s2) {
const data = metricsData.baseline.s2;
html += `<tr style='border-bottom:2px solid #ccc; background:#f9f9f9;'>`;
html += `<td style='padding:6px 8px; font-weight:600;'>S2 (baseline)</td>`;
metrics.forEach(m => {
const val = m === "nse_pc" ? (data.nse_pc ?? data.nse) : data[m];
const fmt =
val !== null && val !== undefined
? m === "nrmse"
? val.toFixed(4)
: val.toFixed(3)
: "—";
html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`;
});
html += "</tr>";
}
// Add fusion scenario rows
scenarios.forEach((scenario, i) => {
const data = metricsData.temporal[scenario];
if (!data) return;
html += `<tr style='border-bottom:1px solid #eee;'>`;
html += `<td style='padding:6px 8px; font-weight:500;'>${scenarioNames[i]}</td>`;
metrics.forEach(m => {
const val = m === "nse_pc" ? (data.nse_pc ?? data.nse) : data[m];
const fmt =
val !== null && val !== undefined
? m === "nrmse"
? val.toFixed(4)
: val.toFixed(3)
: "—";
html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`;
});
html += "</tr>";
});
html += "</tbody></table>";
// Add phenocam stats info if available
if (metricsData.phenocam_stats) {
const stats = metricsData.phenocam_stats;
html += `<p style='margin-top:10px; font-size:10px; color:#666;'>PhenoCam GCC samples (ground truth): n = ${stats.n_samples}</p>`;
}
container.innerHTML = html;
}
function drawTimeseries() {
const currentDate = dateFromDays(parseInt(slider.value));
for (const source of ["s2", "fusion", "s3"]) {
const canvas = document.getElementById(`${source}timeseries`);
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 120;
const w = canvas.width, h = canvas.height;
const pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
// Get all data with valid dates (dates are now in ISO format from JSON)
let data = timeseries[source].filter(t => {
if (!t.date) return false;
const date = new Date(t.date);
return !isNaN(date.getTime());
});
// Filter to only entries with non-null NDVI values for plotting
const dataWithNdvi = data.filter(t => t.ndvi !== null);
if (!dataWithNdvi.length) continue;
// Use data with NDVI for plotting
data = dataWithNdvi;
const dates = data.map(t => new Date(t.date));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1; // Avoid division by zero
const ndvi = data.map(t => t.ndvi);
const minNdvi = Math.min(...ndvi);
const maxNdvi = Math.max(...ndvi);
const ndviRange = maxNdvi - minNdvi || 1; // Avoid division by zero
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minNdvi) / ndviRange) * plotH;
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minNdvi.toFixed(2), 2, pad + plotH + 10);
ctx.fillText(maxNdvi.toFixed(2), 2, pad + 3);
ctx.strokeStyle = "#0066ff";
ctx.beginPath();
let first = true;
for (const t of data) {
const px = x(t.date), py = y(t.ndvi);
if (first) { ctx.moveTo(px, py); first = false; }
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const validData = data.filter(t => !isNaN(new Date(t.date).getTime()));
if (validData.length === 0) continue;
const closest = validData.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest && closest.ndvi !== null) {
const yPos = y(closest.ndvi);
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.ndvi.toFixed(3), xPos + 5, yPos - 5);
}
}
}
async function findFile(dateStr, source) {
const target = new Date(dateStr);
const basePath = source === "fusion" ? getFusionPath() : `processed_${strategy}_sigma${sigma}`;
const yearEnd = new Date(parseInt(season), 11, 31);
const seasonStart = start.getTime();
const seasonEnd = yearEnd.getTime();
for (let offset = 0; offset <= 365; offset++) {
const datesToTry = offset === 0
? [target]
: [new Date(target.getTime() - offset * 86400000), new Date(target.getTime() + offset * 86400000)];
for (const d of datesToTry) {
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
const date = d.toISOString().split("T")[0].replace(/-/g, "");
const filename = `${date}_0.geotiff`;
try {
const res = await fetch(`data/${siteName}/${season}/${basePath}/${source}/${filename}`, { method: 'HEAD' });
if (res.ok) return filename;
} catch {}
}
}
return null;
}
function transformBounds(bbox, fromCRS) {
const [sw, ne] = [proj4(fromCRS, "EPSG:4326", [bbox[0], bbox[1]]), proj4(fromCRS, "EPSG:4326", [bbox[2], bbox[3]])];
return [[sw[1], sw[0]], [ne[1], ne[0]]];
}
async function loadGeotiff(source, filename) {
const basePath = source === "fusion" ? getFusionPath() : `processed_${strategy}_sigma${sigma}`;
const path = `data/${siteName}/${season}/${basePath}/${source}/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer());
const image = await tiff.getImage();
const rasters = await image.readRasters();
const width = image.getWidth();
const height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326";
const bandIndices = [0, 1, 2];
const [blue, green, red] = bandIndices.map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rNorm, gNorm, bNorm] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rNorm.length; i++) {
const idx = i * 4;
if (rNorm[i] === 0 && gNorm[i] === 0 && bNorm[i] === 0) {
imgData.data[idx + 3] = 0;
} else {
imgData.data[idx] = rNorm[i];
imgData.data[idx + 1] = gNorm[i];
imgData.data[idx + 2] = bNorm[i];
imgData.data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
if (overlays[source]) maps[source].removeLayer(overlays[source]);
overlays[source] = L.imageOverlay(canvas.toDataURL(), bounds, { opacity: 0.95 }).addTo(maps[source]);
maps[source].fitBounds(bounds);
// Extract date from filename to show the actual date of the file found (closest available date)
const dateStr = filename.split("_")[0];
const date = `${dateStr.slice(0,4)}-${dateStr.slice(4,6)}-${dateStr.slice(6,8)}`;
document.getElementById(`${source}rgbdate`).textContent = date;
}
const fmtDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const dateFromDays = (days) => fmtDate(new Date(start.getTime() + days * 86400000));
const daysFromDate = (dateStr) => {
const [y, m, d] = dateStr.split("-").map(Number);
return Math.floor((new Date(y, m - 1, d) - start) / 86400000);
};
async function loadPhenoCam(dateStr) {
const target = new Date(dateStr);
for (let offset = 0; offset < 15; offset++) {
for (const dir of [0, -1, 1]) {
const d = new Date(target);
d.setDate(d.getDate() + offset * dir);
const date = d.toISOString().split("T")[0].replace(/-/g, "");
const url = `data/${siteName}/${season}/raw/phenocam/${date}.jpg`;
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok) {
document.getElementById("phenocamimage").src = url;
document.getElementById("phenocamdate").textContent = `${date.slice(0,4)}-${date.slice(4,6)}-${date.slice(6,8)}`;
return;
}
} catch {}
}
}
document.getElementById("phenocamimage").src = "";
document.getElementById("phenocamdate").textContent = "";
}
async function updateImages() {
const date = dateFromDays(parseInt(slider.value));
dateDisplay.textContent = date;
const params = new URLSearchParams();
params.set("site", siteName);
params.set("season", season);
params.set("date", date);
params.set("strategy", strategy);
if (sigma !== "20") params.set("sigma", sigma);
history.replaceState({}, "", `?${params}`);
drawTimeseries();
drawGreennessTimeseries();
drawPhenocamGreennessTimeseries();
drawCombinedGreennessTimeseries();
drawScenariosComparison();
drawMetricsTable();
for (const source of ["s2", "fusion", "s3"]) {
const filename = await findFile(date, source);
if (filename) {
try {
await loadGeotiff(source, filename);
} catch (e) {
console.error(`Error loading ${source}:`, e);
}
}
}
await loadPhenoCam(date);
}
const scenarioSelect = document.getElementById("scenarioSelect");
scenarioSelect.value = `${strategy}_${sigma}`;
scenarioSelect.addEventListener("change", function() {
const [newStrategy, newSigma] = this.value.split("_");
const params = new URLSearchParams(location.search);
params.set("strategy", newStrategy);
params.set("sigma", newSigma);
window.location.search = params.toString();
});
const siteSelect = document.getElementById("siteSelect");
const seasonSelect = document.getElementById("seasonSelect");
function getSiteBySitename(sitename) {
return sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
let availableSiteSeasons = {}; // { sitename: [season, ...] }
function populateSeasonOptions(sitename) {
seasonSelect.innerHTML = "";
const seasons = availableSiteSeasons[sitename] || [];
for (const s of seasons) {
const opt = document.createElement("option");
opt.value = s;
opt.textContent = s;
seasonSelect.appendChild(opt);
}
}
async function probeDataExists(sitename, season) {
try {
const res = await fetch(`data/${sitename}/${season}/metrics.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
async function setSiteSeason(newSiteName, newSeason) {
const site = getSiteBySitename(newSiteName);
let pos;
let description;
if (site) {
const [lon, lat] = site.geometry.coordinates;
pos = [lat, lon];
description = site.properties.description || newSiteName;
} else {
pos = [47.116171, 11.320308];
description = newSiteName;
}
siteName = newSiteName;
season = newSeason;
sitePosition = pos;
start = new Date(parseInt(season), 0, 1);
const yearEnd = new Date(parseInt(season), 11, 31);
slider.max = Math.ceil((yearEnd - start) / 86400000);
document.getElementById("siteName").textContent = description;
document.getElementById("season").textContent = season;
siteMap.setView(sitePosition, 4);
siteMarker.setLatLng(sitePosition);
for (const source of ["s2", "fusion", "s3"]) {
maps[source].setView(sitePosition, 12);
markers[source].setLatLng(sitePosition);
}
const params = new URLSearchParams(location.search);
params.set("site", siteName);
params.set("season", season);
history.replaceState({}, "", `?${params}`);
await loadTimeseries();
const urlDate = params.get("date");
if (urlDate) slider.value = daysFromDate(urlDate);
await updateImages();
}
async function init() {
try {
const res = await fetch("data/sites.geojson");
if (!res.ok) throw new Error("Could not load sites");
sitesData = await res.json();
} catch (e) {
console.error("Failed to load sites.geojson:", e);
sitesData = { features: [] };
}
const features = sitesData.features || [];
availableSiteSeasons = {};
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeDataExists(sn, s)) withData.push(s);
}
availableSiteSeasons[sn] = withData.length ? withData : seasonsFromGeo;
}
const availableSites = Object.keys(availableSiteSeasons);
siteSelect.innerHTML = "";
if (availableSites.length === 0) {
const opt = document.createElement("option");
opt.value = "innsbruck";
opt.textContent = "innsbruck";
siteSelect.appendChild(opt);
availableSiteSeasons.innsbruck = ["2024"];
} else {
for (const sn of availableSites.sort()) {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
}
}
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = urlSite && availableSites.includes(urlSite) ? urlSite : availableSites[0] || "innsbruck";
siteName = initialSite;
siteSelect.value = initialSite;
populateSeasonOptions(initialSite);
const seasons = availableSiteSeasons[initialSite] || [];
const initialSeason = urlSeason && seasons.includes(urlSeason) ? urlSeason : (seasons[0] || "2024");
season = initialSeason;
seasonSelect.value = initialSeason;
siteSelect.addEventListener("change", function() {
const sn = this.value;
populateSeasonOptions(sn);
const seas = availableSiteSeasons[sn] || [];
seasonSelect.value = seas[0] || season;
setSiteSeason(sn, seasonSelect.value);
});
seasonSelect.addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
await setSiteSeason(initialSite, initialSeason);
}
slider.addEventListener("input", updateImages);
init();
</script>
</body>
</html>