Switching horses.
This commit is contained in:
parent
25cbd97662
commit
e3e14027fc
51 changed files with 5078 additions and 11678 deletions
|
|
@ -1,397 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Fusion Viewer</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="common.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: 15px; 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; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.header-sticky { position: sticky; top: 0; background: white; z-index: 1000; border-bottom: 1px solid #ccc; padding-bottom: 20px; margin-bottom: 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; }
|
||||
.season-row { padding-bottom: 15px; }
|
||||
h2 { margin: 0; font-size: 16px; color: #666; display: inline; }
|
||||
.download-links { margin-left: 10px; font-size: 14px; }
|
||||
.download-links a { margin-right: 8px; color: #0066cc; text-decoration: none; }
|
||||
.download-links a:hover { text-decoration: underline; }
|
||||
#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; }
|
||||
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
|
||||
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
|
||||
#fusionMap { height: 500px; border: 1px solid #ccc; margin-top: 10px; }
|
||||
.leaflet-image-layer { image-rendering: pixelated; }
|
||||
.leaflet-control-attribution { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header-sticky">
|
||||
<div class="nav">
|
||||
<a href="index.html">Full</a>
|
||||
<a href="preselection.html">Pre-selection</a>
|
||||
<a href="prepared.html">Prepared</a>
|
||||
<a href="fusion.html" class="active">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>
|
||||
<h1 id="siteName">Innsbruck</h1>
|
||||
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
<label>Strategy:</label>
|
||||
<select id="strategySelect">
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="nonaggressive">Non-aggressive</option>
|
||||
</select>
|
||||
<label>Sigma:</label>
|
||||
<select id="sigmaSelect">
|
||||
<option value="20">σ=20</option>
|
||||
<option value="30">σ=30</option>
|
||||
</select>
|
||||
<label>Mode:</label>
|
||||
<select id="fusionModeSelect" title="BtI = reflectance fusion; ItB = GCC fusion">
|
||||
<option value="bti">BtI (REFL)</option>
|
||||
<option value="itb">ItB (GCC)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="range" id="dateSlider" min="0" max="365" value="0">
|
||||
<div id="dateDisplay">2024-01-01</div>
|
||||
</div>
|
||||
<div class="map-label" id="mapLabelFusion">Fusion RGB (closest available)</div>
|
||||
<div id="mapDate" class="map-date"></div>
|
||||
<div id="fusionMap"></div>
|
||||
<div id="plots">
|
||||
<div class="plot-label">NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>
|
||||
<div class="plot-label">GCC</div><canvas id="plot_gcc" class="plot"></canvas>
|
||||
<div class="plot-label">B02 (Blue)</div><canvas id="plot_b02" class="plot"></canvas>
|
||||
<div class="plot-label">B03 (Green)</div><canvas id="plot_b03" class="plot"></canvas>
|
||||
<div class="plot-label">B04 (Red)</div><canvas id="plot_b04" class="plot"></canvas>
|
||||
<div class="plot-label">B8A (NIR)</div><canvas id="plot_b8a" class="plot"></canvas>
|
||||
</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 siteName = "innsbruck", season = "2024";
|
||||
let strategy = "aggressive", sigma = "20", fusionMode = "bti";
|
||||
let sitePosition = [47.116171, 11.320308];
|
||||
let start = new Date(2024, 0, 1);
|
||||
let availableSiteSeasons = {};
|
||||
let fusionMap = null, overlay = null, marker = null;
|
||||
let ndviTs = [], gccTs = [], bandsTs = [];
|
||||
const BANDS = [{key:"b02",color:"#0066ff"},{key:"b03",color:"#00aa00"},{key:"b04",color:"#cc0000"},{key:"b8a",color:"#9900cc"}];
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
function getPreparedBase() {
|
||||
return fusionMode === "itb" ? `prepared_${strategy}_itb` : `prepared_${strategy}`;
|
||||
}
|
||||
|
||||
function getFusionDir() {
|
||||
const sub = sigma === "30" ? "fusion_sigma30" : "fusion";
|
||||
return `data/${siteName}/${season}/${getPreparedBase()}/${sub}`;
|
||||
}
|
||||
|
||||
function getFusionTimeseriesDir() {
|
||||
return sigma === "30" ? "fusion_sigma30" : "fusion";
|
||||
}
|
||||
|
||||
async function loadTimeseries() {
|
||||
const sub = getFusionTimeseriesDir();
|
||||
const base = `data/${siteName}/${season}/${getPreparedBase()}`;
|
||||
try {
|
||||
if (fusionMode === "itb") {
|
||||
const g = await fetch(`${base}/gcc/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : []));
|
||||
ndviTs = [];
|
||||
gccTs = g;
|
||||
bandsTs = [];
|
||||
} else {
|
||||
const [n, g, b] = await Promise.all([
|
||||
fetch(`${base}/ndvi/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(`${base}/gcc/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(`${base}/bands/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
ndviTs = n;
|
||||
gccTs = g;
|
||||
bandsTs = b;
|
||||
}
|
||||
} catch {
|
||||
ndviTs = [];
|
||||
gccTs = [];
|
||||
bandsTs = [];
|
||||
}
|
||||
drawPlots();
|
||||
updateDownloadLinks();
|
||||
}
|
||||
|
||||
function drawPlot(canvasId, data, key, 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 pts = data.filter(t => t[key] != null);
|
||||
if (!pts.length) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#999"; ctx.font = "12px sans-serif"; ctx.fillText("No data", pad, pad + plotH / 2); return; }
|
||||
const dates = pts.map(t => new Date(t.date));
|
||||
const vals = pts.map(t => t[key]);
|
||||
const minD = new Date(Math.min(...dates)), maxD = new Date(Math.max(...dates));
|
||||
const minV = Math.min(...vals), maxV = Math.max(...vals);
|
||||
const dRange = maxD - minD || 1, vRange = maxV - minV || 1;
|
||||
const x = d => pad + ((new Date(d) - minD) / dRange) * plotW;
|
||||
const y = v => pad + plotH - ((v - minV) / vRange) * 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(minV.toFixed(3), 2, pad + plotH + 10);
|
||||
ctx.fillText(maxV.toFixed(3), 2, pad + 3);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
pts.forEach((t, i) => { const px = x(t.date), py = y(t[key]); i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); });
|
||||
ctx.stroke();
|
||||
const curDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const xPos = x(curDate);
|
||||
ctx.strokeStyle = "#f00";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(xPos, pad); ctx.lineTo(xPos, pad + plotH); ctx.stroke();
|
||||
const closest = pts.reduce((c, t) => Math.abs(new Date(t.date) - new Date(curDate)) < Math.abs(new Date(c.date) - new Date(curDate)) ? t : c);
|
||||
if (closest) { ctx.fillStyle = "#f00"; ctx.font = "bold 10px sans-serif"; ctx.fillText(closest[key].toFixed(3), xPos + 5, y(closest[key]) - 5); }
|
||||
}
|
||||
|
||||
function drawPlots() {
|
||||
drawPlot("plot_ndvi", ndviTs, "ndvi", "#2d7a3e");
|
||||
drawPlot("plot_gcc", gccTs, "greenness_index", "#00aa00");
|
||||
BANDS.forEach(b => drawPlot(`plot_${b.key}`, bandsTs, b.key, b.color));
|
||||
}
|
||||
|
||||
function updateDownloadLinks() {
|
||||
const el = document.getElementById("downloadLinks");
|
||||
if (!el) return;
|
||||
const sub = getFusionTimeseriesDir();
|
||||
const prep = `data/${siteName}/${season}/${getPreparedBase()}`;
|
||||
if (fusionMode === "itb") {
|
||||
el.innerHTML = `<a href="${prep}/gcc/${sub}/timeseries.json">[GCC JSON]</a>`;
|
||||
return;
|
||||
}
|
||||
const base = `${prep}/export/${sub}`;
|
||||
const name = `${siteName}_${season}_fusion_${strategy}_${sub}`;
|
||||
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
|
||||
}
|
||||
|
||||
async function findFusionFile(dateStr) {
|
||||
const target = new Date(dateStr);
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
const seasonStart = start.getTime();
|
||||
const seasonEnd = yearEnd.getTime();
|
||||
for (let offset = 0; offset <= 365; offset++) {
|
||||
for (const dir of offset === 0 ? [0] : [-1, 1]) {
|
||||
const d = new Date(target.getTime() + dir * offset * 86400000);
|
||||
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
|
||||
const ds = d.toISOString().split("T")[0].replace(/-/g, "");
|
||||
const filename = (fusionMode === "itb" ? "GCC_" : "REFL_") + `${ds}.tif`;
|
||||
try {
|
||||
const res = await fetch(`${getFusionDir()}/${filename}`, { method: "HEAD" });
|
||||
if (res.ok) return filename;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 loadGeotiff(filename) {
|
||||
const path = `${getFusionDir()}/${filename}`;
|
||||
const buf = await (await fetch(path)).arrayBuffer();
|
||||
const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
|
||||
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
|
||||
const dateStr = filename.replace(/^(REFL|GCC)_/, "").replace(".tif", "");
|
||||
return { dataUrl, bounds, dateStr };
|
||||
}
|
||||
|
||||
async function updateMap() {
|
||||
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const filename = await findFusionFile(dateStr);
|
||||
if (!filename || !fusionMap) {
|
||||
if (overlay) { fusionMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { dataUrl, bounds, dateStr: ds } = await loadGeotiff(filename);
|
||||
if (overlay) fusionMap.removeLayer(overlay);
|
||||
overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(fusionMap);
|
||||
fusionMap.fitBounds(bounds);
|
||||
document.getElementById("mapDate").textContent = `${ds.slice(0,4)}-${ds.slice(4,6)}-${ds.slice(6,8)}`;
|
||||
} catch (e) {
|
||||
if (overlay) { fusionMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDataExists(sitename, s) {
|
||||
try {
|
||||
const res = await fetch(`data/${sitename}/${s}/raw/preselection/s2_preselection.json`, { method: "HEAD" });
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function getSiteBySitename(sn) {
|
||||
return window.sitesData?.features?.find(f => f.properties?.sitename === sn);
|
||||
}
|
||||
|
||||
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 (fusionMap) { fusionMap.setView(sitePosition, 12); if (marker) marker.setLatLng(sitePosition); }
|
||||
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
|
||||
document.getElementById("season").textContent = season;
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("site", siteName);
|
||||
params.set("season", season);
|
||||
params.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${params}`);
|
||||
const urlDate = params.get("date");
|
||||
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
await loadTimeseries();
|
||||
await updateMap();
|
||||
}
|
||||
|
||||
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;
|
||||
strategy = urlParams.get("strategy") || "aggressive";
|
||||
sigma = urlParams.get("sigma") || "20";
|
||||
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
|
||||
document.getElementById("strategySelect").value = strategy;
|
||||
document.getElementById("sigmaSelect").value = sigma;
|
||||
document.getElementById("fusionModeSelect").value = fusionMode;
|
||||
const ml = document.getElementById("mapLabelFusion");
|
||||
if (ml) ml.textContent = fusionMode === "itb" ? "Fusion GCC grayscale (closest available)" : "Fusion RGB (closest available)";
|
||||
|
||||
const initSite = getSiteBySitename(initialSite);
|
||||
if (initSite?.geometry?.coordinates) {
|
||||
const [lon, lat] = initSite.geometry.coordinates;
|
||||
sitePosition = [lat, lon];
|
||||
}
|
||||
fusionMap = L.map("fusionMap", { zoomControl: false }).setView(sitePosition, 12)
|
||||
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
|
||||
marker = 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(fusionMap);
|
||||
|
||||
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("strategySelect").addEventListener("change", function() {
|
||||
strategy = this.value;
|
||||
urlParams.set("strategy", strategy);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("sigmaSelect").addEventListener("change", function() {
|
||||
sigma = this.value;
|
||||
urlParams.set("sigma", sigma);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("fusionModeSelect").addEventListener("change", function() {
|
||||
fusionMode = this.value;
|
||||
urlParams.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
const ml = document.getElementById("mapLabelFusion");
|
||||
if (ml) ml.textContent = fusionMode === "itb" ? "Fusion GCC grayscale (closest available)" : "Fusion RGB (closest available)";
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
|
||||
await setSiteSeason(initialSite, initialSeason);
|
||||
}
|
||||
|
||||
document.getElementById("dateSlider").addEventListener("input", function() {
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
|
||||
drawPlots(); updateMap();
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gap validation</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.nav { margin-bottom: 15px; 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; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
|
||||
.selectors { margin-bottom: 18px; }
|
||||
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
|
||||
h1 { font-size: 22px; margin-top: 0; }
|
||||
h2 { font-size: 16px; margin-top: 22px; color: #333; }
|
||||
h2:first-of-type { margin-top: 8px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 12px; margin-bottom: 14px; }
|
||||
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
|
||||
th { background: #f5f5f5; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.paths { font-size: 11px; word-break: break-all; color: #444; max-width: 420px; }
|
||||
.intro { font-size: 13px; color: #333; background: #fafafa; border: 1px solid #e5e5e5;
|
||||
padding: 10px 12px; border-radius: 4px; margin-bottom: 16px; line-height: 1.5; }
|
||||
.intro code { background: #f1f1f1; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.section-note { font-size: 12px; color: #555; margin: -6px 0 8px 0; line-height: 1.45; }
|
||||
.empty { color: #666; font-style: italic; }
|
||||
.err { color: #a00; }
|
||||
details.meta { font-size: 12px; margin-top: 12px; border: 1px solid #e5e5e5; border-radius: 4px; padding: 8px 12px; background: #fafafa; }
|
||||
details.meta summary { cursor: pointer; font-weight: 600; }
|
||||
details.meta pre { margin: 8px 0 0; overflow: auto; font-size: 11px; max-height: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="index.html">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" class="active">Gap validation</a>
|
||||
<a href="phenology.html">Phenology</a>
|
||||
</div>
|
||||
<h1 id="pageTitle">Gap validation</h1>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
<script>
|
||||
let siteName = "innsbruck",
|
||||
season = "2024";
|
||||
let availableSiteSeasons = {};
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
async function probeSummary(sn, s) {
|
||||
try {
|
||||
const res = await fetch(`data/${sn}/${s}/validation/gap_validation_summary.json`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v, d = 4) {
|
||||
if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "—";
|
||||
return v.toFixed(d);
|
||||
}
|
||||
|
||||
function fmtInt(v) {
|
||||
if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "—";
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
function crossoverBlock(summary) {
|
||||
const scen = summary.scenario;
|
||||
const wcRoot = summary.whittaker_crossover || {};
|
||||
const wc = (scen && wcRoot[scen]) || Object.values(wcRoot)[0];
|
||||
if (!wc) return "";
|
||||
const first = wc.first_gap_days_fusion_nse_below_whittaker;
|
||||
const def = wc.whittaker_definition || "";
|
||||
let h = `<h2>Whittaker crossover (NSE<sub>S2</sub>)</h2>`;
|
||||
h += `<p class="section-note">${def}</p>`;
|
||||
h += `<p class="section-note"><b>First gap length (days)</b> where fusion NSE<sub>S2</sub> < Whittaker NSE<sub>S2</sub> (strict): <b>${first != null ? first : "—"}</b> (none if fusion never falls below).</p>`;
|
||||
const rows = wc.by_gap || [];
|
||||
if (rows.length) {
|
||||
h += `<table><tr><th>Gap days</th><th class="num">NSE<sub>S2</sub> fusion</th><th class="num">NSE<sub>S2</sub> Whittaker</th></tr>`;
|
||||
for (const r of rows) {
|
||||
h += `<tr><td>${r.gap_days}</td><td class="num">${fmt(r.nse_s2_fusion, 3)}</td><td class="num">${fmt(r.nse_s2_whittaker, 3)}</td></tr>`;
|
||||
}
|
||||
h += `</table>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function manifestTable(manifest) {
|
||||
if (!manifest?.entries?.length) return "";
|
||||
let h = `<h2>Gap manifest</h2>`;
|
||||
h += `<p class="section-note">From <code>data/${siteName}/${season}/validation/gap_manifest.json</code>. Midpoint rule: ${manifest.entries[0]?.midpoint_rule || "—"}.</p>`;
|
||||
h += `<table><tr><th>Transition</th><th>Gap days</th><th>Prediction</th><th>Window</th><th>Withheld S2</th></tr>`;
|
||||
for (const e of manifest.entries) {
|
||||
const w = `${e.window_start} → ${e.window_end}`;
|
||||
h += `<tr><td>${e.transition || "—"}</td><td>${e.gap_days}</td><td>${e.prediction_date}</td><td>${w}</td><td>${e.withheld_s2_filename || "—"}</td></tr>`;
|
||||
}
|
||||
h += `</table>`;
|
||||
return h;
|
||||
}
|
||||
|
||||
function resultsTable(results) {
|
||||
if (!results?.length) return `<p class="empty">No result rows in summary.</p>`;
|
||||
const head = `<tr>
|
||||
<th>Transition</th><th>Gap</th><th>Prediction</th><th>Withheld REFL</th>
|
||||
<th class="num">RMSE<br><span style="font-weight:normal">gap</span></th>
|
||||
<th class="num">NSE<sub>S2</sub><br><span style="font-weight:normal">gap</span></th>
|
||||
<th class="num">RMSE<br><span style="font-weight:normal">no gap</span></th>
|
||||
<th class="num">NSE<sub>S2</sub><br><span style="font-weight:normal">no gap</span></th>
|
||||
<th class="num">ΔRMSE</th><th class="num">ΔNSE</th>
|
||||
<th class="num">NSE<sub>S2</sub><br><span style="font-weight:normal">Whitt.</span></th>
|
||||
<th class="num">n</th>
|
||||
<th>Paths / error</th>
|
||||
</tr>`;
|
||||
const parts = [head];
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
parts.push(
|
||||
`<tr><td>${r.transition ?? "—"}</td><td>${r.gap_days ?? "—"}</td><td colspan="9" class="err">${r.error}</td><td class="paths">${r.fused_gap_path || ""}</td></tr>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const g = r.spatial?.gap || {};
|
||||
const ng = r.spatial?.no_gap || {};
|
||||
const wh = r.spatial?.whittaker || {};
|
||||
const dRm = r.spatial?.delta_rmse;
|
||||
const dNs = r.spatial?.delta_nse;
|
||||
const p = r.paths || {};
|
||||
const pathNote = [p.fused_gap, p.fused_no_gap, p.withheld_s2_refl].filter(Boolean).join("<br>");
|
||||
parts.push(`<tr>
|
||||
<td>${r.transition || "—"}</td>
|
||||
<td>${r.gap_days}</td>
|
||||
<td>${r.prediction_date || "—"}</td>
|
||||
<td style="font-size:11px">${r.withheld_s2_filename || "—"}</td>
|
||||
<td class="num">${fmt(g.rmse)}</td>
|
||||
<td class="num">${fmt(g.nse_s2, 3)}</td>
|
||||
<td class="num">${fmt(ng.rmse)}</td>
|
||||
<td class="num">${fmt(ng.nse_s2, 3)}</td>
|
||||
<td class="num">${fmt(dRm)}</td>
|
||||
<td class="num">${fmt(dNs, 3)}</td>
|
||||
<td class="num">${fmt(wh.nse_s2, 3)}</td>
|
||||
<td class="num">${fmtInt(g.n_pixels)}</td>
|
||||
<td class="paths">${pathNote}</td>
|
||||
</tr>`);
|
||||
}
|
||||
return `<table>${parts.join("")}</table>`;
|
||||
}
|
||||
|
||||
function metaDetails(summary) {
|
||||
const cmd = summary.command_line;
|
||||
const git = summary.git_commit;
|
||||
if (!cmd && !git) return "";
|
||||
let h = `<details class="meta"><summary>Run metadata</summary>`;
|
||||
if (git) h += `<p>Git: <code>${git}</code></p>`;
|
||||
if (cmd?.length) h += `<pre>${cmd.map((x) => String(x)).join(" ")}</pre>`;
|
||||
h += `</details>`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function render(summary, manifest) {
|
||||
const el = document.getElementById("content");
|
||||
if (!summary) {
|
||||
el.innerHTML = `<p class="err">Could not load <code>data/${siteName}/${season}/validation/gap_validation_summary.json</code>.</p>
|
||||
<p class="section-note">From <code>processing/</code>: <code>python -m gap_validation.run --site ${siteName} --season ${season} --lat LAT --lon LON</code> (see <code>--help</code>). Serve from <code>processing/</code>: <code>python3 -m http.server 8000</code> → <code>/webapp/gap_validation.html</code> (<code>webapp/data</code> → <code>../data</code>).</p>`;
|
||||
if (manifest?.entries) el.innerHTML += manifestTable(manifest);
|
||||
return;
|
||||
}
|
||||
const scen = summary.scenario || "—";
|
||||
const sn = summary.site_name ?? siteName;
|
||||
const se = summary.season ?? season;
|
||||
let html = `<div class="intro">
|
||||
Tier-2 withheld S2, spatial GCC vs withheld scene, NSE<sub>S2</sub>, and Whittaker comparison.
|
||||
Summary: <code>data/${sn}/${se}/validation/gap_validation_summary.json</code>.
|
||||
Scenario in this file: <b>${scen}</b> (one run overwrites; re-run CLI for other strategy/σ/mode).
|
||||
</div>`;
|
||||
html += `<h2>Spatial metrics (per gap length)</h2>`;
|
||||
html += `<p class="section-note">Reference = GCC from withheld S2 REFL (bilinear to fusion grid). Prediction = fused GCC. ΔRMSE = RMSE<sub>gap</sub> − RMSE<sub>no gap</sub>; ΔNSE = NSE<sub>no gap</sub> − NSE<sub>gap</sub>.</p>`;
|
||||
html += resultsTable(summary.results);
|
||||
html += crossoverBlock(summary);
|
||||
html += metaDetails(summary);
|
||||
if (manifest?.entries) html += manifestTable(manifest);
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let summary = null,
|
||||
manifest = null;
|
||||
try {
|
||||
const r1 = await fetch(`data/${siteName}/${season}/validation/gap_validation_summary.json`);
|
||||
summary = r1.ok ? await r1.json() : null;
|
||||
} catch {
|
||||
summary = null;
|
||||
}
|
||||
try {
|
||||
const r2 = await fetch(`data/${siteName}/${season}/validation/gap_manifest.json`);
|
||||
manifest = r2.ok ? await r2.json() : null;
|
||||
} catch {
|
||||
manifest = null;
|
||||
}
|
||||
await render(summary, manifest);
|
||||
const site = window.sitesData?.features?.find((f) => f.properties?.sitename === siteName);
|
||||
document.getElementById("pageTitle").textContent =
|
||||
(site?.properties?.description || siteName) + " — gap validation — " + season;
|
||||
urlParams.set("site", siteName);
|
||||
urlParams.set("season", season);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
}
|
||||
|
||||
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 probeSummary(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;
|
||||
siteName = initialSite;
|
||||
season = initialSeason;
|
||||
|
||||
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";
|
||||
siteName = sn;
|
||||
season = document.getElementById("seasonSelect").value;
|
||||
load();
|
||||
});
|
||||
document.getElementById("seasonSelect").addEventListener("change", function () {
|
||||
season = this.value;
|
||||
load();
|
||||
});
|
||||
await load();
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1817
webapp/index.html
1817
webapp/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1,367 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Metrics</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.nav { margin-bottom: 15px; 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; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
|
||||
.selectors { margin-bottom: 20px; }
|
||||
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 16px; margin-top: 24px; color: #333; }
|
||||
h2:first-of-type { margin-top: 8px; }
|
||||
h3 { font-size: 14px; margin: 14px 0 6px 0; color: #444; font-weight: 600; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 12px; }
|
||||
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.fusion-block table { margin-bottom: 4px; }
|
||||
.fusion-block table + table { margin-top: 12px; }
|
||||
.section-note { font-size: 12px; color: #555; margin: -6px 0 8px 0; max-width: 720px; line-height: 1.45; }
|
||||
.section-note code { background: #f1f1f1; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.intro { font-size: 13px; color: #333; background: #fafafa; border: 1px solid #e5e5e5;
|
||||
padding: 10px 12px; border-radius: 4px; margin-bottom: 18px; line-height: 1.5; }
|
||||
.intro-short { margin-bottom: 0; }
|
||||
details.definitions { margin-top: 28px; font-size: 13px; border: 1px solid #e5e5e5; border-radius: 4px; padding: 8px 12px; background: #fafafa; }
|
||||
details.definitions summary { cursor: pointer; font-weight: 600; color: #333; }
|
||||
details.definitions ul { margin: 8px 0 0 18px; padding: 0; }
|
||||
details.definitions li { margin-bottom: 4px; }
|
||||
.scenario-key { font-size: 11px; color: #666; font-weight: normal; }
|
||||
.empty { color: #666; font-style: italic; }
|
||||
.err { color: #a00; }
|
||||
details.how-read {
|
||||
font-size: 12px; color: #333; line-height: 1.5; max-width: 820px; margin: 0 0 18px 0;
|
||||
padding: 8px 12px 10px; border: 1px solid #ccd; border-radius: 4px; background: #f8fafc;
|
||||
}
|
||||
details.how-read summary {
|
||||
cursor: pointer; font-weight: 600; font-size: 13px; color: #111; margin-bottom: 0;
|
||||
}
|
||||
details.how-read ol { margin: 10px 0 0; padding-left: 1.35rem; }
|
||||
details.how-read li { margin-bottom: 7px; }
|
||||
details.how-read li:last-child { margin-bottom: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="index.html">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" class="active">Metrics</a>
|
||||
<a href="gap_validation.html">Gap validation</a>
|
||||
<a href="phenology.html">Phenology</a>
|
||||
</div>
|
||||
<h1 id="siteName">Metrics</h1>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
<script>
|
||||
/** Shown in the UI; pearson_r, rmse, mae, n_samples remain in metrics.json only. */
|
||||
const DISPLAY_METRIC_COLS = ["r_squared", "nrmse", "nse_pc"];
|
||||
const DISPLAY_METRIC_LABELS = {
|
||||
r_squared: "R² vs mean",
|
||||
nrmse: "nRMSE",
|
||||
nse_pc: "NSE_PC",
|
||||
};
|
||||
|
||||
const FUSION_BTI_ROWS = [
|
||||
["aggressive_sigma20", "Aggressive", 20],
|
||||
["aggressive_sigma30", "Aggressive", 30],
|
||||
["nonaggressive_sigma20", "Non-aggressive", 20],
|
||||
["nonaggressive_sigma30", "Non-aggressive", 30],
|
||||
];
|
||||
|
||||
function mv(m, c) {
|
||||
return c === "nse_pc" ? (m.nse_pc ?? m.nse) : m[c];
|
||||
}
|
||||
function fmtMetric(col, v) {
|
||||
if (v == null || typeof v !== "number") return "—";
|
||||
if (col === "r_squared" || col === "nse_pc") return v.toFixed(3);
|
||||
if (col === "nrmse") return v.toFixed(4);
|
||||
return fmt(v);
|
||||
}
|
||||
let siteName = "innsbruck", season = "2024";
|
||||
let availableSiteSeasons = {};
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
async function probeMetrics(sn, s) {
|
||||
try {
|
||||
const res = await fetch(`data/${sn}/${s}/metrics.json`, { method: "HEAD" });
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function fmt(v) {
|
||||
if (v == null || typeof v !== "number") return "—";
|
||||
return Number.isInteger(v) ? String(v) : v.toFixed(4);
|
||||
}
|
||||
|
||||
function fusionMeanResidual(m) {
|
||||
const x = m?.residual_vs_phenocam?.mean;
|
||||
const n = Number(x);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function fusionSubTableRows(temporal, keysWithLabels, includeMeanResid) {
|
||||
const parts = [];
|
||||
for (const [key, stratLabel, sig] of keysWithLabels) {
|
||||
const m = temporal[key];
|
||||
if (!m) continue;
|
||||
const mr = fusionMeanResidual(m);
|
||||
const meanCell = includeMeanResid
|
||||
? `<td class="num">${mr !== null ? mr.toFixed(3) : "—"}</td>`
|
||||
: "";
|
||||
parts.push(
|
||||
`<tr><td>${stratLabel}, σ=${sig} <span class="scenario-key">(${key})</span></td>${DISPLAY_METRIC_COLS.map((c) => `<td class="num">${fmtMetric(c, mv(m, c))}</td>`).join("")}${meanCell}</tr>`
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function fusionTables(temporal) {
|
||||
if (!temporal || typeof temporal !== "object") {
|
||||
return `<p class="empty">No fusion temporal data</p>`;
|
||||
}
|
||||
const itbRows = FUSION_BTI_ROWS.map(([k, s, sig]) => [`${k}_itb`, s, sig]);
|
||||
const allKeys = [...FUSION_BTI_ROWS.map((r) => r[0]), ...itbRows.map((r) => r[0])];
|
||||
let showMean = false;
|
||||
for (const k of allKeys) {
|
||||
if (fusionMeanResidual(temporal[k]) !== null) {
|
||||
showMean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const btiBody = fusionSubTableRows(temporal, FUSION_BTI_ROWS, showMean);
|
||||
const itbBody = fusionSubTableRows(temporal, itbRows, showMean);
|
||||
if (!btiBody.length && !itbBody.length) {
|
||||
return `<p class="empty">No fusion scenarios in temporal</p>`;
|
||||
}
|
||||
const meanTh = showMean ? `<th class="num">Mean resid.</th>` : "";
|
||||
const head = `<tr><th>Setting</th>${DISPLAY_METRIC_COLS.map((c) => `<th class="num">${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}${meanTh}</tr>`;
|
||||
|
||||
let h = `<div class="fusion-block">`;
|
||||
if (btiBody.length) {
|
||||
h += `<h3>Bands-then-Index (BtI)</h3>`;
|
||||
h += `<table>${head}${btiBody.join("")}</table>`;
|
||||
}
|
||||
if (itbBody.length) {
|
||||
h += `<h3>Index-then-Bands (ItB)</h3>`;
|
||||
h += `<table>${head}${itbBody.join("")}</table>`;
|
||||
}
|
||||
h += `</div>`;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Returns only <table>…</table> or empty string (no heading). */
|
||||
function baselineTable(b) {
|
||||
if (!b || typeof b !== "object") return "";
|
||||
const rows = [];
|
||||
const pushRow = (label, m) => {
|
||||
if (!m || typeof m !== "object") return;
|
||||
rows.push(
|
||||
`<tr><td>${label}</td>${DISPLAY_METRIC_COLS.map((c) => `<td class="num">${fmtMetric(c, mv(m, c))}</td>`).join("")}</tr>`
|
||||
);
|
||||
};
|
||||
pushRow("S2 GCC (all acquisitions)", b.s2);
|
||||
for (const strat of ["aggressive", "nonaggressive"]) {
|
||||
pushRow(`S3 composite GCC (${strat})`, b.s3?.[strat]);
|
||||
pushRow(`S2 GCC cloud-screened (${strat})`, b.s2_cloudfree?.[strat]);
|
||||
pushRow(`S2 Whittaker λ=400 (${strat})`, b.s2_whittaker_lambda400?.[strat]);
|
||||
}
|
||||
if (!rows.length) return "";
|
||||
const head = `<tr><th>Baseline</th>${DISPLAY_METRIC_COLS.map((c) => `<th class="num">${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}</tr>`;
|
||||
return `<table>${head}${rows.join("")}</table>`;
|
||||
}
|
||||
|
||||
function fmtFixed3(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toFixed(3) : "—";
|
||||
}
|
||||
|
||||
function derivedSection(d) {
|
||||
if (!d) return "";
|
||||
const dn = d.delta_nse_pc_sigma20_minus_sigma30;
|
||||
const paired = d.bti_vs_itb_mean_residual || [];
|
||||
if (!dn && !paired.length) return "";
|
||||
|
||||
let h = `<h2>Summaries</h2>`;
|
||||
h += `<p class="section-note">Same numbers as Fusion, condensed. First table: which σ fits PhenoCam better (NSE_PC only). Second: mean bias BtI vs ItB.</p>`;
|
||||
if (dn) {
|
||||
h += `<p class="section-note"><b>ΔNSE_PC</b> = NSE_PC(σ20) − NSE_PC(σ30). <b>+</b> → σ20 better. <b>−</b> → σ30 better.</p>`;
|
||||
h += `<table><tr><th>Mode</th><th>Strategy</th><th class="num">ΔNSE_PC</th></tr>`;
|
||||
let anyDelta = false;
|
||||
for (const mode of ["bti", "itb"]) {
|
||||
for (const strat of ["aggressive", "nonaggressive"]) {
|
||||
const v = dn[mode]?.[strat];
|
||||
if (Number.isFinite(Number(v))) anyDelta = true;
|
||||
h += `<tr><td>${mode.toUpperCase()}</td><td>${strat}</td><td class="num">${fmtFixed3(v)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
h += `</table>`;
|
||||
if (!anyDelta) {
|
||||
h += `<p class="section-note">ΔNSE_PC needs both σ20 and σ30 fusion rows in <code>temporal</code> (BtI and ItB). Re-run <code>metrics_stats</code>.</p>`;
|
||||
}
|
||||
}
|
||||
if (paired.length) {
|
||||
h += `<p class="section-note">Mean(fused − PhenoCam) per row. <b>+</b> / <b>−</b> = average over / under PhenoCam. Closer to <b>0</b> in a column = less bias for that workflow.</p>`;
|
||||
h += `<table><tr><th>Strategy</th><th>σ</th><th class="num">Mean residual BtI</th><th class="num">Mean residual ItB</th></tr>`;
|
||||
for (const row of paired) {
|
||||
h += `<tr><td>${row.strategy}</td><td>${row.sigma}</td><td class="num">${fmtFixed3(row.mean_residual_bti)}</td><td class="num">${fmtFixed3(row.mean_residual_itb)}</td></tr>`;
|
||||
}
|
||||
h += `</table>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function howToReadBlock() {
|
||||
return `<details class="how-read">
|
||||
<summary>How to read</summary>
|
||||
<ol>
|
||||
<li>All scores are satellite or fusion <b>GCC</b> vs <b>PhenoCam GCC</b> at the site 3×3 window, <b>same calendar days</b> only. Extra stats: <code>metrics.json</code>.</li>
|
||||
<li><b>R² vs mean</b> and <b>NSE_PC</b> are the same value (1 − SS<sub>res</sub>/SS<sub>tot</sub> vs predicting mean PhenoCam each day); not (Pearson <i>r</i>)²; can be negative. Higher = better. <b>nRMSE</b>: lower = better.</li>
|
||||
<li><b>Fusion:</b> same row number in BtI and in ItB = same screening + same σ — compare left/right. Down one block = change screening or σ.</li>
|
||||
<li><b>Mean resid.</b> (if present): mean(fused − PhenoCam). Sign = average bias; use R² vs mean / nRMSE / NSE_PC for overall fit.</li>
|
||||
<li><b>Summaries:</b> ΔNSE_PC = NSE at σ20 minus NSE at σ30 (+ means σ20 wins). Paired table: closer to 0 = less mean bias.</li>
|
||||
</ol>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function definitionsDetails() {
|
||||
return `<details class="definitions">
|
||||
<summary>Definitions</summary>
|
||||
<ul>
|
||||
<li><b>BtI</b>: fuse reflectance bands, then GCC.</li>
|
||||
<li><b>ItB</b>: GCC on S2 and S3, then fuse GCC.</li>
|
||||
<li><b>Scenario</b>: screening (<code>aggressive</code> / <code>nonaggressive</code>) × σ (20 / 30 days).</li>
|
||||
<li><a href="phenology.html">Phenology</a> — PhenoCam SOS/EOS (TIMESAT).</li>
|
||||
<li><b>R² vs mean</b> — coefficient of determination vs a constant mean(PhenoCam) baseline; JSON key <code>r_squared</code>; duplicates <code>nse_pc</code>. Not (Pearson <i>r</i>)².</li>
|
||||
<li><code>metrics.json</code> — also Pearson <i>r</i>, RMSE, MAE, <code>n_samples</code>.</li>
|
||||
</ul>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const el = document.getElementById("content");
|
||||
if (!data) {
|
||||
el.innerHTML = `<p class="err">Could not load metrics.json</p>`;
|
||||
return;
|
||||
}
|
||||
let html = "";
|
||||
html += `<div class="intro intro-short">
|
||||
GCC at the 3×3 site window vs PhenoCam. Sections: PhenoCam → baselines → fusion (BtI, then ItB) → summaries.
|
||||
<code>data/${siteName}/${season}/metrics.json</code>
|
||||
</div>`;
|
||||
html += howToReadBlock();
|
||||
|
||||
if (data.phenocam_stats) {
|
||||
html += `<h2>PhenoCam (ground truth)</h2>`;
|
||||
html += `<p class="section-note">Camera ROI GCC (not compared to itself). Dates / SOS–EOS: <a href="phenology.html">Phenology</a>.</p>`;
|
||||
html += `<table><tr><th>mean</th><th>std</th><th>min</th><th>max</th><th>n</th></tr><tr>`;
|
||||
const p = data.phenocam_stats;
|
||||
html += `<td class="num">${fmt(p.mean)}</td><td class="num">${fmt(p.std)}</td><td class="num">${fmt(p.min)}</td><td class="num">${fmt(p.max)}</td><td class="num">${fmt(p.n_samples)}</td></tr></table>`;
|
||||
}
|
||||
|
||||
const baselineTbl = baselineTable(data.baseline);
|
||||
if (baselineTbl) {
|
||||
html += `<h2>Baselines (vs PhenoCam)</h2>`;
|
||||
html += `<p class="section-note">Same columns as fusion (vs PhenoCam). Higher R² vs mean / NSE_PC, lower nRMSE = better. S3 = coarse-only; Whittaker = smoothed S2-only.</p>`;
|
||||
html += baselineTbl;
|
||||
}
|
||||
|
||||
html += `<h2>Fusion (vs PhenoCam)</h2>`;
|
||||
html += `<p class="section-note">BtI block vs ItB block: same row = same screening + σ. Within a block: four EFAST combinations.</p>`;
|
||||
html += fusionTables(data.temporal || {});
|
||||
|
||||
html += derivedSection(data.derived);
|
||||
|
||||
html += definitionsDetails();
|
||||
|
||||
el.innerHTML = html || `<p class="empty">Empty metrics file</p>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`data/${siteName}/${season}/metrics.json`);
|
||||
render(res.ok ? await res.json() : null);
|
||||
} catch {
|
||||
render(null);
|
||||
}
|
||||
const site = window.sitesData?.features?.find((f) => f.properties?.sitename === siteName);
|
||||
document.getElementById("siteName").textContent = (site?.properties?.description || siteName) + " — " + season;
|
||||
urlParams.set("site", siteName);
|
||||
urlParams.set("season", season);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
}
|
||||
|
||||
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 probeMetrics(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;
|
||||
siteName = initialSite;
|
||||
season = initialSeason;
|
||||
|
||||
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";
|
||||
siteName = sn;
|
||||
season = document.getElementById("seasonSelect").value;
|
||||
load();
|
||||
});
|
||||
document.getElementById("seasonSelect").addEventListener("change", function () {
|
||||
season = this.value;
|
||||
load();
|
||||
});
|
||||
await load();
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Phenology</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.nav { margin-bottom: 15px; 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; }
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||
h1 { font-size: 22px; margin-top: 0; }
|
||||
.intro { font-size: 13px; color: #333; background: #fafafa; border: 1px solid #e5e5e5;
|
||||
padding: 10px 12px; border-radius: 4px; margin-bottom: 16px; line-height: 1.5; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { border: 1px solid #ccc; padding: 8px 10px; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td.num { text-align: center; font-variant-numeric: tabular-nums; }
|
||||
td.site { font-weight: 500; }
|
||||
a.rowlink { color: #0066cc; text-decoration: none; }
|
||||
a.rowlink:hover { text-decoration: underline; }
|
||||
.empty { color: #666; }
|
||||
.err { color: #a00; }
|
||||
.loading { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="index.html">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" class="active">Phenology</a>
|
||||
</div>
|
||||
<h1>PhenoCam phenology (50% amplitude)</h1>
|
||||
<p class="intro">
|
||||
Green-up and green-down dates from <code>data/<site>/<season>/raw/phenocam/phenocam_phenology.json</code>
|
||||
(TIMESAT on PhenoCam GCC). Site/season rows match <code>data/sites.geojson</code>.
|
||||
Run <code>python phenology_timesat.py --all</code> or the pipeline to generate missing JSON files.
|
||||
</p>
|
||||
<p id="status" class="loading">Loading…</p>
|
||||
<div id="tableWrap"></div>
|
||||
</div>
|
||||
<script>
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cellDate(v) {
|
||||
if (v == null || v === "") return "<span class='empty'>—</span>";
|
||||
return escapeHtml(v);
|
||||
}
|
||||
|
||||
async function loadPhenologyRow(site, season) {
|
||||
const path = `data/${site}/${season}/raw/phenocam/phenocam_phenology.json`;
|
||||
try {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) return { ok: false, up: null, down: null };
|
||||
const j = await res.json();
|
||||
return {
|
||||
ok: true,
|
||||
up: j.green_up_50pct_date ?? null,
|
||||
down: j.green_down_50pct_date ?? null
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, up: null, down: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const status = document.getElementById("status");
|
||||
const wrap = document.getElementById("tableWrap");
|
||||
let features = [];
|
||||
try {
|
||||
const res = await fetch("data/sites.geojson");
|
||||
if (!res.ok) throw new Error("Could not load sites.geojson");
|
||||
const g = await res.json();
|
||||
features = g.features || [];
|
||||
} catch (e) {
|
||||
status.textContent = "";
|
||||
status.className = "err";
|
||||
status.textContent = "Failed to load data/sites.geojson.";
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const f of features) {
|
||||
const site = f.properties && f.properties.sitename;
|
||||
if (!site) continue;
|
||||
const desc = (f.properties && f.properties.description) || site;
|
||||
const seasons = f.properties && f.properties.seasons
|
||||
? Object.keys(f.properties.seasons).sort()
|
||||
: [];
|
||||
for (const season of seasons) {
|
||||
rows.push({ site, season, desc });
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => a.site.localeCompare(b.site) || a.season.localeCompare(b.season));
|
||||
|
||||
const results = await Promise.all(
|
||||
rows.map((r) =>
|
||||
loadPhenologyRow(r.site, r.season).then((phen) => ({ ...r, ...phen }))
|
||||
)
|
||||
);
|
||||
|
||||
const head =
|
||||
"<thead><tr>" +
|
||||
"<th>Site</th><th>Season</th><th>Description</th>" +
|
||||
"<th>Green-up</th><th>Green-down</th>" +
|
||||
"</tr></thead>";
|
||||
const body = results
|
||||
.map((r) => {
|
||||
const q = new URLSearchParams();
|
||||
q.set("site", r.site);
|
||||
q.set("season", r.season);
|
||||
const viewer = `index.html?${q.toString()}`;
|
||||
return (
|
||||
"<tr>" +
|
||||
`<td class="site"><a class="rowlink" href="${viewer}">${escapeHtml(r.site)}</a></td>` +
|
||||
`<td class="num">${r.season}</td>` +
|
||||
`<td>${escapeHtml(r.desc)}</td>` +
|
||||
`<td class="num">${cellDate(r.up)}</td>` +
|
||||
`<td class="num">${cellDate(r.down)}</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
status.textContent = "";
|
||||
status.className = "";
|
||||
wrap.innerHTML = "<table>" + head + "<tbody>" + body + "</tbody></table>";
|
||||
}
|
||||
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Postprocessed Viewer</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="common.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: 15px; 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; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.header-sticky { position: sticky; top: 0; background: white; z-index: 1000; border-bottom: 1px solid #ccc; padding-bottom: 20px; margin-bottom: 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; }
|
||||
.season-row { padding-bottom: 15px; }
|
||||
h2 { margin: 0; font-size: 16px; color: #666; display: inline; }
|
||||
.download-links { margin-left: 10px; font-size: 14px; }
|
||||
.download-links a { margin-right: 8px; color: #0066cc; text-decoration: none; }
|
||||
.download-links a:hover { text-decoration: underline; }
|
||||
#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; }
|
||||
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
|
||||
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
|
||||
#postprocessedMap { height: 500px; border: 1px solid #ccc; margin-top: 10px; }
|
||||
.leaflet-image-layer { image-rendering: pixelated; }
|
||||
.leaflet-control-attribution { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header-sticky">
|
||||
<div class="nav">
|
||||
<a href="index.html">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" class="active">Postprocessed</a>
|
||||
<a href="metrics.html">Metrics</a>
|
||||
<a href="gap_validation.html">Gap validation</a>
|
||||
<a href="phenology.html">Phenology</a>
|
||||
</div>
|
||||
<h1 id="siteName">Innsbruck</h1>
|
||||
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
<label>Strategy:</label>
|
||||
<select id="strategySelect">
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="nonaggressive">Non-aggressive</option>
|
||||
</select>
|
||||
<label>Sigma:</label>
|
||||
<select id="sigmaSelect">
|
||||
<option value="20">σ=20</option>
|
||||
<option value="30">σ=30</option>
|
||||
</select>
|
||||
<label>Source:</label>
|
||||
<select id="sourceSelect">
|
||||
<option value="s2">S2</option>
|
||||
<option value="fusion">Fusion</option>
|
||||
<option value="s3">S3</option>
|
||||
</select>
|
||||
<label>Mode:</label>
|
||||
<select id="fusionModeSelect" title="BtI vs ItB processed paths">
|
||||
<option value="bti">BtI</option>
|
||||
<option value="itb">ItB</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="range" id="dateSlider" min="0" max="365" value="0">
|
||||
<div id="dateDisplay">2024-01-01</div>
|
||||
</div>
|
||||
<div class="map-label">Postprocessed RGB (closest available)</div>
|
||||
<div id="mapDate" class="map-date"></div>
|
||||
<div id="postprocessedMap"></div>
|
||||
<div id="plots">
|
||||
<div class="plot-label">NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>
|
||||
<div class="plot-label">GCC</div><canvas id="plot_gcc" class="plot"></canvas>
|
||||
<div class="plot-label">B02 (Blue)</div><canvas id="plot_b02" class="plot"></canvas>
|
||||
<div class="plot-label">B03 (Green)</div><canvas id="plot_b03" class="plot"></canvas>
|
||||
<div class="plot-label">B04 (Red)</div><canvas id="plot_b04" class="plot"></canvas>
|
||||
<div class="plot-label">B8A (NIR)</div><canvas id="plot_b8a" class="plot"></canvas>
|
||||
</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 siteName = "innsbruck", season = "2024";
|
||||
let strategy = "aggressive", sigma = "20", source = "s2", fusionMode = "bti";
|
||||
let sitePosition = [47.116171, 11.320308];
|
||||
let start = new Date(2024, 0, 1);
|
||||
let availableSiteSeasons = {};
|
||||
let postprocessedMap = null, overlay = null, marker = null;
|
||||
let ndviTs = [], gccTs = [], bandsTs = [];
|
||||
const BANDS = [{key:"b02",color:"#0066ff"},{key:"b03",color:"#00aa00"},{key:"b04",color:"#cc0000"},{key:"b8a",color:"#9900cc"}];
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
function getProcessedPath() {
|
||||
const mid = fusionMode === "itb" ? `processed_${strategy}_itb_sigma${sigma}` : `processed_${strategy}_sigma${sigma}`;
|
||||
return `data/${siteName}/${season}/${mid}`;
|
||||
}
|
||||
|
||||
async function loadTimeseries() {
|
||||
const base = getProcessedPath();
|
||||
try {
|
||||
const [n, g, b] = await Promise.all([
|
||||
fetch(`${base}/ndvi/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(`${base}/gcc/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(`${base}/bands/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
ndviTs = n;
|
||||
gccTs = g;
|
||||
bandsTs = b;
|
||||
} catch {
|
||||
ndviTs = [];
|
||||
gccTs = [];
|
||||
bandsTs = [];
|
||||
}
|
||||
drawPlots();
|
||||
updateDownloadLinks();
|
||||
}
|
||||
|
||||
function drawPlot(canvasId, data, key, 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 pts = data.filter(t => t[key] != null);
|
||||
if (!pts.length) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#999"; ctx.font = "12px sans-serif"; ctx.fillText("No data", pad, pad + plotH / 2); return; }
|
||||
const dates = pts.map(t => new Date(t.date));
|
||||
const vals = pts.map(t => t[key]);
|
||||
const minD = new Date(Math.min(...dates)), maxD = new Date(Math.max(...dates));
|
||||
const minV = Math.min(...vals), maxV = Math.max(...vals);
|
||||
const dRange = maxD - minD || 1, vRange = maxV - minV || 1;
|
||||
const x = d => pad + ((new Date(d) - minD) / dRange) * plotW;
|
||||
const y = v => pad + plotH - ((v - minV) / vRange) * 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(minV.toFixed(3), 2, pad + plotH + 10);
|
||||
ctx.fillText(maxV.toFixed(3), 2, pad + 3);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
pts.forEach((t, i) => { const px = x(t.date), py = y(t[key]); i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); });
|
||||
ctx.stroke();
|
||||
const curDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const xPos = x(curDate);
|
||||
ctx.strokeStyle = "#f00";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(xPos, pad); ctx.lineTo(xPos, pad + plotH); ctx.stroke();
|
||||
const closest = pts.reduce((c, t) => Math.abs(new Date(t.date) - new Date(curDate)) < Math.abs(new Date(c.date) - new Date(curDate)) ? t : c);
|
||||
if (closest) { ctx.fillStyle = "#f00"; ctx.font = "bold 10px sans-serif"; ctx.fillText(closest[key].toFixed(3), xPos + 5, y(closest[key]) - 5); }
|
||||
}
|
||||
|
||||
function drawPlots() {
|
||||
drawPlot("plot_ndvi", ndviTs, "ndvi", "#2d7a3e");
|
||||
drawPlot("plot_gcc", gccTs, "greenness_index", "#00aa00");
|
||||
BANDS.forEach(b => drawPlot(`plot_${b.key}`, bandsTs, b.key, b.color));
|
||||
}
|
||||
|
||||
function updateDownloadLinks() {
|
||||
const el = document.getElementById("downloadLinks");
|
||||
if (!el) return;
|
||||
const root = getProcessedPath();
|
||||
if (fusionMode === "itb") {
|
||||
el.innerHTML = `<a href="${root}/gcc/${source}/timeseries.json">[GCC JSON]</a>`;
|
||||
return;
|
||||
}
|
||||
const base = `${root}/export/${source}`;
|
||||
const name = `${siteName}_${season}_postprocessed_${strategy}_sigma${sigma}_${source}`;
|
||||
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
|
||||
}
|
||||
|
||||
async function findProcessedFile(dateStr) {
|
||||
const target = new Date(dateStr);
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
const seasonStart = start.getTime();
|
||||
const seasonEnd = yearEnd.getTime();
|
||||
for (let offset = 0; offset <= 365; offset++) {
|
||||
for (const dir of offset === 0 ? [0] : [-1, 1]) {
|
||||
const d = new Date(target.getTime() + dir * offset * 86400000);
|
||||
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
|
||||
const ds = d.toISOString().split("T")[0].replace(/-/g, "");
|
||||
const filename = `${ds}_0.geotiff`;
|
||||
try {
|
||||
const res = await fetch(`${getProcessedPath()}/${source}/${filename}`, { method: "HEAD" });
|
||||
if (res.ok) return filename;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 loadGeotiff(filename) {
|
||||
const path = `${getProcessedPath()}/${source}/${filename}`;
|
||||
const buf = await (await fetch(path)).arrayBuffer();
|
||||
const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
|
||||
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
|
||||
const dateStr = filename.replace("_0.geotiff", "");
|
||||
return { dataUrl, bounds, dateStr };
|
||||
}
|
||||
|
||||
async function updateMap() {
|
||||
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const filename = await findProcessedFile(dateStr);
|
||||
if (!filename || !postprocessedMap) {
|
||||
if (overlay) { postprocessedMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { dataUrl, bounds, dateStr: ds } = await loadGeotiff(filename);
|
||||
if (overlay) postprocessedMap.removeLayer(overlay);
|
||||
overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(postprocessedMap);
|
||||
postprocessedMap.fitBounds(bounds);
|
||||
document.getElementById("mapDate").textContent = `${ds.slice(0,4)}-${ds.slice(4,6)}-${ds.slice(6,8)}`;
|
||||
} catch (e) {
|
||||
if (overlay) { postprocessedMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDataExists(sitename, s) {
|
||||
try {
|
||||
const res = await fetch(`data/${sitename}/${s}/metrics.json`, { method: "HEAD" });
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function getSiteBySitename(sn) {
|
||||
return window.sitesData?.features?.find(f => f.properties?.sitename === sn);
|
||||
}
|
||||
|
||||
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 (postprocessedMap) { postprocessedMap.setView(sitePosition, 12); if (marker) marker.setLatLng(sitePosition); }
|
||||
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
|
||||
document.getElementById("season").textContent = season;
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("site", siteName);
|
||||
params.set("season", season);
|
||||
params.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${params}`);
|
||||
const urlDate = params.get("date");
|
||||
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
await loadTimeseries();
|
||||
await updateMap();
|
||||
}
|
||||
|
||||
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;
|
||||
strategy = urlParams.get("strategy") || "aggressive";
|
||||
sigma = urlParams.get("sigma") || "20";
|
||||
source = urlParams.get("source") || "s2";
|
||||
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
|
||||
document.getElementById("strategySelect").value = strategy;
|
||||
document.getElementById("sigmaSelect").value = sigma;
|
||||
document.getElementById("sourceSelect").value = source;
|
||||
document.getElementById("fusionModeSelect").value = fusionMode;
|
||||
|
||||
const initSite = getSiteBySitename(initialSite);
|
||||
if (initSite?.geometry?.coordinates) {
|
||||
const [lon, lat] = initSite.geometry.coordinates;
|
||||
sitePosition = [lat, lon];
|
||||
}
|
||||
postprocessedMap = L.map("postprocessedMap", { zoomControl: false }).setView(sitePosition, 12)
|
||||
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
|
||||
marker = 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(postprocessedMap);
|
||||
|
||||
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("strategySelect").addEventListener("change", function() {
|
||||
strategy = this.value;
|
||||
urlParams.set("strategy", strategy);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("sigmaSelect").addEventListener("change", function() {
|
||||
sigma = this.value;
|
||||
urlParams.set("sigma", sigma);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("sourceSelect").addEventListener("change", function() {
|
||||
source = this.value;
|
||||
urlParams.set("source", source);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("fusionModeSelect").addEventListener("change", function() {
|
||||
fusionMode = this.value;
|
||||
urlParams.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
|
||||
await setSiteSeason(initialSite, initialSeason);
|
||||
}
|
||||
|
||||
document.getElementById("dateSlider").addEventListener("input", function() {
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
|
||||
drawPlots(); updateMap();
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Prepared S2/S3 Viewer</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="common.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: 15px; 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; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.header-sticky { position: sticky; top: 0; background: white; z-index: 1000; border-bottom: 1px solid #ccc; padding-bottom: 20px; margin-bottom: 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; }
|
||||
.season-row { padding-bottom: 15px; }
|
||||
h2 { margin: 0; font-size: 16px; color: #666; display: inline; }
|
||||
.download-links { margin-left: 10px; font-size: 14px; }
|
||||
.download-links a { margin-right: 8px; color: #0066cc; text-decoration: none; }
|
||||
.download-links a:hover { text-decoration: underline; }
|
||||
#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; }
|
||||
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
|
||||
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
|
||||
#preparedMap { height: 500px; border: 1px solid #ccc; margin-top: 10px; }
|
||||
.leaflet-image-layer { image-rendering: pixelated; }
|
||||
.leaflet-control-attribution { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header-sticky">
|
||||
<div class="nav">
|
||||
<a href="index.html">Full</a>
|
||||
<a href="preselection.html">Pre-selection</a>
|
||||
<a href="prepared.html" class="active">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>
|
||||
<h1 id="siteName">Innsbruck</h1>
|
||||
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
<label>Strategy:</label>
|
||||
<select id="strategySelect">
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="nonaggressive">Non-aggressive</option>
|
||||
</select>
|
||||
<label>Source:</label>
|
||||
<select id="sourceSelect">
|
||||
<option value="s2">S2</option>
|
||||
<option value="s3">S3</option>
|
||||
</select>
|
||||
<label>Mode:</label>
|
||||
<select id="fusionModeSelect" title="BtI = REFL/composite; ItB = GCC rasters">
|
||||
<option value="bti">BtI</option>
|
||||
<option value="itb">ItB</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="range" id="dateSlider" min="0" max="365" value="0">
|
||||
<div id="dateDisplay">2024-01-01</div>
|
||||
</div>
|
||||
<div class="map-label" id="mapLabel">Prepared RGB (closest available)</div>
|
||||
<div id="mapDate" class="map-date"></div>
|
||||
<div id="preparedMap"></div>
|
||||
<div id="plots">
|
||||
<div class="plot-label">NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>
|
||||
<div class="plot-label">GCC</div><canvas id="plot_gcc" class="plot"></canvas>
|
||||
<div class="plot-label">B02 (Blue)</div><canvas id="plot_b02" class="plot"></canvas>
|
||||
<div class="plot-label">B03 (Green)</div><canvas id="plot_b03" class="plot"></canvas>
|
||||
<div class="plot-label">B04 (Red)</div><canvas id="plot_b04" class="plot"></canvas>
|
||||
<div class="plot-label">B8A (NIR)</div><canvas id="plot_b8a" class="plot"></canvas>
|
||||
</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 siteName = "innsbruck", season = "2024";
|
||||
let strategy = "aggressive", source = "s2", fusionMode = "bti";
|
||||
let sitePosition = [47.116171, 11.320308];
|
||||
let start = new Date(2024, 0, 1);
|
||||
let availableSiteSeasons = {};
|
||||
let preparedMap = null, overlay = null, marker = null;
|
||||
let ndviTs = [], gccTs = [], bandsTs = [];
|
||||
const BANDS = [{key:"b02",color:"#0066ff"},{key:"b03",color:"#00aa00"},{key:"b04",color:"#cc0000"},{key:"b8a",color:"#9900cc"}];
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
function getPreparedPath() {
|
||||
const mid = fusionMode === "itb" ? `prepared_${strategy}_itb` : `prepared_${strategy}`;
|
||||
return `data/${siteName}/${season}/${mid}`;
|
||||
}
|
||||
|
||||
async function loadTimeseries() {
|
||||
try {
|
||||
const [n, g, b] = await Promise.all([
|
||||
fetch(`${getPreparedPath()}/ndvi/${source}/timeseries.json`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${getPreparedPath()}/gcc/${source}/timeseries.json`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${getPreparedPath()}/bands/${source}/timeseries.json`).then(r => r.ok ? r.json() : [])
|
||||
]);
|
||||
ndviTs = n; gccTs = g; bandsTs = b;
|
||||
} catch { ndviTs = []; gccTs = []; bandsTs = []; }
|
||||
drawPlots();
|
||||
updateDownloadLinks();
|
||||
}
|
||||
|
||||
function drawPlot(canvasId, data, key, 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 pts = data.filter(t => t[key] != null);
|
||||
if (!pts.length) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#999"; ctx.font = "12px sans-serif"; ctx.fillText("No data", pad, pad + plotH / 2); return; }
|
||||
const dates = pts.map(t => new Date(t.date));
|
||||
const vals = pts.map(t => t[key]);
|
||||
const minD = new Date(Math.min(...dates)), maxD = new Date(Math.max(...dates));
|
||||
const minV = Math.min(...vals), maxV = Math.max(...vals);
|
||||
const dRange = maxD - minD || 1, vRange = maxV - minV || 1;
|
||||
const x = d => pad + ((new Date(d) - minD) / dRange) * plotW;
|
||||
const y = v => pad + plotH - ((v - minV) / vRange) * 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(minV.toFixed(3), 2, pad + plotH + 10);
|
||||
ctx.fillText(maxV.toFixed(3), 2, pad + 3);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
pts.forEach((t, i) => { const px = x(t.date), py = y(t[key]); i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); });
|
||||
ctx.stroke();
|
||||
const curDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const xPos = x(curDate);
|
||||
ctx.strokeStyle = "#f00";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(xPos, pad); ctx.lineTo(xPos, pad + plotH); ctx.stroke();
|
||||
const closest = pts.reduce((c, t) => Math.abs(new Date(t.date) - new Date(curDate)) < Math.abs(new Date(c.date) - new Date(curDate)) ? t : c);
|
||||
if (closest) { ctx.fillStyle = "#f00"; ctx.font = "bold 10px sans-serif"; ctx.fillText(closest[key].toFixed(3), xPos + 5, y(closest[key]) - 5); }
|
||||
}
|
||||
|
||||
function drawPlots() {
|
||||
drawPlot("plot_ndvi", ndviTs, "ndvi", "#2d7a3e");
|
||||
drawPlot("plot_gcc", gccTs, "greenness_index", "#00aa00");
|
||||
BANDS.forEach(b => drawPlot(`plot_${b.key}`, bandsTs, b.key, b.color));
|
||||
}
|
||||
|
||||
function updateDownloadLinks() {
|
||||
const el = document.getElementById("downloadLinks");
|
||||
if (!el) return;
|
||||
const root = getPreparedPath();
|
||||
if (fusionMode === "itb") {
|
||||
el.innerHTML = `<a href="${root}/gcc/${source}/timeseries.json">[GCC JSON]</a>`;
|
||||
return;
|
||||
}
|
||||
const base = `${root}/export/${source}`;
|
||||
const name = `${siteName}_${season}_prepared_${strategy}_${source}`;
|
||||
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
|
||||
}
|
||||
|
||||
async function findPreparedFile(dateStr) {
|
||||
const target = new Date(dateStr);
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
const seasonStart = start.getTime();
|
||||
const seasonEnd = yearEnd.getTime();
|
||||
for (let offset = 0; offset <= 365; offset++) {
|
||||
for (const dir of offset === 0 ? [0] : [-1, 1]) {
|
||||
const d = new Date(target.getTime() + dir * offset * 86400000);
|
||||
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
|
||||
const ds = d.toISOString().split("T")[0].replace(/-/g, "");
|
||||
const filename =
|
||||
source === "s2"
|
||||
? fusionMode === "itb"
|
||||
? `S2A_MSIL2A_${ds}_GCC.tif`
|
||||
: `S2A_MSIL2A_${ds}_REFL.tif`
|
||||
: `composite_${ds}.tif`;
|
||||
try {
|
||||
const res = await fetch(`${getPreparedPath()}/${source}/${filename}`, { method: "HEAD" });
|
||||
if (res.ok) return filename;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 loadGeotiff(filename) {
|
||||
const path = `${getPreparedPath()}/${source}/${filename}`;
|
||||
const buf = await (await fetch(path)).arrayBuffer();
|
||||
const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
|
||||
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
|
||||
const m = filename.match(/(\d{8})/);
|
||||
const dateStr = m ? m[1] : "";
|
||||
return { dataUrl, bounds, dateStr };
|
||||
}
|
||||
|
||||
async function updateMap() {
|
||||
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
const filename = await findPreparedFile(dateStr);
|
||||
if (!filename || !preparedMap) {
|
||||
if (overlay) { preparedMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { dataUrl, bounds, dateStr: ds } = await loadGeotiff(filename);
|
||||
if (overlay) preparedMap.removeLayer(overlay);
|
||||
overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(preparedMap);
|
||||
preparedMap.fitBounds(bounds);
|
||||
document.getElementById("mapDate").textContent = `${ds.slice(0,4)}-${ds.slice(4,6)}-${ds.slice(6,8)}`;
|
||||
} catch (e) {
|
||||
if (overlay) { preparedMap.removeLayer(overlay); overlay = null; }
|
||||
document.getElementById("mapDate").textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDataExists(sitename, s) {
|
||||
try {
|
||||
const res = await fetch(`data/${sitename}/${s}/raw/preselection/s2_preselection.json`, { method: "HEAD" });
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function getSiteBySitename(sn) {
|
||||
return window.sitesData?.features?.find(f => f.properties?.sitename === sn);
|
||||
}
|
||||
|
||||
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 (preparedMap) { preparedMap.setView(sitePosition, 12); if (marker) marker.setLatLng(sitePosition); }
|
||||
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
|
||||
document.getElementById("season").textContent = season;
|
||||
const yearEnd = new Date(parseInt(season), 11, 31);
|
||||
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("site", siteName);
|
||||
params.set("season", season);
|
||||
params.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${params}`);
|
||||
const urlDate = params.get("date");
|
||||
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
|
||||
await loadTimeseries();
|
||||
await updateMap();
|
||||
}
|
||||
|
||||
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;
|
||||
strategy = urlParams.get("strategy") || "aggressive";
|
||||
source = urlParams.get("source") || "s2";
|
||||
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
|
||||
document.getElementById("strategySelect").value = strategy;
|
||||
document.getElementById("sourceSelect").value = source;
|
||||
document.getElementById("fusionModeSelect").value = fusionMode;
|
||||
const ml = document.getElementById("mapLabel");
|
||||
if (ml) ml.textContent = fusionMode === "itb" ? "Prepared GCC grayscale / S3 (closest available)" : "Prepared RGB (closest available)";
|
||||
|
||||
const initSite = getSiteBySitename(initialSite);
|
||||
if (initSite?.geometry?.coordinates) {
|
||||
const [lon, lat] = initSite.geometry.coordinates;
|
||||
sitePosition = [lat, lon];
|
||||
}
|
||||
preparedMap = L.map("preparedMap", { zoomControl: false }).setView(sitePosition, 12)
|
||||
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
|
||||
marker = 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(preparedMap);
|
||||
|
||||
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("strategySelect").addEventListener("change", function() {
|
||||
strategy = this.value;
|
||||
urlParams.set("strategy", strategy);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("sourceSelect").addEventListener("change", function() {
|
||||
source = this.value;
|
||||
urlParams.set("source", source);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
document.getElementById("fusionModeSelect").addEventListener("change", function() {
|
||||
fusionMode = this.value;
|
||||
urlParams.set("mode", fusionMode);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
const ml = document.getElementById("mapLabel");
|
||||
if (ml) ml.textContent = fusionMode === "itb" ? "Prepared GCC grayscale / S3 (closest available)" : "Prepared RGB (closest available)";
|
||||
loadTimeseries(); updateMap();
|
||||
});
|
||||
|
||||
await setSiteSeason(initialSite, initialSeason);
|
||||
}
|
||||
|
||||
document.getElementById("dateSlider").addEventListener("input", function() {
|
||||
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
|
||||
drawPlots(); updateMap();
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,541 +0,0 @@
|
|||
<!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; }
|
||||
.nav { margin-bottom: 15px; 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; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.header-sticky { position: sticky; top: 0; background: white; z-index: 1000; border-bottom: 1px solid #ccc; padding-bottom: 20px; margin-bottom: 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; }
|
||||
.season-row { padding-bottom: 15px; }
|
||||
h2 { margin: 0; font-size: 16px; color: #666; display: inline; }
|
||||
.download-links { margin-left: 10px; font-size: 14px; }
|
||||
.download-links a { margin-right: 8px; color: #0066cc; text-decoration: none; }
|
||||
.download-links a:hover { text-decoration: underline; }
|
||||
.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">
|
||||
<div class="header-sticky">
|
||||
<div class="nav">
|
||||
<a href="index.html">Full</a>
|
||||
<a href="preselection.html" class="active">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>
|
||||
<h1 id="siteName">Innsbruck</h1>
|
||||
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
|
||||
<div class="selectors">
|
||||
<label>Site:</label>
|
||||
<select id="siteSelect"></select>
|
||||
<label>Season:</label>
|
||||
<select id="seasonSelect"></select>
|
||||
<label>Source:</label>
|
||||
<select id="sourceSelect">
|
||||
<option value="s2">S2</option>
|
||||
<option value="s3">S3</option>
|
||||
</select>
|
||||
<label>Exclusion:</label>
|
||||
<select id="exclusionSelect">
|
||||
<option value="none">None</option>
|
||||
<option value="aggressive">Aggressive</option>
|
||||
<option value="nonaggressive">Non-aggressive</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="range" id="dateSlider" min="0" max="365" value="0">
|
||||
<div id="dateDisplay">2024-01-01</div>
|
||||
</div>
|
||||
<div class="map-label" id="mapLabel">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 source = "s2";
|
||||
let exclusion = "none";
|
||||
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);
|
||||
|
||||
function filteredTimeseries(arr) {
|
||||
if (exclusion === "none") return arr;
|
||||
const key = exclusion === "aggressive" ? "excluded_aggressive" : "excluded_nonaggressive";
|
||||
return arr.filter(t => !t[key]);
|
||||
}
|
||||
|
||||
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 = filteredTimeseries(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 = filteredTimeseries(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 = filteredTimeseries(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));
|
||||
}
|
||||
|
||||
function computeGcc(entry) {
|
||||
const b = entry.b02 + entry.b03 + entry.b04;
|
||||
return b > 0 ? entry.b03 / b : null;
|
||||
}
|
||||
|
||||
async function loadTimeseries() {
|
||||
const rawBase = `data/${siteName}/${season}/raw`;
|
||||
const src = document.getElementById("sourceSelect")?.value || "s2";
|
||||
source = src;
|
||||
try {
|
||||
const preselectionRes = await fetch(`${rawBase}/preselection/${source}_preselection.json`);
|
||||
const preselection = preselectionRes.ok ? await preselectionRes.json() : [];
|
||||
timeseries = preselection;
|
||||
ndviTimeseries = preselection;
|
||||
gccTimeseries = preselection.map(t => ({ ...t, greenness_index: computeGcc(t) })).filter(t => t.greenness_index != null);
|
||||
} catch {
|
||||
timeseries = [];
|
||||
ndviTimeseries = [];
|
||||
gccTimeseries = [];
|
||||
}
|
||||
const srcLabel = source.toUpperCase();
|
||||
document.getElementById("mapLabel").textContent = `${srcLabel} RGB (closest available)`;
|
||||
const jsonUrl = `${rawBase}/preselection/${source}_preselection.json`;
|
||||
const csvUrl = `${rawBase}/preselection/${source}_preselection.csv`;
|
||||
document.getElementById("downloadLinks").innerHTML =
|
||||
`<a href="${jsonUrl}" download="${siteName}_${season}_${source}_preselection.json" target="_blank">[JSON]</a>` +
|
||||
`<a href="${csvUrl}" download="${siteName}_${season}_${source}_preselection.csv" target="_blank">[CSV]</a>`;
|
||||
document.getElementById("bandPlots").innerHTML =
|
||||
`<div class="plot-label">${srcLabel} NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>` +
|
||||
`<div class="plot-label">${srcLabel} 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}/raw/preselection/s2_preselection.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("sourceSelect").value = urlParams.get("source") || "s2";
|
||||
exclusion = urlParams.get("exclusion") || "none";
|
||||
document.getElementById("exclusionSelect").value = exclusion;
|
||||
|
||||
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("sourceSelect").addEventListener("change", async function() {
|
||||
source = this.value;
|
||||
urlParams.set("source", source);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
await loadTimeseries();
|
||||
});
|
||||
document.getElementById("exclusionSelect").addEventListener("change", function() {
|
||||
exclusion = this.value;
|
||||
urlParams.set("exclusion", exclusion);
|
||||
history.replaceState({}, "", `?${urlParams}`);
|
||||
drawAllPlots();
|
||||
updateS2Imagery();
|
||||
});
|
||||
|
||||
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 = filteredTimeseries(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}/raw/${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(), 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue