efast-phenocam-validation/webapp/s2-timeseries.html
Felix Delattre 3919b8e871 Renaming.
2026-02-20 21:57:42 +01:00

492 lines
23 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>S2 Band Reflectance Timeseries</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; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.selectors { margin-bottom: 20px; }
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
h1 { margin: 0 0 5px 0; font-size: 22px; }
h2 { margin: 0 0 15px 0; font-size: 16px; color: #666; }
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
#dateSlider { width: 100%; margin: 15px 0; }
#dateDisplay { text-align: center; font-size: 14px; color: #666; }
.map-label { font-size: 12px; margin-bottom: 3px; color: #666; }
.map-date { font-size: 11px; margin-top: 3px; color: #999; }
#s2map { height: 400px; border: 1px solid #ccc; margin-top: 10px; }
.leaflet-image-layer { image-rendering: pixelated; }
.leaflet-control-attribution { display: none; }
</style>
</head>
<body>
<div class="container">
<h1 id="siteName">Innsbruck</h1>
<h2 id="season">2024</h2>
<div class="selectors">
<label>Site:</label>
<select id="siteSelect"></select>
<label>Season:</label>
<select id="seasonSelect"></select>
<label>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>
<input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div>
<div class="map-label">S2 RGB (closest available)</div>
<div id="s2rgbdate" class="map-date"></div>
<div id="s2map"></div>
<div id="bandPlots"></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");
const BANDS = [
{ key: "b02", label: "B02 (Blue)", color: "#0066ff" },
{ key: "b03", label: "B03 (Green)", color: "#00aa00" },
{ key: "b04", label: "B04 (Red)", color: "#cc0000" },
{ key: "b8a", label: "B8A (NIR)", color: "#9900cc" }
];
let siteName = "innsbruck", season = "2024";
let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1);
let timeseries = [];
let gccTimeseries = [];
let ndviTimeseries = [];
let availableSiteSeasons = {};
let s2Map = null, s2Overlay = null, s2Marker = null;
const urlParams = new URLSearchParams(location.search);
const [strategy, sigma] = (urlParams.get("scenario") || "aggressive_20").split("_");
function getBasePath() {
return `processed_${strategy}_sigma${sigma || "20"}`;
}
function fmtDate(d) {
return `${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);
};
function drawBandPlot(canvasId, bandKey, bandLabel, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = timeseries.filter(t => t[bandKey] != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t[bandKey]);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), 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.clearRect(0, 0, w, h);
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(4), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(4), 2, pad + 3);
ctx.strokeStyle = color;
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t[bandKey]);
i === 0 ? ctx.moveTo(px, py) : 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(document.getElementById("dateSlider").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) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest[bandKey].toFixed(4), xPos + 5, y(closest[bandKey]) - 5);
}
}
function drawNdviPlot() {
const canvas = document.getElementById("plot_ndvi");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = ndviTimeseries.filter(t => t.ndvi != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.ndvi);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), 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.clearRect(0, 0, w, h);
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 = "#2d7a3e";
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.ndvi);
i === 0 ? ctx.moveTo(px, py) : 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(document.getElementById("dateSlider").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) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.ndvi.toFixed(3), xPos + 5, y(closest.ndvi) - 5);
}
}
function drawGccPlot() {
const canvas = document.getElementById("plot_gcc");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = gccTimeseries.filter(t => t.greenness_index != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.greenness_index);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), 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.clearRect(0, 0, w, h);
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();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.greenness_index);
i === 0 ? ctx.moveTo(px, py) : 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(document.getElementById("dateSlider").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) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.greenness_index.toFixed(3), xPos + 5, y(closest.greenness_index) - 5);
}
}
function drawAllPlots() {
drawNdviPlot();
drawGccPlot();
BANDS.forEach(b => drawBandPlot(`plot_${b.key}`, b.key, b.label, b.color));
}
async function loadTimeseries() {
const base = `../data/${siteName}/${season}/${getBasePath()}`;
try {
const [bandsRes, gccRes, ndviRes] = await Promise.all([
fetch(`${base}/s2_bands/timeseries.json`),
fetch(`${base}/gcc/s2/timeseries.json`),
fetch(`${base}/ndvi/s2/timeseries.json`)
]);
timeseries = bandsRes.ok ? await bandsRes.json() : [];
gccTimeseries = gccRes.ok ? await gccRes.json() : [];
ndviTimeseries = ndviRes.ok ? await ndviRes.json() : [];
} catch {
timeseries = [];
gccTimeseries = [];
ndviTimeseries = [];
}
document.getElementById("bandPlots").innerHTML =
`<div class="plot-label">S2 NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>` +
`<div class="plot-label">S2 GCC (Greenness Index)</div><canvas id="plot_gcc" class="plot"></canvas>` +
BANDS.map(b => `<div class="plot-label">${b.label}</div><canvas id="plot_${b.key}" class="plot"></canvas>`).join("");
const yearEnd = new Date(parseInt(season), 11, 31);
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
drawAllPlots();
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
updateS2Imagery();
}
async function probeDataExists(sitename, s) {
try {
const res = await fetch(`../data/${sitename}/${s}/processed_aggressive_sigma20/s2_bands/timeseries.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
function getSiteBySitename(sitename) {
return window.sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
async function setSiteSeason(newSite, newSeason) {
siteName = newSite;
season = newSeason;
start = new Date(parseInt(season), 0, 1);
const site = getSiteBySitename(newSite);
if (site?.geometry?.coordinates) {
const [lon, lat] = site.geometry.coordinates;
sitePosition = [lat, lon];
}
if (s2Map) { s2Map.setView(sitePosition, 12); if (s2Marker) s2Marker.setLatLng(sitePosition); }
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
document.getElementById("season").textContent = season;
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) document.getElementById("dateSlider").value = daysFromDate(urlDate);
}
async function init() {
try {
const res = await fetch("../data/sites.geojson");
window.sitesData = res.ok ? await res.json() : { features: [] };
} catch {
window.sitesData = { features: [] };
}
const features = window.sitesData.features || [];
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);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
const siteSelect = document.getElementById("siteSelect");
siteSelect.innerHTML = "";
(availableSites.length ? availableSites.sort() : ["innsbruck"]).forEach(sn => {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
if (!availableSiteSeasons[sn]) availableSiteSeasons[sn] = ["2024"];
});
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = (urlSite && availableSiteSeasons[urlSite]) ? urlSite : (availableSites[0] || "innsbruck");
const initialSeason = (urlSeason && (availableSiteSeasons[initialSite] || []).includes(urlSeason)) ? urlSeason : ((availableSiteSeasons[initialSite] || [])[0] || "2024");
siteSelect.value = initialSite;
document.getElementById("seasonSelect").innerHTML = (availableSiteSeasons[initialSite] || []).map(s =>
`<option value="${s}">${s}</option>`
).join("");
document.getElementById("seasonSelect").value = initialSeason;
document.getElementById("scenarioSelect").value = `${strategy}_${sigma || "20"}`;
const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) {
const [lon, lat] = initSite.geometry.coordinates;
sitePosition = [lat, lon];
}
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
s2Map = L.map("s2map", { zoomControl: false }).setView(sitePosition, 12)
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
s2Marker = 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(s2Map);
siteSelect.addEventListener("change", function() {
const sn = this.value;
const seas = availableSiteSeasons[sn] || [];
document.getElementById("seasonSelect").innerHTML = seas.map(s => `<option value="${s}">${s}</option>`).join("");
document.getElementById("seasonSelect").value = seas[0] || "2024";
setSiteSeason(sn, document.getElementById("seasonSelect").value);
});
document.getElementById("seasonSelect").addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
document.getElementById("scenarioSelect").addEventListener("change", function() {
const p = new URLSearchParams(location.search);
p.set("scenario", this.value);
window.location.search = p.toString();
});
await setSiteSeason(initialSite, initialSeason);
}
document.getElementById("dateSlider").addEventListener("input", function() {
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
drawAllPlots();
updateS2Imagery();
});
function closestFilename(dateStr) {
const target = new Date(dateStr);
const withData = timeseries.filter(t => t.filename);
if (!withData.length) return null;
const closest = withData.reduce((c, t) =>
Math.abs(new Date(t.date) - target) < Math.abs(new Date(c.date) - target) ? t : c
);
return closest.filename;
}
function transformBounds(bbox, fromCRS) {
const sw = proj4(fromCRS, "EPSG:4326", [bbox[0], bbox[1]]);
const ne = proj4(fromCRS, "EPSG:4326", [bbox[2], bbox[3]]);
return [[sw[1], sw[0]], [ne[1], ne[0]]];
}
async function loadS2Geotiff(filename) {
const path = `../data/${siteName}/${season}/${getBasePath()}/s2/${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(), 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 [blue, green, red] = [0, 1, 2].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 [rN, gN, bN] = [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 < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[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);
return { dataUrl: canvas.toDataURL(), bounds };
}
async function updateS2Imagery() {
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const filename = closestFilename(dateStr);
if (!filename || !s2Map) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
return;
}
try {
const { dataUrl, bounds } = await loadS2Geotiff(filename);
if (s2Overlay) s2Map.removeLayer(s2Overlay);
s2Overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(s2Map);
s2Map.fitBounds(bounds);
const d = filename.split("_")[0];
document.getElementById("s2rgbdate").textContent = `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
} catch (e) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
}
}
init();
</script>
</body>
</html>