efast-phenocam-validation/webapp/index.html
Felix Delattre a037e6b4fd Foo
2026-03-04 18:50:29 +01:00

972 lines
48 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; }
.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>
</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>
<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 Comparison: All Scenarios</div>
<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 = [];
// 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);
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] = 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(() => [])
]);
timeseries = { s2, fusion, s3 };
greennessTimeseries = { s2: s2gcc, fusion: fusiongcc, s3: s3gcc };
phenocamGreennessTimeseries = phenocam;
// 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);
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
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);
const currentDate = dateFromDays(parseInt(slider.value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
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);
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.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);
}
});
}
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 scenarios = ["aggressive_sigma20", "aggressive_sigma30", "nonaggressive_sigma20", "nonaggressive_sigma30"];
const scenarioNames = ["Aggressive σ20", "Aggressive σ30", "Non-aggressive σ20", "Non-aggressive σ30"];
const metrics = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse"];
const metricLabels = { pearson_r: "r", r_squared: "R²", rmse: "RMSE", mae: "MAE", nrmse: "nRMSE", nse: "NSE" };
let 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 = data[m];
const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse" ? val.toFixed(3) : val.toFixed(4)) : "—";
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 = data[m];
const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse" ? val.toFixed(3) : val.toFixed(4)) : "—";
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 stats: mean=${stats.mean.toFixed(3)}, std=${stats.std.toFixed(3)}, 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>