Renaming.

This commit is contained in:
Felix Delattre 2026-02-20 21:57:42 +01:00
parent f9da4aef7d
commit 3919b8e871
12 changed files with 953 additions and 203 deletions

View file

@ -1,3 +1,4 @@
"""PhenoCam acquisition from PhenoCam Network API."""
import csv import csv
import json import json
import requests import requests
@ -213,4 +214,3 @@ def download_phenocam_greenness(season, site_position, site_name, date_range=Non
json.dump(timeseries, f, indent=2) json.dump(timeseries, f, indent=2)
print(f"[PhenoCam-GI] Saved: {output_file} ({len(timeseries)} entries)") print(f"[PhenoCam-GI] Saved: {output_file} ({len(timeseries)} entries)")

View file

@ -1,12 +1,16 @@
"""Sentinel-2-MSI acquisition from AWS Element84 Earth Search (STAC catalog)."""
import numpy as np
import rasterio import rasterio
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import requests import requests
from pathlib import Path from pathlib import Path
from rasterio.warp import transform_geom from rasterio.crs import CRS
from rasterio.warp import Resampling, calculate_default_transform, reproject, transform_geom
from rasterio.windows import from_bounds, transform as window_transform from rasterio.windows import from_bounds, transform as window_transform
from pystac_client import Client from pystac_client import Client
BBOX_SIZE = 0.011 BBOX_SIZE = 0.016
TARGET_CRS = CRS.from_epsg(32632)
def _get_bbox(lon, lat): def _get_bbox(lon, lat):
@ -128,10 +132,41 @@ def download_s2(season, site_position, site_name, date_range=None):
band_data[band_idx] = data[0] band_data[band_idx] = data[0]
if profile and len(band_data) == len(bands): if profile and len(band_data) == len(bands):
stacked = [band_data[i] for i in sorted(band_data.keys())] stacked = np.array([band_data[i] for i in sorted(band_data.keys())])
band_names = [list(bands.keys())[i] for i in sorted(band_data.keys())] band_names = [list(bands.keys())[i] for i in sorted(band_data.keys())]
viewing_angle = _extract_viewing_angle(item) viewing_angle = _extract_viewing_angle(item)
if profile["crs"] != TARGET_CRS:
src_transform = profile["transform"]
src_height, src_width = profile["height"], profile["width"]
left, bottom, right, top = rasterio.transform.array_bounds(
src_height, src_width, src_transform
)
dst_transform, dst_width, dst_height = calculate_default_transform(
profile["crs"], TARGET_CRS, src_width, src_height,
left=left, bottom=bottom, right=right, top=top,
)
reprojected = np.empty(
(len(stacked), dst_height, dst_width), dtype=stacked.dtype
)
for i in range(len(stacked)):
reproject(
source=stacked[i],
destination=reprojected[i],
src_transform=src_transform,
src_crs=profile["crs"],
dst_transform=dst_transform,
dst_crs=TARGET_CRS,
resampling=Resampling.bilinear,
)
stacked = reprojected
profile.update({
"crs": TARGET_CRS,
"transform": dst_transform,
"width": dst_width,
"height": dst_height,
})
with rasterio.open(filepath, "w", **profile) as dst: with rasterio.open(filepath, "w", **profile) as dst:
for i, data in enumerate(stacked, 1): for i, data in enumerate(stacked, 1):
dst.write(data, i) dst.write(data, i)

View file

@ -1,3 +1,4 @@
"""Sentinel-3-OLCI acquisition from Copernicus Data Space OpenEO API."""
import os import os
import time import time
from pathlib import Path from pathlib import Path

76
fusion.py Normal file
View file

@ -0,0 +1,76 @@
"""EFAST fusion: S2/S3 reflectance fusion for four scenarios."""
from pathlib import Path
from datetime import datetime, timedelta
from preselection import detect_clouds
from preparation import (
prepare_s2,
prepare_s3,
_get_base_dir,
RESOLUTION_RATIO,
)
def _import_efast():
"""Lazy import of efast to avoid import errors when not using efast functions."""
try:
import efast
return efast
except ImportError:
raise ImportError(
"efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git"
)
def run_efast(season, site_position, site_name, cleaning_strategy="aggressive", sigma=None, date_range=None):
lat, lon = site_position
datetime_range = date_range or f"{season}-01-01/{season}-12-31"
efast_base_dir = _get_base_dir(season, site_name, cleaning_strategy)
s2_output_dir = efast_base_dir / "s2"
s3_output_dir = efast_base_dir / "s3"
fusion_output_dir = efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion")
fusion_output_dir.mkdir(parents=True, exist_ok=True)
print(f"[EFAST] Starting fusion: {site_name} ({lat:.6f}, {lon:.6f}), {season}")
efast = _import_efast()
start_str, end_str = datetime_range.split("/")
start_date = datetime.strptime(start_str, "%Y-%m-%d")
end_date = datetime.strptime(end_str, "%Y-%m-%d")
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y%m%d")
output_file = fusion_output_dir / f"REFL_{date_str}.tif"
try:
kwargs = {
"product": "REFL",
"max_days": 30,
"date_position": 2,
"minimum_acquisition_importance": 0.0,
"ratio": RESOLUTION_RATIO,
}
if sigma is not None:
kwargs["sigma"] = sigma
efast.fusion(current_date, s3_output_dir, s2_output_dir, fusion_output_dir, **kwargs)
print(
f"[EFAST] Saved: {output_file}"
if output_file.exists()
else f"[EFAST] No output for {date_str} (insufficient nearby data)"
)
except Exception as e:
print(f"[EFAST] Error processing {date_str}: {e}")
current_date += timedelta(days=1)
print("[EFAST] Completed")
def run_all_efast_scenarios(season, site_position, site_name, sigma_value=30, date_range=None):
for strategy in ["aggressive", "nonaggressive"]:
detect_clouds(season, site_name, cleaning_strategy=strategy)
prepare_s2(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
prepare_s3(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=None, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma_value, date_range=date_range)

View file

@ -1,3 +1,4 @@
"""Index generation: NDVI and GCC from S2/S3/fusion GeoTIFFs."""
import json import json
import numpy as np import numpy as np
import rasterio import rasterio
@ -451,3 +452,60 @@ def create_gcc_timeseries_post_process(season, site_position, site_name):
input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/fusion/") input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/fusion/")
output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/gcc/fusion/") output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/gcc/fusion/")
_create_gcc_timeseries_for_dir(input_dir, output_dir, site_position, f"POST-PROCESS-FUSION-{strategy}-σ{sigma}") _create_gcc_timeseries_for_dir(input_dir, output_dir, site_position, f"POST-PROCESS-FUSION-{strategy}-σ{sigma}")
def _get_bands_from_original(input_file, site_position):
"""Extract mean B02, B03, B04, B8A from 3x3 window at site. Returns dict or None."""
try:
with rasterio.open(input_file) as src:
if src.count < 4:
return None
lon, lat = site_position[1], site_position[0]
x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat])
if not (
src.bounds.left <= x[0] <= src.bounds.right
and src.bounds.bottom <= y[0] <= src.bounds.top
):
return None
row, col = src.index(x[0], y[0])
r0, r1 = max(0, row - 1), min(src.height, row + 2)
c0, c1 = max(0, col - 1), min(src.width, col + 2)
bands = [src.read(i + 1, window=((r0, r1), (c0, c1))).astype(np.float32) for i in range(4)]
mask = ~np.any([np.isnan(b) for b in bands], axis=0)
mask &= np.all([b > 0 for b in bands], axis=0)
if not np.any(mask):
return None
return {
"b02": float(np.mean(bands[0][mask])),
"b03": float(np.mean(bands[1][mask])),
"b04": float(np.mean(bands[2][mask])),
"b8a": float(np.mean(bands[3][mask])),
}
except Exception:
return None
def _create_s2_bands_timeseries_for_dir(input_dir, output_dir, site_position):
print(f"[S2-BANDS] Creating timeseries.json...")
timeseries = []
for f in sorted(input_dir.glob("*.geotiff")):
date_str = f.stem.split("_")[0]
if len(date_str) != 8 or not date_str.isdigit():
continue
date = datetime.strptime(date_str, "%Y%m%d").isoformat()
bands = _get_bands_from_original(f, site_position)
timeseries.append({"date": date, "filename": f.name, **(bands or {})})
timeseries.sort(key=lambda x: x["date"])
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "timeseries.json").write_text(json.dumps(timeseries, indent=2))
print(f"[S2-BANDS] Saved: {output_dir / 'timeseries.json'} ({len(timeseries)} entries)")
def create_s2_bands_timeseries_post_process(season, site_position, site_name):
for strategy in ["aggressive", "nonaggressive"]:
for sigma in [20, 30]:
processed_dir = f"processed_{strategy}_sigma{sigma}"
input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/s2/")
output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/s2_bands/")
if input_dir.exists():
_create_s2_bands_timeseries_for_dir(input_dir, output_dir, site_position)

View file

@ -1,4 +1,4 @@
"""Calculate metrics comparing fusion-derived GCC with phenocam GCC ground truth.""" """Metrics and statistics: temporal/spatial metrics and PhenoCam stats."""
import json import json
import numpy as np import numpy as np
from pathlib import Path from pathlib import Path
@ -7,7 +7,7 @@ from scipy.stats import pearsonr
import rasterio import rasterio
from rasterio.warp import transform as transform_coords from rasterio.warp import transform as transform_coords
from generate_indexes import BLUE_BAND, GREEN_BAND, RED_BAND from metrics_indices import BLUE_BAND, GREEN_BAND, RED_BAND
def load_timeseries(filepath): def load_timeseries(filepath):
@ -349,8 +349,8 @@ def main():
import sys import sys
if len(sys.argv) < 4: if len(sys.argv) < 4:
print("Usage: calculate_metrics.py <season> <site_name> <lat> <lon>") print("Usage: metrics_stats.py <season> <site_name> <lat> <lon>")
print("Example: calculate_metrics.py 2024 innsbruck 47.116171 11.320308") print("Example: metrics_stats.py 2024 innsbruck 47.116171 11.320308")
sys.exit(1) sys.exit(1)
season = int(sys.argv[1]) season = int(sys.argv[1])

View file

@ -1,3 +1,4 @@
"""Post-processing: crop fusion/S2/S3 to valid pixels."""
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import rasterio import rasterio
@ -100,3 +101,8 @@ def process_all_scenarios(season, site_position, site_name):
for strategy in ["aggressive", "nonaggressive"]: for strategy in ["aggressive", "nonaggressive"]:
for sigma in [None, 30]: for sigma in [None, 30]:
process_cropped(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma) process_cropped(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma)
# Aliases
postprocess = process_cropped
postprocess_all_scenarios = process_all_scenarios

View file

@ -1,7 +1,7 @@
"""Data preparation: S2/S3 preprocessing for fusion."""
import json import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
import numpy as np import numpy as np
import rasterio import rasterio
@ -9,24 +9,20 @@ from rasterio.warp import Resampling
from rasterio.vrt import WarpedVRT from rasterio.vrt import WarpedVRT
from rasterio import shutil as rio_shutil from rasterio import shutil as rio_shutil
RESOLUTION_RATIO = 21
def _import_efast():
"""Lazy import of efast to avoid import errors when not using efast functions.""" def _import_distance_to_clouds():
"""Lazy import of efast.distance_to_clouds."""
try: try:
import efast
from efast.s2_processing import distance_to_clouds from efast.s2_processing import distance_to_clouds
from efast.s3_processing import reproject_and_crop_s3 return distance_to_clouds
return efast, distance_to_clouds, reproject_and_crop_s3
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git" "efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git"
) )
RESOLUTION_RATIO = 21
def _load_clouds(clouds_file): def _load_clouds(clouds_file):
clouds = {"s2": set(), "s3": set()} clouds = {"s2": set(), "s3": set()}
if clouds_file.exists(): if clouds_file.exists():
@ -100,12 +96,7 @@ def prepare_s2(season, site_position, site_name, cleaning_strategy="aggressive",
temp_normalized = s2_output_dir / f"temp_{s2_file.name}" temp_normalized = s2_output_dir / f"temp_{s2_file.name}"
with rasterio.open(s2_file) as src: with rasterio.open(s2_file) as src:
pb = src.tags().get("PROCESSING_BASELINE", "") data = src.read().astype("float32") / 10000.0
data = src.read().astype("float32")
mask_nodata = data == 0
data = (data - 1000) / 10000.0 if pb >= "04.00" else data / 10000.0
data = np.maximum(data, 0)
data[mask_nodata] = 0
profile = src.profile.copy() profile = src.profile.copy()
profile.update({"dtype": "float32", "nodata": 0}) profile.update({"dtype": "float32", "nodata": 0})
with rasterio.open(temp_normalized, "w", **profile) as dst: with rasterio.open(temp_normalized, "w", **profile) as dst:
@ -116,7 +107,7 @@ def prepare_s2(season, site_position, site_name, cleaning_strategy="aggressive",
) )
temp_normalized.unlink() temp_normalized.unlink()
_, distance_to_clouds, _ = _import_efast() distance_to_clouds = _import_distance_to_clouds()
distance_to_clouds(s2_output_dir, ratio=RESOLUTION_RATIO) distance_to_clouds(s2_output_dir, ratio=RESOLUTION_RATIO)
@ -200,59 +191,3 @@ def prepare_s3(season, site_position, site_name, cleaning_strategy="aggressive",
rio_shutil.copy(vrt, outfile, **profile) rio_shutil.copy(vrt, outfile, **profile)
shutil.rmtree(temp_composite_dir) shutil.rmtree(temp_composite_dir)
def run_efast(season, site_position, site_name, cleaning_strategy="aggressive", sigma=None, date_range=None):
lat, lon = site_position
datetime_range = date_range or f"{season}-01-01/{season}-12-31"
efast_base_dir = _get_base_dir(season, site_name, cleaning_strategy)
s2_output_dir = efast_base_dir / "s2"
s3_output_dir = efast_base_dir / "s3"
fusion_output_dir = efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion")
fusion_output_dir.mkdir(parents=True, exist_ok=True)
print(f"[EFAST] Starting fusion: {site_name} ({lat:.6f}, {lon:.6f}), {season}")
efast, _, _ = _import_efast()
start_str, end_str = datetime_range.split("/")
start_date = datetime.strptime(start_str, "%Y-%m-%d")
end_date = datetime.strptime(end_str, "%Y-%m-%d")
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y%m%d")
output_file = fusion_output_dir / f"REFL_{date_str}.tif"
try:
kwargs = {
"product": "REFL",
"max_days": 30,
"date_position": 2,
"minimum_acquisition_importance": 0.0,
"ratio": RESOLUTION_RATIO,
}
if sigma is not None:
kwargs["sigma"] = sigma
efast.fusion(current_date, s3_output_dir, s2_output_dir, fusion_output_dir, **kwargs)
print(
f"[EFAST] Saved: {output_file}"
if output_file.exists()
else f"[EFAST] No output for {date_str} (insufficient nearby data)"
)
except Exception as e:
print(f"[EFAST] Error processing {date_str}: {e}")
current_date += timedelta(days=1)
print("[EFAST] Completed")
def run_all_efast_scenarios(season, site_position, site_name, sigma_value=30, date_range=None):
from clouds import detect_clouds
for strategy in ["aggressive", "nonaggressive"]:
detect_clouds(season, site_name, cleaning_strategy=strategy)
prepare_s2(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
prepare_s3(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=None, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma_value, date_range=date_range)

View file

@ -1,3 +1,4 @@
"""Pre-selection: NDVI-based cloud/flaw filtering for S2 and S3 data."""
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -8,6 +9,7 @@ THRESHOLDS = {"aggressive": {"threshold": 0.3, "delta": 0.15}, "nonaggressive":
def detect_clouds(season, site_name, cleaning_strategy="aggressive"): def detect_clouds(season, site_name, cleaning_strategy="aggressive"):
"""Filter cloud-covered/flawed S2 and S3 files using NDVI thresholds."""
output_file = Path(f"data/{site_name}/{season}/clouds_{cleaning_strategy}.json") output_file = Path(f"data/{site_name}/{season}/clouds_{cleaning_strategy}.json")
clouds = {"s2": [], "s3": []} clouds = {"s2": [], "s3": []}
thresholds = THRESHOLDS[cleaning_strategy] thresholds = THRESHOLDS[cleaning_strategy]
@ -61,3 +63,7 @@ def detect_clouds(season, site_name, cleaning_strategy="aggressive"):
json.dump(clouds, f, indent=2) json.dump(clouds, f, indent=2)
print(f"[CLOUDS] Saved: {output_file}") print(f"[CLOUDS] Saved: {output_file}")
# Alias for backward compatibility
preselect = detect_clouds

38
run.py
View file

@ -1,31 +1,28 @@
from call_efast import run_all_efast_scenarios from fusion import run_all_efast_scenarios
from post_process import process_all_scenarios from postprocessing import process_all_scenarios
from generate_indexes import ( from metrics_indices import (
generate_ndvi_raw,
create_ndvi_timeseries_raw, create_ndvi_timeseries_raw,
generate_ndvi_post_process,
create_ndvi_timeseries_post_process, create_ndvi_timeseries_post_process,
generate_gcc_post_process,
create_gcc_timeseries_post_process, create_gcc_timeseries_post_process,
create_s2_bands_timeseries_post_process,
) )
from download_s2 import download_s2 from acquisition_s2 import download_s2
from download_s3 import download_s3 from acquisition_s3 import download_s3
from download_phenocam import download_phenocam, download_phenocam_greenness from acquisition_phenocam import download_phenocam, download_phenocam_greenness
from clouds import detect_clouds from metrics_stats import calculate_all_metrics
from calculate_metrics import calculate_all_metrics
def run_pipeline(season, site_position, site_name): def run_pipeline(season, site_position, site_name):
"""Run pipeline (downloads + EFAST fusion + post-process + metrics).""" """Run pipeline (downloads + EFAST fusion + post-process + metrics)."""
try: try:
# Download steps (needed for new site/season) # Download steps (needed for new site/season)
download_s2(season, site_position, site_name) #download_s2(season, site_position, site_name)
download_s3(season, site_position, site_name) #download_s3(season, site_position, site_name)
download_phenocam(season, site_position, site_name) #download_phenocam(season, site_position, site_name)
download_phenocam_greenness(season, site_position, site_name) #download_phenocam_greenness(season, site_position, site_name)
print(f"Generating NDVI for raw data: {site_name}, {season}") #print(f"Generating NDVI for raw data: {site_name}, {season}")
create_ndvi_timeseries_raw(season, site_position, site_name) #create_ndvi_timeseries_raw(season, site_position, site_name)
print(f"Running EFAST fusion for all scenarios: {site_name}, {season}") print(f"Running EFAST fusion for all scenarios: {site_name}, {season}")
run_all_efast_scenarios(season, site_position, site_name) run_all_efast_scenarios(season, site_position, site_name)
@ -39,6 +36,9 @@ def run_pipeline(season, site_position, site_name):
print(f"Generating GCC for final outputs: {site_name}, {season}") print(f"Generating GCC for final outputs: {site_name}, {season}")
create_gcc_timeseries_post_process(season, site_position, site_name) create_gcc_timeseries_post_process(season, site_position, site_name)
print(f"Generating S2 band timeseries: {site_name}, {season}")
create_s2_bands_timeseries_post_process(season, site_position, site_name)
print(f"Calculating metrics: {site_name}, {season}") print(f"Calculating metrics: {site_name}, {season}")
calculate_all_metrics(season, site_name, site_position) calculate_all_metrics(season, site_name, site_position)
@ -48,6 +48,8 @@ def run_pipeline(season, site_position, site_name):
if __name__ == "__main__": if __name__ == "__main__":
run_pipeline(2024, (47.116171, 11.320308), "innsbruck")
# forthgr - FORTH Heraklion Greece, Agriculture, 2024 # forthgr - FORTH Heraklion Greece, Agriculture, 2024
# sites.geojson: lon=25.0743, lat=35.3045 # sites.geojson: lon=25.0743, lat=35.3045
run_pipeline(2024, (35.3045, 25.0743), "forthgr") #run_pipeline(2024, (35.3045, 25.0743), "forthgr")

View file

@ -11,6 +11,9 @@
.slider-container { position: sticky; top: 0; background: white; padding: 20px; z-index: 1000; border-bottom: 1px solid #ccc; } .slider-container { position: sticky; top: 0; background: white; padding: 20px; z-index: 1000; border-bottom: 1px solid #ccc; }
.scenario-selector { margin-bottom: 10px; } .scenario-selector { margin-bottom: 10px; }
.scenario-selector select { padding: 5px 10px; font-size: 14px; } .scenario-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector { margin-bottom: 10px; }
.site-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector label { margin-right: 5px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { display: flex; gap: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-top: 10px;padding-bottom: 20px;} .header { display: flex; gap: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-top: 10px;padding-bottom: 20px;}
.header-col { flex: 1; } .header-col { flex: 1; }
@ -60,6 +63,12 @@
<div id="sitemap" class="sitemap"></div> <div id="sitemap" class="sitemap"></div>
</div> </div>
</div> </div>
<div class="site-selector">
<label for="siteSelect">Site: </label>
<select id="siteSelect"></select>
<label for="seasonSelect">Season: </label>
<select id="seasonSelect"></select>
</div>
<div class="scenario-selector"> <div class="scenario-selector">
<label for="scenarioSelect">Scenario: </label> <label for="scenarioSelect">Scenario: </label>
<select id="scenarioSelect"> <select id="scenarioSelect">
@ -118,15 +127,16 @@
proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs"); proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
const start = new Date(2024, 0, 1); let start = new Date(2024, 0, 1);
const slider = document.getElementById("dateSlider"); const slider = document.getElementById("dateSlider");
const dateDisplay = document.getElementById("dateDisplay"); const dateDisplay = document.getElementById("dateDisplay");
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const osmOpts = { attribution: "OpenStreetMap", opacity: 0.4 }; const osmOpts = { attribution: "OpenStreetMap", opacity: 0.4 };
const mapOpts = { zoomControl: false }; const mapOpts = { zoomControl: false };
const sitePosition = [47.116171, 11.320308]; let sitePosition = [47.116171, 11.320308];
const siteName = "innsbruck"; let siteName = "innsbruck";
const season = "2024"; let season = "2024";
let sitesData = null;
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
const strategy = urlParams.get("strategy") || "aggressive"; const strategy = urlParams.get("strategy") || "aggressive";
@ -139,7 +149,7 @@
let allScenariosGCC = {}; let allScenariosGCC = {};
let metricsData = null; let metricsData = null;
const siteMap = L.map("sitemap", { zoomControl: false }).setView(sitePosition, 4).addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 1 })); const siteMap = L.map("sitemap", { zoomControl: false }).setView(sitePosition, 4).addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 1 }));
L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(siteMap); const siteMarker = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(siteMap);
const maps = { const maps = {
s2: L.map("s2map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)), s2: L.map("s2map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
fusion: L.map("fusionmap", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)), fusion: L.map("fusionmap", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
@ -173,6 +183,7 @@
}); });
async function loadTimeseries() { async function loadTimeseries() {
metricsData = null;
const fusionPath = getFusionPath(); const fusionPath = getFusionPath();
const [s2, fusion, s3, s2gcc, fusiongcc, s3gcc, phenocam] = await Promise.all([ const [s2, fusion, s3, s2gcc, fusiongcc, s3gcc, phenocam] = await Promise.all([
fetch(`../data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s2/timeseries.json`).then(r => r.json()).catch(() => []), fetch(`../data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s2/timeseries.json`).then(r => r.json()).catch(() => []),
@ -526,8 +537,13 @@
function drawMetricsTable() { function drawMetricsTable() {
const container = document.getElementById("metricsTable"); const container = document.getElementById("metricsTable");
const hasAnyData = timeseries.s2.length || timeseries.fusion.length || timeseries.s3.length || phenocamGreennessTimeseries.length;
if (!hasAnyData) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>No data for this site/season.</p>";
return;
}
if (!metricsData || !metricsData.temporal) { if (!metricsData || !metricsData.temporal) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>Metrics not available. Run calculate_metrics.py to generate.</p>"; container.innerHTML = "<p style='color:#666; font-size:12px;'>Metrics not available. Run the pipeline (run.py) or metrics_stats.py to generate.</p>";
return; return;
} }
@ -789,6 +805,8 @@
const date = dateFromDays(parseInt(slider.value)); const date = dateFromDays(parseInt(slider.value));
dateDisplay.textContent = date; dateDisplay.textContent = date;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("site", siteName);
params.set("season", season);
params.set("date", date); params.set("date", date);
params.set("strategy", strategy); params.set("strategy", strategy);
if (sigma !== "20") params.set("sigma", sigma); if (sigma !== "20") params.set("sigma", sigma);
@ -822,10 +840,131 @@
window.location.search = params.toString(); window.location.search = params.toString();
}); });
const urlDate = urlParams.get("date"); const siteSelect = document.getElementById("siteSelect");
const seasonSelect = document.getElementById("seasonSelect");
function getSiteBySitename(sitename) {
return sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
let availableSiteSeasons = {}; // { sitename: [season, ...] }
function populateSeasonOptions(sitename) {
seasonSelect.innerHTML = "";
const seasons = availableSiteSeasons[sitename] || [];
for (const s of seasons) {
const opt = document.createElement("option");
opt.value = s;
opt.textContent = s;
seasonSelect.appendChild(opt);
}
}
async function probeDataExists(sitename, season) {
try {
const res = await fetch(`../data/${sitename}/${season}/metrics.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
async function setSiteSeason(newSiteName, newSeason) {
const site = getSiteBySitename(newSiteName);
let pos;
let description;
if (site) {
const [lon, lat] = site.geometry.coordinates;
pos = [lat, lon];
description = site.properties.description || newSiteName;
} else {
pos = [47.116171, 11.320308];
description = newSiteName;
}
siteName = newSiteName;
season = newSeason;
sitePosition = pos;
start = new Date(parseInt(season), 0, 1);
const yearEnd = new Date(parseInt(season), 11, 31);
slider.max = Math.ceil((yearEnd - start) / 86400000);
document.getElementById("siteName").textContent = description;
document.getElementById("season").textContent = season;
siteMap.setView(sitePosition, 4);
siteMarker.setLatLng(sitePosition);
for (const source of ["s2", "fusion", "s3"]) {
maps[source].setView(sitePosition, 12);
markers[source].setLatLng(sitePosition);
}
const params = new URLSearchParams(location.search);
params.set("site", siteName);
params.set("season", season);
history.replaceState({}, "", `?${params}`);
await loadTimeseries();
const urlDate = params.get("date");
if (urlDate) slider.value = daysFromDate(urlDate); if (urlDate) slider.value = daysFromDate(urlDate);
await updateImages();
}
async function init() {
try {
const res = await fetch("../data/sites.geojson");
if (!res.ok) throw new Error("Could not load sites");
sitesData = await res.json();
} catch (e) {
console.error("Failed to load sites.geojson:", e);
sitesData = { features: [] };
}
const features = sitesData.features || [];
availableSiteSeasons = {};
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeDataExists(sn, s)) withData.push(s);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
siteSelect.innerHTML = "";
if (availableSites.length === 0) {
const opt = document.createElement("option");
opt.value = "innsbruck";
opt.textContent = "innsbruck";
siteSelect.appendChild(opt);
availableSiteSeasons.innsbruck = ["2024"];
} else {
for (const sn of availableSites.sort()) {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
}
}
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = urlSite && availableSites.includes(urlSite) ? urlSite : availableSites[0] || "innsbruck";
siteName = initialSite;
siteSelect.value = initialSite;
populateSeasonOptions(initialSite);
const seasons = availableSiteSeasons[initialSite] || [];
const initialSeason = urlSeason && seasons.includes(urlSeason) ? urlSeason : (seasons[0] || "2024");
season = initialSeason;
seasonSelect.value = initialSeason;
siteSelect.addEventListener("change", function() {
const sn = this.value;
populateSeasonOptions(sn);
const seas = availableSiteSeasons[sn] || [];
seasonSelect.value = seas[0] || season;
setSiteSeason(sn, seasonSelect.value);
});
seasonSelect.addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
await setSiteSeason(initialSite, initialSeason);
}
slider.addEventListener("input", updateImages); slider.addEventListener("input", updateImages);
loadTimeseries().then(updateImages); init();
</script> </script>
</body> </body>
</html> </html>

492
webapp/s2-timeseries.html Normal file
View file

@ -0,0 +1,492 @@
<!DOCTYPE html>
<html>
<head>
<title>S2 Band Reflectance Timeseries</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<style>
body { margin: 0; font-family: sans-serif; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.selectors { margin-bottom: 20px; }
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
h1 { margin: 0 0 5px 0; font-size: 22px; }
h2 { margin: 0 0 15px 0; font-size: 16px; color: #666; }
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
#dateSlider { width: 100%; margin: 15px 0; }
#dateDisplay { text-align: center; font-size: 14px; color: #666; }
.map-label { font-size: 12px; margin-bottom: 3px; color: #666; }
.map-date { font-size: 11px; margin-top: 3px; color: #999; }
#s2map { height: 400px; border: 1px solid #ccc; margin-top: 10px; }
.leaflet-image-layer { image-rendering: pixelated; }
.leaflet-control-attribution { display: none; }
</style>
</head>
<body>
<div class="container">
<h1 id="siteName">Innsbruck</h1>
<h2 id="season">2024</h2>
<div class="selectors">
<label>Site:</label>
<select id="siteSelect"></select>
<label>Season:</label>
<select id="seasonSelect"></select>
<label>Scenario:</label>
<select id="scenarioSelect">
<option value="aggressive_20">Aggressive σ20</option>
<option value="aggressive_30">Aggressive σ30</option>
<option value="nonaggressive_20">Non-aggressive σ20</option>
<option value="nonaggressive_30">Non-aggressive σ30</option>
</select>
</div>
<input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div>
<div class="map-label">S2 RGB (closest available)</div>
<div id="s2rgbdate" class="map-date"></div>
<div id="s2map"></div>
<div id="bandPlots"></div>
</div>
<script>
proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
const BANDS = [
{ key: "b02", label: "B02 (Blue)", color: "#0066ff" },
{ key: "b03", label: "B03 (Green)", color: "#00aa00" },
{ key: "b04", label: "B04 (Red)", color: "#cc0000" },
{ key: "b8a", label: "B8A (NIR)", color: "#9900cc" }
];
let siteName = "innsbruck", season = "2024";
let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1);
let timeseries = [];
let gccTimeseries = [];
let ndviTimeseries = [];
let availableSiteSeasons = {};
let s2Map = null, s2Overlay = null, s2Marker = null;
const urlParams = new URLSearchParams(location.search);
const [strategy, sigma] = (urlParams.get("scenario") || "aggressive_20").split("_");
function getBasePath() {
return `processed_${strategy}_sigma${sigma || "20"}`;
}
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const dateFromDays = (days) => fmtDate(new Date(start.getTime() + days * 86400000));
const daysFromDate = (dateStr) => {
const [y, m, d] = dateStr.split("-").map(Number);
return Math.floor((new Date(y, m - 1, d) - start) / 86400000);
};
function drawBandPlot(canvasId, bandKey, bandLabel, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = timeseries.filter(t => t[bandKey] != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t[bandKey]);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(4), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(4), 2, pad + 3);
ctx.strokeStyle = color;
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t[bandKey]);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest[bandKey].toFixed(4), xPos + 5, y(closest[bandKey]) - 5);
}
}
function drawNdviPlot() {
const canvas = document.getElementById("plot_ndvi");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = ndviTimeseries.filter(t => t.ndvi != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.ndvi);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#2d7a3e";
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.ndvi);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.ndvi.toFixed(3), xPos + 5, y(closest.ndvi) - 5);
}
}
function drawGccPlot() {
const canvas = document.getElementById("plot_gcc");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = gccTimeseries.filter(t => t.greenness_index != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.greenness_index);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#00aa00";
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.greenness_index);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.greenness_index.toFixed(3), xPos + 5, y(closest.greenness_index) - 5);
}
}
function drawAllPlots() {
drawNdviPlot();
drawGccPlot();
BANDS.forEach(b => drawBandPlot(`plot_${b.key}`, b.key, b.label, b.color));
}
async function loadTimeseries() {
const base = `../data/${siteName}/${season}/${getBasePath()}`;
try {
const [bandsRes, gccRes, ndviRes] = await Promise.all([
fetch(`${base}/s2_bands/timeseries.json`),
fetch(`${base}/gcc/s2/timeseries.json`),
fetch(`${base}/ndvi/s2/timeseries.json`)
]);
timeseries = bandsRes.ok ? await bandsRes.json() : [];
gccTimeseries = gccRes.ok ? await gccRes.json() : [];
ndviTimeseries = ndviRes.ok ? await ndviRes.json() : [];
} catch {
timeseries = [];
gccTimeseries = [];
ndviTimeseries = [];
}
document.getElementById("bandPlots").innerHTML =
`<div class="plot-label">S2 NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>` +
`<div class="plot-label">S2 GCC (Greenness Index)</div><canvas id="plot_gcc" class="plot"></canvas>` +
BANDS.map(b => `<div class="plot-label">${b.label}</div><canvas id="plot_${b.key}" class="plot"></canvas>`).join("");
const yearEnd = new Date(parseInt(season), 11, 31);
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
drawAllPlots();
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
updateS2Imagery();
}
async function probeDataExists(sitename, s) {
try {
const res = await fetch(`../data/${sitename}/${s}/processed_aggressive_sigma20/s2_bands/timeseries.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
function getSiteBySitename(sitename) {
return window.sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
async function setSiteSeason(newSite, newSeason) {
siteName = newSite;
season = newSeason;
start = new Date(parseInt(season), 0, 1);
const site = getSiteBySitename(newSite);
if (site?.geometry?.coordinates) {
const [lon, lat] = site.geometry.coordinates;
sitePosition = [lat, lon];
}
if (s2Map) { s2Map.setView(sitePosition, 12); if (s2Marker) s2Marker.setLatLng(sitePosition); }
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
document.getElementById("season").textContent = season;
const params = new URLSearchParams(location.search);
params.set("site", siteName);
params.set("season", season);
history.replaceState({}, "", `?${params}`);
await loadTimeseries();
const urlDate = params.get("date");
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
}
async function init() {
try {
const res = await fetch("../data/sites.geojson");
window.sitesData = res.ok ? await res.json() : { features: [] };
} catch {
window.sitesData = { features: [] };
}
const features = window.sitesData.features || [];
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeDataExists(sn, s)) withData.push(s);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
const siteSelect = document.getElementById("siteSelect");
siteSelect.innerHTML = "";
(availableSites.length ? availableSites.sort() : ["innsbruck"]).forEach(sn => {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
if (!availableSiteSeasons[sn]) availableSiteSeasons[sn] = ["2024"];
});
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = (urlSite && availableSiteSeasons[urlSite]) ? urlSite : (availableSites[0] || "innsbruck");
const initialSeason = (urlSeason && (availableSiteSeasons[initialSite] || []).includes(urlSeason)) ? urlSeason : ((availableSiteSeasons[initialSite] || [])[0] || "2024");
siteSelect.value = initialSite;
document.getElementById("seasonSelect").innerHTML = (availableSiteSeasons[initialSite] || []).map(s =>
`<option value="${s}">${s}</option>`
).join("");
document.getElementById("seasonSelect").value = initialSeason;
document.getElementById("scenarioSelect").value = `${strategy}_${sigma || "20"}`;
const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) {
const [lon, lat] = initSite.geometry.coordinates;
sitePosition = [lat, lon];
}
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
s2Map = L.map("s2map", { zoomControl: false }).setView(sitePosition, 12)
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
s2Marker = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(s2Map);
siteSelect.addEventListener("change", function() {
const sn = this.value;
const seas = availableSiteSeasons[sn] || [];
document.getElementById("seasonSelect").innerHTML = seas.map(s => `<option value="${s}">${s}</option>`).join("");
document.getElementById("seasonSelect").value = seas[0] || "2024";
setSiteSeason(sn, document.getElementById("seasonSelect").value);
});
document.getElementById("seasonSelect").addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
document.getElementById("scenarioSelect").addEventListener("change", function() {
const p = new URLSearchParams(location.search);
p.set("scenario", this.value);
window.location.search = p.toString();
});
await setSiteSeason(initialSite, initialSeason);
}
document.getElementById("dateSlider").addEventListener("input", function() {
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
drawAllPlots();
updateS2Imagery();
});
function closestFilename(dateStr) {
const target = new Date(dateStr);
const withData = timeseries.filter(t => t.filename);
if (!withData.length) return null;
const closest = withData.reduce((c, t) =>
Math.abs(new Date(t.date) - target) < Math.abs(new Date(c.date) - target) ? t : c
);
return closest.filename;
}
function transformBounds(bbox, fromCRS) {
const sw = proj4(fromCRS, "EPSG:4326", [bbox[0], bbox[1]]);
const ne = proj4(fromCRS, "EPSG:4326", [bbox[2], bbox[3]]);
return [[sw[1], sw[0]], [ne[1], ne[0]]];
}
async function loadS2Geotiff(filename) {
const path = `../data/${siteName}/${season}/${getBasePath()}/s2/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer());
const image = await tiff.getImage();
const rasters = await image.readRasters();
const width = image.getWidth(), height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
(geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326");
const [blue, green, red] = [0, 1, 2].map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rN, gN, bN] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[i]; imgData.data[idx + 3] = 255; }
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
return { dataUrl: canvas.toDataURL(), bounds };
}
async function updateS2Imagery() {
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const filename = closestFilename(dateStr);
if (!filename || !s2Map) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
return;
}
try {
const { dataUrl, bounds } = await loadS2Geotiff(filename);
if (s2Overlay) s2Map.removeLayer(s2Overlay);
s2Overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(s2Map);
s2Map.fitBounds(bounds);
const d = filename.split("_")[0];
document.getElementById("s2rgbdate").textContent = `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
} catch (e) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
}
}
init();
</script>
</body>
</html>