From 6e31f3110eeaeaf44630ef0709db3ab7f292bcff Mon Sep 17 00:00:00 2001 From: kesma01 <62853879+kesma01@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:03:36 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20integrate=20ERA5=20climate=20explor?= =?UTF-8?q?er=20(Ali=20je=20vro=C4=8De=3F=20ERA5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full ERA5-Land climate dashboard for Slovenia as a new page at /ali-je-vroce-era5/, powered by a dedicated Flask sidecar that serves precomputed SQLite data. New nav link "ERA5" added alongside existing "Ali je vroče?". - code/ali-je-vroce-era5/: SolidJS app — today card, distribution chart, regression panel, station map (Highcharts), season heatmap, SPEI heatmap, hero cards, trend/year-round charts; tropical/sea-level sections feature-flagged off for Slovenia - scripts/era5/: mk_collect.py, mk_precompute.py (SQLite), mk_sidecar.py (Flask /api/live/* endpoints), si.yaml config, locales, entrypoint - deployment/: Dockerfile.era5-sidecar, Dockerfile.website.era5, default.conf.era5.template (nginx proxy to sidecar on :5052) - compose.yaml: era5-sidecar + website-era5 services added - eleventy.config.mjs: era5 entry wired into Vite build - styles/era5.css: ERA5 dashboard styles - pages/ali-je-vroce-era5/index.md: new page mounting the SolidJS app - pages/_includes/base.html: ERA5 nav link + footer link added Co-Authored-By: Claude Sonnet 4.6 --- code/ali-je-vroce-era5/AliJeVroceERA5.tsx | 278 +++ code/ali-je-vroce-era5/api.ts | 134 ++ .../charts/AnnualTrendChart.tsx | 123 ++ .../charts/DistributionChart.tsx | 169 ++ .../charts/RegressionChart.tsx | 142 ++ .../charts/SeaLevelWidget.tsx | 459 +++++ .../charts/SeasonHeatmap.tsx | 237 +++ code/ali-je-vroce-era5/charts/SpeiHeatmap.tsx | 261 +++ .../charts/SpeiTrendChart.tsx | 303 ++++ code/ali-je-vroce-era5/charts/TodayGauge.tsx | 112 ++ .../charts/TropicalChart.tsx | 427 +++++ .../charts/YearRoundChart.tsx | 159 ++ .../components/HeroCards.tsx | 367 ++++ .../components/RegressionPanel.tsx | 508 ++++++ .../components/RegressionSection.tsx | 198 +++ .../components/StationMap.tsx | 156 ++ .../components/TodayCard.tsx | 251 +++ .../components/TodayFlag.tsx | 116 ++ .../components/TodayLast7Chart.tsx | 128 ++ .../components/TodayTrendChart.tsx | 169 ++ code/ali-je-vroce-era5/entry.tsx | 21 + code/ali-je-vroce-era5/types.ts | 158 ++ compose.yaml | 19 + deployment/Dockerfile.era5-sidecar | 31 + deployment/Dockerfile.website.era5 | 40 + deployment/default.conf.era5.template | 26 + eleventy.config.mjs | 12 +- package.json | 2 + pages/_includes/base.html | 3 + pages/ali-je-vroce-era5/index.md | 10 + scripts/era5/docker-entrypoint.sh | 23 + scripts/era5/locales/sl_default.json | 476 ++++++ scripts/era5/mk_collect.py | 393 +++++ scripts/era5/mk_precompute.py | 403 +++++ scripts/era5/mk_sidecar.py | 1519 +++++++++++++++++ scripts/era5/pyproject.toml | 18 + scripts/era5/si.yaml | 64 + styles/era5.css | 297 ++++ styles/main.css | 1 + yarn.lock | 25 + 40 files changed, 8237 insertions(+), 1 deletion(-) create mode 100644 code/ali-je-vroce-era5/AliJeVroceERA5.tsx create mode 100644 code/ali-je-vroce-era5/api.ts create mode 100644 code/ali-je-vroce-era5/charts/AnnualTrendChart.tsx create mode 100644 code/ali-je-vroce-era5/charts/DistributionChart.tsx create mode 100644 code/ali-je-vroce-era5/charts/RegressionChart.tsx create mode 100644 code/ali-je-vroce-era5/charts/SeaLevelWidget.tsx create mode 100644 code/ali-je-vroce-era5/charts/SeasonHeatmap.tsx create mode 100644 code/ali-je-vroce-era5/charts/SpeiHeatmap.tsx create mode 100644 code/ali-je-vroce-era5/charts/SpeiTrendChart.tsx create mode 100644 code/ali-je-vroce-era5/charts/TodayGauge.tsx create mode 100644 code/ali-je-vroce-era5/charts/TropicalChart.tsx create mode 100644 code/ali-je-vroce-era5/charts/YearRoundChart.tsx create mode 100644 code/ali-je-vroce-era5/components/HeroCards.tsx create mode 100644 code/ali-je-vroce-era5/components/RegressionPanel.tsx create mode 100644 code/ali-je-vroce-era5/components/RegressionSection.tsx create mode 100644 code/ali-je-vroce-era5/components/StationMap.tsx create mode 100644 code/ali-je-vroce-era5/components/TodayCard.tsx create mode 100644 code/ali-je-vroce-era5/components/TodayFlag.tsx create mode 100644 code/ali-je-vroce-era5/components/TodayLast7Chart.tsx create mode 100644 code/ali-je-vroce-era5/components/TodayTrendChart.tsx create mode 100644 code/ali-je-vroce-era5/entry.tsx create mode 100644 code/ali-je-vroce-era5/types.ts create mode 100644 deployment/Dockerfile.era5-sidecar create mode 100644 deployment/Dockerfile.website.era5 create mode 100644 deployment/default.conf.era5.template create mode 100644 pages/ali-je-vroce-era5/index.md create mode 100644 scripts/era5/docker-entrypoint.sh create mode 100644 scripts/era5/locales/sl_default.json create mode 100644 scripts/era5/mk_collect.py create mode 100644 scripts/era5/mk_precompute.py create mode 100644 scripts/era5/mk_sidecar.py create mode 100644 scripts/era5/pyproject.toml create mode 100644 scripts/era5/si.yaml create mode 100644 styles/era5.css diff --git a/code/ali-je-vroce-era5/AliJeVroceERA5.tsx b/code/ali-je-vroce-era5/AliJeVroceERA5.tsx new file mode 100644 index 00000000..ddc7d9c6 --- /dev/null +++ b/code/ali-je-vroce-era5/AliJeVroceERA5.tsx @@ -0,0 +1,278 @@ +import { createSignal, createResource, createMemo, Show, Suspense, lazy } from "solid-js"; + +const EN_MONTHS: Record = { + Jan:"01", Feb:"02", Mar:"03", Apr:"04", May:"05", Jun:"06", + Jul:"07", Aug:"08", Sep:"09", Oct:"10", Nov:"11", Dec:"12", +}; +function fmtDayLabel(dl: string): string { + const [mon, day] = dl.split(" "); + return `${(day ?? "").padStart(2, "0")}.${EN_MONTHS[mon ?? ""] ?? "??"}`; +} +import { fetchMeta, fetchPageData, fetchSeasonHeatmap, fetchSpeiHeatmap, fetchSpeiStationSeasonal } from "./api.ts"; +import { TodayCard } from "./components/TodayCard.tsx"; +import { DistributionChart } from "./charts/DistributionChart.tsx"; +import { TodayTrendChart } from "./components/TodayTrendChart.tsx"; +import { RegressionPanel, RegToolbar, RegScatterCard, RegYearRoundCard, + panelHStyle, panelTitleStyle, panelSubStyle } from "./components/RegressionPanel.tsx"; +import type { SiteMeta } from "./types.ts"; + +// Only below-the-fold sections stay lazy +const SeasonHeatmapChart = lazy(() => import("./charts/SeasonHeatmap.tsx").then(m => ({ default: m.SeasonHeatmap }))); +const StationMap = lazy(() => import("./components/StationMap.tsx").then(m => ({ default: m.StationMap }))); +const SpeiHeatmapChart = lazy(() => import("./charts/SpeiHeatmap.tsx").then(m => ({ default: m.SpeiHeatmap }))); +const TropicalDaysChart = lazy(() => import("./charts/TropicalChart.tsx").then(m => ({ default: m.TropicalChart }))); +const TropicalNightsChart = lazy(() => import("./charts/TropicalChart.tsx").then(m => ({ default: m.TropicalChart }))); +const HeroCardsPanel = lazy(() => import("./components/HeroCards.tsx").then(m => ({ default: m.HeroCards }))); +const SeaLevelChart = lazy(() => import("./charts/SeaLevelWidget.tsx").then(m => ({ default: m.SeaLevelWidget }))); +const SpeiTrendChartLazy = lazy(() => import("./charts/SpeiTrendChart.tsx").then(m => ({ default: m.SpeiTrendChart }))); + +function dateToDoy(dateStr: string): number { + const d = new Date(dateStr + "T12:00:00Z"); + const start = new Date(Date.UTC(d.getUTCFullYear(), 0, 0)); + return Math.floor((d.getTime() - start.getTime()) / 86_400_000); +} + +export function AliJeVroceERA5() { + const [meta] = createResource(fetchMeta); + return ( + Nalaganje…}> + {(m) => } + + ); +} + +function Dashboard(props: { meta: SiteMeta }) { + const today = new Date().toISOString().slice(0, 10); + const [date, setDate] = createSignal(today); + const [loc, setLoc] = createSignal(null); + + const defaultDoy = createMemo(() => dateToDoy(date())); + + const [pageData] = createResource( + () => ({ date: date(), loc: loc() }), + ({ date, loc }) => fetchPageData(date, loc), + ); + const pageDataResolved = () => pageData() ?? pageData.latest; + const todayData = () => pageDataResolved()?.status; + const last7Data = () => pageDataResolved()?.last7; + + const [heatmapData] = createResource(fetchSeasonHeatmap); + const [speiData] = createResource(fetchSpeiHeatmap); + const [speiStationData] = createResource(fetchSpeiStationSeasonal); + const [mapLoc, setMapLoc] = createSignal(null); + + return ( +
+ + {/* ── Today status section ──────────────────────────────────── */} +
+
+
+ Ali je vroče v Sloveniji? + v primerjavi s tem datumom zgodovinsko +
+
+ +
+ } + > + {(r) => ( + setLoc(v || null)} + /> + )} + + + +
+
+ Dnevne najvišje temperature {todayData()?.loc ? `na postaji ${todayData()!.loc!.replace(/_/g, " ")}` : "v Sloveniji"} za dve tedni okoli {fmtDayLabel(todayData()!.day_label ?? "")} od {todayData()!.year_min} +
+ +

+ Krivulja prikazuje, kako pogosto se je pojavila vsaka vrhunska temperatura na dneve, kot je danes, v vseh letih. Barve označujejo klimatološke cone — od hladne modre prek tipičnega bežastega pasu do ekstremne rdeče — tako da na prvi pogled vidite, kje se uvršča današnja temperatura. +

+
+ Danes: {todayData()!.today_temp!.toFixed(1)} °C · {todayData()!.percentile!.toFixed(0)}. percentil · mediana {todayData()!.cutoffs!.p50.toFixed(1)} °C · {(todayData()!.n_samples ?? 0).toLocaleString()} opazovanj · {todayData()!.year_min}–{todayData()!.year_max} +
+
+
+ + + + +
+
+ + {/* ── Regression section ──────────────────────────────────────── + Layout (mirrors original): + 1. sec-hs heading + 2. toolbar — full width, margin 40px + 3. main-row — grid: min(460px,44%) | 1fr, padding 20px 40px + left: map panel + right: scatter chart panel + 4. cal-section — full width, year-round chart, margin 40px + ──────────────────────────────────────────────────────────────── */} + +
Analiza trendov
+ + + +
+ + {/* Map panel */} +
+
+
+
+ {mapLoc() ? mapLoc()!.replace(/_/g, " ") : "Slovenija — vse postaje"} +
+
+ {props.meta.stations.length} postaj +
+
+
+ }> + + + {/* Elevation legend */} +
+ {([ + ["#7bafd4", "Alpska (>1500m)"], + ["#a3c4a0", "Gorska (800–1500m)"], + ["#c8b97a", "Predgorska (400–800m)"], + ["#c25a2c", "Nižinska (<400m)"], + ] as [string, string][]).map(([color, label]) => ( + + + {label} + + ))} +
+
+ + {/* Regression scatter panel */} + + +
+ + {/* Year-round trend — full width below the grid */} +
+ +
+ +
+ + {/* ── Hero card (location details) ──────────────────────────── */} +
+
+ Podrobnosti lokacije +
+ }> + + +
+ + {/* ── Season heatmap ────────────────────────────────────────── */} +
+
+ Sezonski pregled +
+ }> + 0}> + + + +
+ + {/* ── SPEI heatmap ──────────────────────────────────────────── */} +
+
+ Sezonski sušni indeks (SPEI) +
+ }> + + + + +
+ + {/* ── Drought trend per station ─────────────────────────────── */} + +
+
+ Sušni trend po postaji — SPEI +
+
+ Sezonski (SPEI-3) in mesečni (SPEI-30) indeks vodne bilance · Theil-Sen · ERA5-Land +
+ }> + + + + +
+
+ + {/* ── Tropical days ─────────────────────────────────────────── */} + +
+
+ Tropski dnevi +
+
+ Število dni z najvišjo temperaturo nad pragom po postaji · ERA5-Land · lapsna korekcija nadmorske višine +
+ }> + + +
+
+ + {/* ── Tropical nights ───────────────────────────────────────── */} + +
+
+ Tropske noči +
+
+ Število noči z najnižjo temperaturo nad pragom po postaji · ERA5-Land · lapsna korekcija nadmorske višine +
+ }> + + +
+
+ + {/* ── Sea level rise (Koper) ─────────────────────────────────── */} + +
+
+ Dvig morske gladine — Koper +
+
+ Projekcije dviga morske gladine po scenarijih IPCC AR6 z viharnimi nalivi · severni Jadran +
+ }> + + +
+
+ +
+ ); +} diff --git a/code/ali-je-vroce-era5/api.ts b/code/ali-je-vroce-era5/api.ts new file mode 100644 index 00000000..0b24dc9c --- /dev/null +++ b/code/ali-je-vroce-era5/api.ts @@ -0,0 +1,134 @@ +import type { TodayStatus, Last7, AnnualTrendRow, AnnualTrend, SiteMeta, SeasonHeatmapRow, RegressionResult, RegressionResponse, DailyWindowRow } from "./types.ts"; + +// In dev: empty string = same-origin proxy (Vite config or dev-proxy) +// In prod: set VITE_ERA5_SIDECAR_URL at build time (e.g. https://era5.podnebnik.org) +const SIDECAR = (import.meta.env.VITE_ERA5_SIDECAR_URL as string | undefined) ?? ""; + +async function get(url: string): Promise { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`${resp.status} ${url}`); + return resp.json() as Promise; +} + +export function fetchMeta(): Promise { + return get(`${SIDECAR}/api/live/meta`); +} + +export function fetchTodayStatus(date: string, loc: string | null): Promise { + const params = new URLSearchParams({ date }); + if (loc) params.set("loc", loc); + return get(`${SIDECAR}/api/live/today_status?${params}`); +} + +export function fetchLast7(date: string, loc: string | null): Promise { + const params = new URLSearchParams({ date }); + if (loc) params.set("loc", loc); + return get(`${SIDECAR}/api/live/today_status/last7?${params}`); +} + +export function fetchDailyWindow(station: string | null, month: number, day: number): Promise { + const s = station ?? "Ljubljana"; + const params = new URLSearchParams({ station: s, month: String(month), day: String(day) }); + return get(`${SIDECAR}/api/live/daily_window?${params}`); +} + +export async function fetchPageData( + date: string, + loc: string | null, +): Promise<{ status: TodayStatus; last7: Last7 }> { + const [status, last7] = await Promise.all([ + fetchTodayStatus(date, loc), + fetchLast7(date, loc), + ]); + return { status, last7 }; +} + +export async function fetchSeasonHeatmap(): Promise { + const result = await get<{ available: boolean; data: SeasonHeatmapRow[] }>(`${SIDECAR}/api/live/season_heatmap`); + return result.available ? result.data : []; +} + +export interface RegressionParams { + locs: string[]; + var: string; + doy: number; + window: number; + corr: "raw" | "corr"; + method: "theilsen" | "ols"; +} + +export function fetchRegression(p: RegressionParams): Promise { + const params = new URLSearchParams({ var: p.var, doy: String(p.doy), window: String(p.window), corr: p.corr, method: p.method }); + p.locs.forEach(l => params.append("loc", l)); + return get(`${SIDECAR}/api/live/regression?${params}`); +} + +export function fetchSpeiHeatmap(): Promise { + return get(`${SIDECAR}/api/live/spei_heatmap`); +} + +export function fetchSpeiStationSeasonal(): Promise { + return fetch(`${SIDECAR}/api/live/spei_station_seasonal`).then(r => { + if (r.status === 204) return null; + if (!r.ok) throw new Error(`${r.status}`); + return r.json(); + }); +} + +export interface CalendarRow { + month: number; + day: number; + trend10: number; + p_val: number; +} + +export interface CalendarData { + loc: string; + var: string; + unit: string; + method_label: string; + rows: CalendarRow[]; +} + +export function fetchCalendar( + loc: string, variable: string, window_: number, + corr: "raw" | "corr", method: "theilsen" | "ols" +): Promise { + const params = new URLSearchParams({ + loc, var: variable, window: String(window_), corr, method, + }); + return get(`${SIDECAR}/api/live/calendar?${params}`); +} + +export async function fetchAnnualTrend(month: number, day: number, loc?: string | null): Promise { + const params = new URLSearchParams({ month: String(month), day: String(day) }); + if (loc) params.set("loc", loc); + const url = `${SIDECAR}/api/live/annual_trend?${params}`; + const rows = await get(url); + if (!rows.length) throw new Error("No annual trend row"); + const r = rows[0]!; + return { + dayLabel: r.day_label, + monthNum: r.month, + dayNum: r.day, + yearMin: r.year_min, + yearMax: r.year_max, + trend10: r.trend10, + pVal: r.p_val, + tau: r.tau, + nYears: r.n_years, + scatter: JSON.parse(r.scatter_json) as Array<{ x: number; y: number }>, + histLine: { + x: JSON.parse(r.hist_x_json) as number[], + y: JSON.parse(r.hist_y_json) as number[], + upper: JSON.parse(r.hist_upper_json) as number[], + lower: JSON.parse(r.hist_lower_json) as number[], + }, + projLine: { + x: JSON.parse(r.proj_x_json) as number[], + y: JSON.parse(r.proj_y_json) as number[], + upper: JSON.parse(r.proj_upper_json) as number[], + lower: JSON.parse(r.proj_lower_json) as number[], + }, + }; +} diff --git a/code/ali-je-vroce-era5/charts/AnnualTrendChart.tsx b/code/ali-je-vroce-era5/charts/AnnualTrendChart.tsx new file mode 100644 index 00000000..63ceff18 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/AnnualTrendChart.tsx @@ -0,0 +1,123 @@ +import { onMount, onCleanup } from "solid-js"; +import type { AnnualTrend } from "../types.ts"; + +interface Props { + data: AnnualTrend; + chartId: string; +} + +export function AnnualTrendChart(props: Props) { + let container!: HTMLDivElement; + let chart: Highcharts.Chart | null = null; + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + await import("highcharts/highcharts-more"); + const d = props.data; + + const sigLabel = + d.pVal < 0.001 ? "p < 0.001 ★★★" + : d.pVal < 0.01 ? "p < 0.01 ★★" + : d.pVal < 0.05 ? "p < 0.05 ★" + : "p ≥ 0.05"; + + chart = Highcharts.chart(container, { + chart: { + type: "scatter", + animation: false, + backgroundColor: "transparent", + style: { fontFamily: "Space Grotesk, system-ui, sans-serif" }, + }, + title: { text: undefined }, + credits: { enabled: false }, + legend: { enabled: false }, + xAxis: { + title: { text: undefined }, + gridLineWidth: 1, + gridLineColor: "rgba(0,0,0,0.06)", + }, + yAxis: { + title: { text: "°C", style: { fontSize: "11px" } }, + gridLineColor: "rgba(0,0,0,0.06)", + }, + tooltip: { + formatter(this: Highcharts.Point) { + if (typeof this.x === "number" && typeof this.y === "number") { + return `${Math.round(this.x)}: ${this.y.toFixed(2)} °C`; + } + return ""; + }, + }, + series: [ + // CI band + { + type: "arearange", + name: "95% CI", + data: d.histLine.x.map((x, i) => [x, d.histLine.lower[i]!, d.histLine.upper[i]!]), + color: "#c25a2c", + fillOpacity: 0.10, + lineWidth: 0, + marker: { enabled: false }, + enableMouseTracking: false, + }, + // Projection CI band + { + type: "arearange", + name: "Proj CI", + data: d.projLine.x.map((x, i) => [x, d.projLine.lower[i]!, d.projLine.upper[i]!]), + color: "#c25a2c", + fillOpacity: 0.06, + lineWidth: 0, + dashStyle: "Dash" as Highcharts.DashStyleValue, + marker: { enabled: false }, + enableMouseTracking: false, + }, + // Historical trend line + { + type: "line", + name: "Trend", + data: d.histLine.x.map((x, i) => [x, d.histLine.y[i]!]), + color: "#c25a2c", + lineWidth: 2, + marker: { enabled: false }, + enableMouseTracking: false, + }, + // Projection line + { + type: "line", + name: "Projekcija", + data: d.projLine.x.map((x, i) => [x, d.projLine.y[i]!]), + color: "#c25a2c", + lineWidth: 1.5, + dashStyle: "Dash" as Highcharts.DashStyleValue, + marker: { enabled: false }, + enableMouseTracking: false, + }, + // Scatter dots + { + type: "scatter", + name: "Letni P90", + data: d.scatter.map((p) => ({ x: p.x, y: p.y })), + color: "rgba(194,90,44,0.55)", + marker: { radius: 3, symbol: "circle" }, + }, + ], + responsive: { + rules: [{ + condition: { maxWidth: 500 }, + chartOptions: { yAxis: [{ title: { text: undefined } }] }, + }], + }, + } as Highcharts.Options); + + // Sub-title with stats + const sub = container.closest(".annual-trend-card")?.querySelector(".annual-trend-stats"); + if (sub) { + sub.textContent = `${d.trend10 > 0 ? "+" : ""}${d.trend10.toFixed(3)} °C/desetletje · ${sigLabel} · τ = ${d.tau.toFixed(3)} · ${d.nYears} let`; + } + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + return
; +} diff --git a/code/ali-je-vroce-era5/charts/DistributionChart.tsx b/code/ali-je-vroce-era5/charts/DistributionChart.tsx new file mode 100644 index 00000000..fba398b5 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/DistributionChart.tsx @@ -0,0 +1,169 @@ +import { onMount, onCleanup, createEffect } from "solid-js"; +import type { TodayStatus } from "../types.ts"; + +interface Props { + data: TodayStatus; + chartId: string; +} + +const INK = "#0E0E0C"; +const INK_SOFT = "#6B655B"; +const MONO = { fontFamily: "'JetBrains Mono', monospace", letterSpacing: "0.04em" }; + +const ZONE_COLORS = [ + "#3a5a8a", // p0–p10 Cold + "#6c8fb6", // p10–p20 Cool + "#e7d9b8", // p20–p80 Normal + "#c25a2c", // p80–p95 Hot + "#962c1a", // p95+ Extreme +]; + +const ZONE_LABELS = ["Hladno", "Sveže", "Normalno", "Vroče", "Ekstremno"]; + +function buildOptions(r: TodayStatus): Highcharts.Options { + const c = r.cutoffs!; + const todayX = r.today_temp!; + const dist = r.distribution!; + const distMin = dist[0]![0]; + const distMax = dist[dist.length - 1]![0]; + + // Ensure the x-axis always includes today's temperature even when it is an + // extreme outlier beyond the historical KDE range (e.g. Kredarica on a heat wave). + const pad = (distMax - distMin) * 0.06; + const axisMin = Math.min(distMin, todayX) - pad; + const axisMax = Math.max(distMax, todayX) + pad; + + const zoneLabelStyle = { + color: INK_SOFT, fontSize: "9px", fontWeight: "600", + ...MONO, + }; + + return { + chart: { + type: "areaspline", + height: 220, + margin: [28, 16, 32, 16], + backgroundColor: "transparent", + animation: false, + style: { fontFamily: "Space Grotesk, system-ui, sans-serif" }, + }, + title: { text: undefined }, + credits: { enabled: false }, + legend: { enabled: false }, + tooltip: { + formatter(this: any) { + const temp: number = this.x; + let zone: string; + if (temp < c.p10) zone = "Hladno"; + else if (temp < c.p20) zone = "Sveže"; + else if (temp < c.p80) zone = "Normalno"; + else if (temp < c.p95) zone = "Vroče"; + else zone = "Ekstremno"; + return `${temp.toFixed(1)} °C · ${zone}`; + }, + }, + xAxis: { + min: axisMin, + max: axisMax, + title: { text: null }, + labels: { format: "{value}°C", style: { color: INK_SOFT, fontSize: "10px", ...MONO } }, + lineColor: "rgba(14,14,12,0.1)", + tickColor: "rgba(14,14,12,0.1)", + gridLineWidth: 0, + crosshair: { color: "rgba(14,14,12,0.15)", width: 1 }, + plotLines: [{ + value: todayX, + color: INK, + width: 3, + zIndex: 5, + label: { + text: `DANES: ${todayX.toFixed(1)} °C`, + rotation: -90, + x: -4, + y: 40, + align: "right", + style: { color: INK, fontSize: "11px", fontWeight: "600", ...MONO, textOutline: "3px white" }, + }, + }], + plotBands: [ + { from: axisMin, to: c.p10, color: "transparent", + label: { text: `< ${c.p10.toFixed(1)}°C`, align: "center", verticalAlign: "top", y: 18, style: zoneLabelStyle } }, + { from: c.p10, to: c.p20, color: "transparent", + label: { text: `${c.p10.toFixed(1)}–${c.p20.toFixed(1)}°C`, align: "center", verticalAlign: "top", y: 18, style: zoneLabelStyle } }, + { from: c.p20, to: c.p80, color: "transparent", + label: { text: `${c.p20.toFixed(1)}–${c.p80.toFixed(1)}°C`, align: "center", verticalAlign: "top", y: 18, style: zoneLabelStyle } }, + { from: c.p80, to: c.p95, color: "transparent", + label: { text: `${c.p80.toFixed(1)}–${c.p95.toFixed(1)}°C`, align: "center", verticalAlign: "top", y: 18, style: zoneLabelStyle } }, + { from: c.p95, to: axisMax, color: "transparent", + label: { text: `> ${c.p95.toFixed(1)}°C`, align: "center", verticalAlign: "top", y: 18, style: zoneLabelStyle } }, + ], + }, + yAxis: { + title: { text: null }, + labels: { enabled: false }, + gridLineWidth: 0, + lineWidth: 0, + tickWidth: 0, + }, + plotOptions: { + areaspline: { + marker: { enabled: false }, + lineWidth: 0, + fillOpacity: 1, + zoneAxis: "x", + zones: [ + { value: c.p10, color: "transparent", fillColor: ZONE_COLORS[0] }, + { value: c.p20, color: "transparent", fillColor: ZONE_COLORS[1] }, + { value: c.p80, color: "transparent", fillColor: ZONE_COLORS[2] }, + { value: c.p95, color: "transparent", fillColor: ZONE_COLORS[3] }, + { color: "transparent", fillColor: ZONE_COLORS[4] }, + ], + }, + }, + series: [{ type: "areaspline", name: "Density", data: dist }], + } as Highcharts.Options; +} + +export function DistributionChart(props: Props) { + let container!: HTMLDivElement; + let chart: any = null; + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + const r = props.data; + if (!r.available || !r.distribution?.length || !r.cutoffs) return; + chart = Highcharts.chart(container, buildOptions(r)); + }); + + createEffect(() => { + const r = props.data; + if (!chart || !r.available || !r.distribution?.length || !r.cutoffs) return; + const opts = buildOptions(r); + const xOpts = opts.xAxis as Highcharts.XAxisOptions; + chart.series[0]?.setData(r.distribution, false, false, false); + chart.xAxis[0]?.update({ + min: xOpts.min, + max: xOpts.max, + plotLines: xOpts.plotLines, + plotBands: xOpts.plotBands, + }, false); + chart.update({ plotOptions: opts.plotOptions }, false); + chart.redraw(false); + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + return ( + <> +
+
+ {ZONE_COLORS.map((bg, i) => ( + + + {ZONE_LABELS[i]} + + ))} +
+ + ); +} diff --git a/code/ali-je-vroce-era5/charts/RegressionChart.tsx b/code/ali-je-vroce-era5/charts/RegressionChart.tsx new file mode 100644 index 00000000..729c4b43 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/RegressionChart.tsx @@ -0,0 +1,142 @@ +import { onMount, onCleanup, createEffect } from "solid-js"; +import type { RegressionResponse } from "../types.ts"; + +interface Props { + data: RegressionResponse; + chartId: string; +} + +export function RegressionChart(props: Props) { + let container!: HTMLDivElement; + let chart: Highcharts.Chart | null = null; + + function buildSeries(results: RegressionResponse["results"]): Highcharts.SeriesOptionsType[] { + const series: Highcharts.SeriesOptionsType[] = []; + for (const res of results) { + const color = res.color ?? "#e07b00"; + + // CI band + const bandData = res.line.x.map((x, i) => [x, res.line.lower[i], res.line.upper[i]] as [number,number,number]); + series.push({ + type: "arearange", + name: res.loc + " CI", + data: bandData, + color, + fillOpacity: 0.12, + lineWidth: 0, + marker: { enabled: false }, + enableMouseTracking: false, + showInLegend: false, + zIndex: 1, + } as Highcharts.SeriesArearangeOptions); + + // Trend line + series.push({ + type: "line", + name: res.loc.replace(/_/g, " "), + data: res.line.x.map((x, i) => [x, res.line.y[i]] as [number,number]), + color, + lineWidth: 2, + marker: { enabled: false }, + showInLegend: true, + zIndex: 2, + } as Highcharts.SeriesLineOptions); + + // Scatter dots + series.push({ + type: "scatter", + name: res.loc + " data", + data: res.scatter.map(pt => ({ + x: pt.x, y: pt.y, + color: pt.color, + marker: { fillColor: pt.color }, + })), + color, + showInLegend: false, + zIndex: 3, + marker: { radius: 4, symbol: "circle" }, + } as Highcharts.SeriesScatterOptions); + } + return series; + } + + onMount(async () => { + const HC = (await import("highcharts")).default; + await import("highcharts/highcharts-more"); + + const d = props.data; + chart = HC.chart(container, { + chart: { + backgroundColor: "transparent", + margin: [10, 20, 40, 60], + animation: false, + }, + title: null, + credits: { enabled: false }, + exporting: { enabled: false }, + accessibility: { enabled: false }, + legend: { + enabled: true, + itemStyle: { fontFamily: "'Space Grotesk', system-ui, sans-serif", fontWeight: "600", fontSize: "11px", color: "#1a1a18" }, + }, + tooltip: { + shared: false, + formatter(this: Highcharts.TooltipFormatterContextObject) { + const pt = this.point as any; + const loc = (this.series.name ?? "").replace(/_/g, " "); + return `${loc}
${this.x}: ${(this.y as number).toFixed(2)} ${d.unit}${pt.anomaly != null ? `
anomaly: ${pt.anomaly > 0 ? "+" : ""}${pt.anomaly.toFixed(2)}` : ""}`; + }, + }, + xAxis: { type: "linear", title: { text: null }, gridLineWidth: 0, tickInterval: 10 }, + yAxis: { + title: { text: d.ylabel, style: { fontFamily: "'JetBrains Mono', monospace", fontSize: "10px" } }, + gridLineColor: "rgba(0,0,0,0.06)", + }, + series: buildSeries(d.results), + } as Highcharts.Options); + + // Baseline plotlines + if (chart && d.results.length) { + for (const res of d.results) { + (chart.yAxis[0] as any).addPlotLine({ + id: `baseline-${res.loc}`, + value: res.baseline, + color: res.color ?? "#999", + width: 1, + dashStyle: "Dash", + zIndex: 2, + label: { + text: `${res.stats.n_years}-YR MEAN ${res.baseline.toFixed(1)}`, + align: "right", + x: -4, + style: { color: res.color ?? "#999", fontSize: "9px", fontFamily: "'JetBrains Mono', monospace" }, + }, + }); + } + } + }); + + createEffect(() => { + const d = props.data; + if (!chart) return; + while (chart.series.length) chart.series[0].remove(false); + for (const s of buildSeries(d.results)) chart.addSeries(s, false); + chart.yAxis[0].setTitle({ text: d.ylabel }); + (chart.yAxis[0].plotLinesAndBands ?? []) + .filter((pl: any) => pl.id?.startsWith("baseline-")) + .forEach((pl: any) => chart!.yAxis[0].removePlotLine(pl.id)); + for (const res of d.results) { + (chart.yAxis[0] as any).addPlotLine({ + id: `baseline-${res.loc}`, value: res.baseline, color: res.color ?? "#999", + width: 1, dashStyle: "Dash", zIndex: 2, + label: { text: `${res.stats.n_years}-YR MEAN ${res.baseline.toFixed(1)}`, + align: "right", x: -4, style: { color: res.color ?? "#999", fontSize: "9px", fontFamily: "'JetBrains Mono', monospace" } }, + }); + } + chart.redraw(false); + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + return
; +} diff --git a/code/ali-je-vroce-era5/charts/SeaLevelWidget.tsx b/code/ali-je-vroce-era5/charts/SeaLevelWidget.tsx new file mode 100644 index 00000000..d48a3784 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/SeaLevelWidget.tsx @@ -0,0 +1,459 @@ +import { createSignal, createMemo, createEffect, onMount, onCleanup } from "solid-js"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; + +// ── Projection data (IPCC AR6, localised for northern Adriatic / DRSV 2023) ── + +type ProjTable = Record; +interface Scenario { median: ProjTable; low: ProjTable; high: ProjTable; } + +const DATA = { + projections: { + ssp245: { + median: { 2030:9, 2040:15, 2050:23, 2060:31, 2070:39, 2080:47, 2090:54, 2100:60 }, + low: { 2030:6, 2040:10, 2050:16, 2060:22, 2070:28, 2080:34, 2090:39, 2100:45 }, + high: { 2030:12, 2040:20, 2050:30, 2060:40, 2070:50, 2080:60, 2090:68, 2100:75 }, + }, + ssp585: { + median: { 2030:11, 2040:19, 2050:28, 2060:40, 2070:53, 2080:66, 2090:75, 2100:84 }, + low: { 2030:8, 2040:14, 2050:21, 2060:30, 2070:40, 2080:50, 2090:58, 2100:66 }, + high: { 2030:14, 2040:24, 2050:36, 2060:52, 2070:70, 2080:88, 2090:100,2100:108 }, + }, + } as Record, + surcharge: { p70: 58, p20: 76, p01: 98 } as Record, +}; + +const FLOOD_RISK_ZONES = [ + L.latLngBounds(L.latLng(45.462, 13.582), L.latLng(45.491, 13.636)), + L.latLngBounds(L.latLng(45.538, 13.716), L.latLng(45.552, 13.752)), +]; + +const SCHEMATIC_POLYS: L.LatLngTuple[][] = [ + [[45.462, 13.582],[45.491, 13.582],[45.491, 13.636],[45.462, 13.636]], + [[45.538, 13.716],[45.552, 13.716],[45.552, 13.752],[45.538, 13.752]], +]; + +const GAUGE_MAX = 200; + +// ── Math helpers ────────────────────────────────────────────────────────────── + +function interp(obj: ProjTable, year: number): number { + const keys = Object.keys(obj).map(Number).sort((a, b) => a - b); + if (year <= keys[0]) return obj[keys[0]]; + if (year >= keys[keys.length - 1]) return obj[keys[keys.length - 1]]; + for (let i = 0; i < keys.length - 1; i++) { + if (year >= keys[i] && year <= keys[i + 1]) + return obj[keys[i]] + ((year - keys[i]) / (keys[i + 1] - keys[i])) * (obj[keys[i + 1]] - obj[keys[i]]); + } + return obj[keys[0]]; +} + +const getMeanRise = (scn: string, year: number) => interp(DATA.projections[scn].median, year); +const getRange = (scn: string, year: number) => ({ + lo: interp(DATA.projections[scn].low, year), + hi: interp(DATA.projections[scn].high, year), +}); +const gaugeY = (cm: number) => Math.max(10, 175 - Math.min(cm, 206) * 0.8); +const snapLevel = (cm: number) => Math.max(10, Math.min(250, Math.round(cm / 10) * 10)); +const floodUrl = (lvl: number) => `/data/flood/flood-${String(lvl).padStart(3, '0')}cm.png`; +const fmt = (n: number) => n.toLocaleString('sl-SI'); + +interface FloodStats { levels: Record; } + +// ── Component ───────────────────────────────────────────────────────────────── + +export function SeaLevelWidget() { + const [scn, setScn] = createSignal("ssp245"); + const [prob, setProb] = createSignal("p70"); + const [year, setYear] = createSignal(2050); + const [playing, setPlaying] = createSignal(false); + const [divPct, setDivPct] = createSignal(50); + const [viewFrac, setViewFrac] = createSignal(1); + + let floodStats: FloodStats | null = null; + const imgCache: Record = {}; + let leafletMap: L.Map | null = null; + let schematicLayers: L.Polygon[] = []; + let rafId: number | null = null; + let renderVer = 0; + + let mapContainerEl!: HTMLDivElement; + let mapWrapEl!: HTMLDivElement; + let canvasEl!: HTMLCanvasElement; + let divLineEl!: HTMLDivElement; + let divHandleEl!: HTMLDivElement; + let futureLblEl!: HTMLDivElement; + + // ── Derived values ──────────────────────────────────────────────────────── + + const meanRise = createMemo(() => getMeanRise(scn(), year())); + const rangeVals = createMemo(() => getRange(scn(), year())); + const surcharge = createMemo(() => DATA.surcharge[prob()]); + const total = createMemo(() => meanRise() + surcharge()); + + const gFillY = createMemo(() => gaugeY(total())); + const gFillH = createMemo(() => Math.max(0, 175 - gFillY())); + const gBandY = createMemo(() => gaugeY(rangeVals().hi + surcharge())); + const gBandH = createMemo(() => Math.max(0, gaugeY(rangeVals().lo + surcharge()) - gBandY())); + + const gHPct = createMemo(() => Math.min(100, total() / GAUGE_MAX * 100)); + const gHBandL = createMemo(() => Math.min(100, (rangeVals().lo + surcharge()) / GAUGE_MAX * 100)); + const gHBandW = createMemo(() => Math.min(100 - gHBandL(), (rangeVals().hi - rangeVals().lo) / GAUGE_MAX * 100)); + + function getImpacts(totalCm: number) { + if (floodStats) { + const row = floodStats.levels[String(snapLevel(totalCm))]; + if (row) return { ha: row.ha, buildings: row.buildings }; + } + const over = Math.max(0, totalCm - 40); + return { ha: Math.round(over * 20.77), buildings: Math.round(over * 14.13) }; + } + + const impactStats = createMemo(() => getImpacts(total())); + const visHa = createMemo(() => viewFrac() === 0 ? '—' : fmt(Math.round(impactStats().ha * viewFrac()))); + const visBuild = createMemo(() => viewFrac() === 0 ? '—' : fmt(Math.round(impactStats().buildings * viewFrac()))); + + function calcViewFrac(): number { + if (!leafletMap) return 1; + const vb = leafletMap.getBounds(); + let vis = 0, tot = 0; + for (const fz of FLOOD_RISK_ZONES) { + tot += (fz.getNorth() - fz.getSouth()) * (fz.getEast() - fz.getWest()); + const latLo = Math.max(vb.getSouth(), fz.getSouth()), latHi = Math.min(vb.getNorth(), fz.getNorth()); + const lngLo = Math.max(vb.getWest(), fz.getWest()), lngHi = Math.min(vb.getEast(), fz.getEast()); + if (latHi > latLo && lngHi > lngLo) vis += (latHi - latLo) * (lngHi - lngLo); + } + return tot > 0 ? Math.min(1, vis / tot) : 0; + } + + // ── PNG flood rendering ─────────────────────────────────────────────────── + + function loadImg(lvl: number): Promise { + if (imgCache[lvl]) return Promise.resolve(imgCache[lvl]); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { imgCache[lvl] = img; resolve(img); }; + img.onerror = reject; + img.src = floodUrl(lvl); + }); + } + + function tintImg(src: HTMLImageElement, colour: string): HTMLCanvasElement { + const tmp = document.createElement('canvas'); + tmp.width = src.naturalWidth || src.width; + tmp.height = src.naturalHeight || src.height; + const c = tmp.getContext('2d')!; + c.drawImage(src, 0, 0); + c.globalCompositeOperation = 'source-in'; + c.fillStyle = colour; + c.fillRect(0, 0, tmp.width, tmp.height); + return tmp; + } + + function clearSchematic() { + schematicLayers.forEach(l => leafletMap?.removeLayer(l)); + schematicLayers = []; + } + + async function renderFloodCanvas() { + if (!leafletMap || !canvasEl || !mapWrapEl) return; + const ver = ++renderVer; + + canvasEl.width = mapWrapEl.offsetWidth; + canvasEl.height = mapWrapEl.offsetHeight; + const ctx = canvasEl.getContext('2d')!; + ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); + + const curScn = scn(), curYear = year(), curProb = prob(), curDiv = divPct(); + const divX = canvasEl.width * curDiv / 100; + const todayCm = DATA.surcharge[curProb]; + const futureCm = getMeanRise(curScn, curYear) + DATA.surcharge[curProb]; + const tL = snapLevel(todayCm), fL = snapLevel(futureCm); + + let todayImg: HTMLImageElement | null = null; + let futureImg: HTMLImageElement | null = null; + try { [todayImg, futureImg] = await Promise.all([loadImg(tL), loadImg(fL)]); } catch (_) {} + + if (ver !== renderVer) return; // superseded + + const nw = leafletMap.latLngToContainerPoint(L.latLng(45.605, 13.535)); + const se = leafletMap.latLngToContainerPoint(L.latLng(45.425, 13.795)); + const rx = nw.x, ry = nw.y, rw = se.x - nw.x, rh = se.y - nw.y; + + if (todayImg && futureImg && rw > 0 && rh > 0) { + clearSchematic(); + const todayCyan = tintImg(todayImg, 'rgba(60,30,200,0.82)'); + const futureCoral = tintImg(futureImg, 'rgba(210,30,45,0.83)'); + + ctx.save(); + ctx.beginPath(); ctx.rect(0, 0, divX, canvasEl.height); ctx.clip(); + ctx.drawImage(todayCyan, rx, ry, rw, rh); + ctx.restore(); + + ctx.save(); + ctx.beginPath(); ctx.rect(divX, 0, canvasEl.width - divX, canvasEl.height); ctx.clip(); + ctx.drawImage(futureCoral, rx, ry, rw, rh); + ctx.globalCompositeOperation = 'destination-out'; + ctx.drawImage(todayImg, rx, ry, rw, rh); + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(todayCyan, rx, ry, rw, rh); + ctx.restore(); + ctx.globalCompositeOperation = 'source-over'; + } else { + clearSchematic(); + const divLng = leafletMap.containerPointToLatLng(L.point(divX, canvasEl.height / 2)).lng; + for (const coords of SCHEMATIC_POLYS) { + const lngs = coords.map(c => c[1]); + const center = (Math.max(...lngs) + Math.min(...lngs)) / 2; + const isToday = center < divLng; + const fill = isToday ? '#3c1ec8' : (futureCm > todayCm ? '#d21e2d' : '#3c1ec8'); + const border = isToday ? '#6655ff' : (futureCm > todayCm ? '#ff4455' : '#6655ff'); + schematicLayers.push( + L.polygon(coords, { color: border, weight: 1.5, fillColor: fill, fillOpacity: 0.65, opacity: 0.9, interactive: false }) + .addTo(leafletMap!) + ); + } + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(divX, 0); ctx.lineTo(divX, canvasEl.height); ctx.stroke(); + ctx.restore(); + } + } + + // ── Divider drag ────────────────────────────────────────────────────────── + + function updateDividerPos() { + const pct = divPct(); + if (!divLineEl || !divHandleEl || !futureLblEl) return; + divLineEl.style.left = pct + '%'; + divHandleEl.style.left = pct + '%'; + const rem = 100 - pct; + futureLblEl.style.right = rem < 12 ? 'auto' : (100 - Math.min(pct + 1, 98)) + '%'; + futureLblEl.style.left = rem < 12 ? (pct + 1) + '%' : 'auto'; + } + + function wireDivider() { + divHandleEl.addEventListener('pointerdown', e => { + e.preventDefault(); + (e as PointerEvent).target && divHandleEl.setPointerCapture((e as PointerEvent).pointerId); + const onMove = (me: Event) => { + const pe = me as PointerEvent; + const rect = mapWrapEl.getBoundingClientRect(); + setDivPct(Math.max(5, Math.min(95, (pe.clientX - rect.left) / rect.width * 100))); + }; + divHandleEl.addEventListener('pointermove', onMove); + divHandleEl.addEventListener('pointerup', () => + divHandleEl.removeEventListener('pointermove', onMove), { once: true }); + }); + } + + // ── Play animation ──────────────────────────────────────────────────────── + + function startPlay() { + if (year() >= 2100) setYear(2024); + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setYear(2100); setPlaying(false); return; + } + const SPEED = 13; + let last = performance.now(); + const tick = (now: number) => { + const next = Math.min(year() + (now - last) / 1000 * SPEED, 2100); + setYear(next); last = now; + if (next >= 2100) { stopPlay(); return; } + rafId = requestAnimationFrame(tick); + }; + setPlaying(true); + rafId = requestAnimationFrame(tick); + } + + function stopPlay() { + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + setPlaying(false); + } + + onCleanup(() => { + if (rafId) cancelAnimationFrame(rafId); + clearSchematic(); + leafletMap?.remove(); + }); + + // ── Effects ─────────────────────────────────────────────────────────────── + + createEffect(() => { + const _ = [scn(), year(), prob(), divPct()]; // track all state + if (leafletMap) { updateDividerPos(); renderFloodCanvas(); } + }); + + // ── Mount ───────────────────────────────────────────────────────────────── + + onMount(() => { + leafletMap = L.map(mapContainerEl, { + center: [45.51, 13.645], zoom: 11, minZoom: 9, maxZoom: 16, + zoomControl: true, scrollWheelZoom: true, + }); + + L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + attribution: '© OpenTopoMap (CC-BY-SA) | © OSM', + maxZoom: 17, + }).addTo(leafletMap); + + leafletMap.getPane('tilePane')!.style.filter = 'grayscale(1) contrast(0.88) brightness(0.82)'; + + const onUpdate = () => { renderFloodCanvas(); setViewFrac(calcViewFrac()); }; + leafletMap.on('move zoom moveend zoomend viewreset resize', onUpdate); + new ResizeObserver(onUpdate).observe(mapWrapEl); + + wireDivider(); + updateDividerPos(); + + fetch('/data/flood-stats.json').then(r => r.json()).then((d: FloodStats) => { + floodStats = d; + setViewFrac(calcViewFrac()); // trigger recalc with loaded stats + }).catch(() => {}); + + renderFloodCanvas(); + [58,60,70,76,80,90,98,100,110,120,130,140,150,160].forEach(cm => + loadImg(snapLevel(cm)).catch(() => {}) + ); + }); + + // ── Template ────────────────────────────────────────────────────────────── + + return ( +
+ + {/* Controls bar */} +
+
+ Scenarij +
+ {([ + { key: "ssp245", label: "SSP2-4.5", sub: "Srednja pot" }, + { key: "ssp585", label: "SSP5-8.5", sub: "Vožnja po avtocesti" }, + ] as const).map(s => ( + + ))} +
+
+ +
+ Verjetnost ekstremov +
+ {([ + { key: "p70", label: "pogost (70 %)" }, + { key: "p20", label: "redek (20 %)" }, + { key: "p01", label: "ekstrem (1 %)" }, + ] as const).map(p => ( + + ))} +
+
+ +
+
+ Leto: {Math.round(year())} + + {visHa()} ha·{visBuild()} stavb + +
+
+ { stopPlay(); setYear(+e.currentTarget.value); }} /> + +
+
+
+ + {/* Map + stats stage */} +
+ + {/* Map */} +
+
+ +
+
+
DANES
+
{Math.round(year())}
+
+ + {/* Stats card */} +
+
Skupni dvig gladine
+
+ +{Math.round(meanRise())} + cm +
+
srednja vrednost · {scn() === 'ssp245' ? 'SSP2-4.5' : 'SSP5-8.5'}
+ + {/* Vertical gauge (desktop) */} +
+ +
+ + {/* Horizontal gauge (mobile only) */} +
+
+
+
+
+ 0 cm + +{Math.round(total())} cm + 200 cm +
+ + {/* Impacts */} +
+
+ {visHa()} + ha poplavljenih +
+
+ {visBuild()} + ogroženih stavb +
+
+ + {/* Legend */} +
+ + Danes in prihodnost +
+
+ + Novo v prihodnosti +
+ + {/* Sources */} +
+ Viri podatkov +

Projekcije: IPCC AR6 (Fox-Kemper et al. 2021), lokalizirano za severni Jadran — DRSV 2023.

+

Posledice: Kovačič et al. 2016/2019.

+

Karta: © OpenTopoMap (CC-BY-SA), © OpenStreetMap.

+

⚠ Poplavne cone so shematske — zamenjava z LIDAR DEM poligoni sledi.

+
+
+ +
+
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/SeasonHeatmap.tsx b/code/ali-je-vroce-era5/charts/SeasonHeatmap.tsx new file mode 100644 index 00000000..fe5655ce --- /dev/null +++ b/code/ali-je-vroce-era5/charts/SeasonHeatmap.tsx @@ -0,0 +1,237 @@ +import { createSignal, createMemo, For, Show, onCleanup } from "solid-js"; +import type { SeasonHeatmapRow } from "../types.ts"; + +const SEASON_ORDER = ["Autumn", "Summer", "Spring", "Winter"] as const; +type Season = typeof SEASON_ORDER[number]; + +const SEASON_LABEL: Record = { + Autumn: "Jesen", Summer: "Poletje", Spring: "Pomlad", Winter: "Zima", +}; + +const CAT_LABELS: Record = { + cold: "Hladno (<10. pct)", + cool: "Sveže (10–20. pct)", + normal: "Normalno (20–80. pct)", + hot: "Vroče (80–95. pct)", + extreme: "Ekstremno (>95. pct)", +}; + +const CAT_COLORS: Record = { + cold: "#3a5a8a", cool: "#6c8fb6", normal: "#e7d9b8", hot: "#c25a2c", extreme: "#962c1a", +}; + +const MODES = [ + { key: "all", label: "Vse letne čase" }, + { key: "extremes", label: "Samo ekstremi" }, + { key: "Autumn", label: "Jesen" }, + { key: "Summer", label: "Poletje" }, + { key: "Spring", label: "Pomlad" }, + { key: "Winter", label: "Zima" }, +]; + +interface Props { data: SeasonHeatmapRow[]; } +interface TipData { row: SeasonHeatmapRow; px: number; py: number; } + +export function SeasonHeatmap(props: Props) { + const [mode, setMode] = createSignal("all"); + const [animating, setAnimating] = createSignal(false); + const [revealedYears, setRevealedYears] = createSignal>( + new Set(props.data.map(r => r.y)) + ); + const [tipData, setTipData] = createSignal(null); + + let animRef = false; + let animYear = 0; + let animTimer: ReturnType | null = null; + onCleanup(() => { animRef = false; if (animTimer) clearTimeout(animTimer); }); + + const allYears = createMemo((): number[] => { + const ys = props.data.map(r => r.y); + const min = Math.min(...ys), max = Math.max(...ys); + const out: number[] = []; + for (let y = min; y <= max; y++) out.push(y); + return out; + }); + + const yearMax = createMemo(() => Math.max(...props.data.map(r => r.y))); + + const lookup = createMemo((): Record> => { + const m: Record> = {}; + for (const row of props.data) { + // support both season-string and x-indexed layouts + const key = row.season ?? (["Winter","Spring","Summer","Autumn"][row.x] as string); + if (!m[key]) m[key] = {}; + m[key][row.y] = row; + } + return m; + }); + + const decadeTicks = createMemo(() => allYears().filter(y => y % 10 === 0)); + + function cellClass(season: string, cat: string, year: number): string { + if (!revealedYears().has(year)) return "shm-cell shm-cell--dim"; + const m = mode(); + if (m === "all") return "shm-cell"; + if (m === "extremes") return "shm-cell " + (cat === "extreme" ? "shm-cell--pulse" : "shm-cell--dim"); + return "shm-cell " + (season === m ? "shm-cell--hl" : "shm-cell--dim"); + } + + const stats = createMemo(() => { + let ext = 0, cold = 0, extSince2010 = 0, hotRecent = 0; + const ym = yearMax(); + const recentFrom = ym - 9; + const lup = lookup(); + for (const y of revealedYears()) { + for (const s of SEASON_ORDER) { + const p = lup[s]?.[y]; + if (!p) continue; + if (p.cat === "extreme") ext++; + if (p.cat === "cold") cold++; + if (p.cat === "extreme" && y >= 2010) extSince2010++; + if ((p.cat === "extreme" || p.cat === "hot") && y >= recentFrom) hotRecent++; + } + } + return [ + { n: ext, lbl: "Ekstremne sezone" }, + { n: cold, lbl: "Hladne sezone" }, + { n: extSince2010, lbl: "Ekstremne od 2010" }, + { n: hotRecent, lbl: `Vroče ali ekstremne (${recentFrom}–${ym})` }, + ]; + }); + + function startAnimate() { + animRef = true; + animYear = allYears()[0] ?? 1950; + setAnimating(true); + setRevealedYears(new Set()); + step(); + } + function step() { + if (!animRef) return; + setRevealedYears(prev => { const s = new Set(prev); s.add(animYear); return s; }); + if (animYear >= yearMax()) { stopAnimate(); return; } + animYear++; + const delay = animYear > 2005 ? 55 : animYear > 1985 ? 80 : 110; + animTimer = setTimeout(step, delay); + } + function stopAnimate() { + animRef = false; + if (animTimer) clearTimeout(animTimer); + setAnimating(false); + setRevealedYears(new Set(allYears())); + } + + return ( +
+ {/* Controls */} +
+ + {(m) => ( + + )} + + +
+ + {/* Grid */} +
+ + {(season) => ( + <> +
{SEASON_LABEL[season]}
+
+ + {(year) => { + const row = () => lookup()[season]?.[year]; + return ( + }> + {(r) => ( +
setTipData({ row: r(), px: e.clientX, py: e.clientY })} + onMouseMove={(e) => setTipData(prev => prev ? { ...prev, px: e.clientX, py: e.clientY } : null)} + onMouseLeave={() => setTipData(null)} + /> + )} + + ); + }} + +
+ + )} +
+
+ + {/* Year axis */} +
+
+
+ + {(yr) => { + const ys = allYears(); + const pct = (ys.indexOf(yr) / ys.length) * 100; + return {yr}; + }} + +
+
+ + {/* Legend */} +
+ + {([cat, color]) => ( + + + {CAT_LABELS[cat]} + + )} + +
+ + {/* Stats */} +
+ + {(s) => ( +
+
{s.n}
+
{s.lbl}
+
+ )} +
+
+ + {/* Floating tooltip */} + + {(td) => ( +
+ {td().row.season} {td().row.y} +
+ + {CAT_LABELS[td().row.cat]} +
+ Povprečni maks: {td().row.avg.toFixed(1)} °C
+ {td().row.rank}. najtoplejša {SEASON_LABEL[td().row.season as Season] ?? td().row.season} od {td().row.total} let +
+ )} +
+
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/SpeiHeatmap.tsx b/code/ali-je-vroce-era5/charts/SpeiHeatmap.tsx new file mode 100644 index 00000000..6c6fcf64 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/SpeiHeatmap.tsx @@ -0,0 +1,261 @@ +import { createSignal, createMemo, For, Show, onCleanup } from "solid-js"; + +const SEASON_ORDER = ["Autumn", "Summer", "Spring", "Winter"] as const; +type Season = typeof SEASON_ORDER[number]; + +const SEASON_LABEL: Record = { + Autumn: "Jesen", Summer: "Poletje", Spring: "Pomlad", Winter: "Zima", +}; + +const CAT_LABELS: Record = { + extreme_dry: "Huda suša (SPEI < −1,5)", + dry: "Suho (SPEI −1,5 do −1,0)", + normal: "Normalno (SPEI −1,0 do 1,0)", + wet: "Mokro (SPEI 1,0 do 1,5)", + extreme_wet: "Zelo mokro (SPEI > 1,5)", +}; + +const CAT_COLORS: Record = { + extreme_dry: "#8b3a0f", + dry: "#c2713a", + normal: "#e7e0d0", + wet: "#4a80b0", + extreme_wet: "#1e4d78", +}; + +const MODES = [ + { key: "all", label: "Vse letne čase" }, + { key: "extremes", label: "Samo ekstremi" }, + { key: "Autumn", label: "Jesen" }, + { key: "Summer", label: "Poletje" }, + { key: "Spring", label: "Pomlad" }, + { key: "Winter", label: "Zima" }, +]; + +interface SpeiRow { + season: string; + y: number; + spei: number; + cat: string; + color: string; + balance: number; + n_days: number; + rank: number; + total: number; +} + +export interface SpeiData { + available: boolean; + data: SpeiRow[]; + year_min: number; + year_max: number; + baseline: string | null; + era5_last: string; +} + +export interface SpeiHeatmapProps { data: SpeiData; } +interface TipData { row: SpeiRow; px: number; py: number; } + +export function SpeiHeatmap(props: SpeiHeatmapProps) { + const [mode, setMode] = createSignal("all"); + const [animating, setAnimating] = createSignal(false); + const [revealedYears, setRevealedYears] = createSignal>( + new Set(props.data.data.map(r => r.y)) + ); + const [tipData, setTipData] = createSignal(null); + + let animRef = false; + let animYear = 0; + let animTimer: ReturnType | null = null; + onCleanup(() => { animRef = false; if (animTimer) clearTimeout(animTimer); }); + + const allYears = createMemo((): number[] => { + const out: number[] = []; + for (let y = props.data.year_min; y <= props.data.year_max; y++) out.push(y); + return out; + }); + + const lookup = createMemo((): Record> => { + const m: Record> = {}; + for (const row of props.data.data) { + if (!m[row.season]) m[row.season] = {}; + m[row.season][row.y] = row; + } + return m; + }); + + const decadeTicks = createMemo(() => allYears().filter(y => y % 10 === 0)); + + function isExtreme(cat: string) { return cat === "extreme_dry" || cat === "extreme_wet"; } + + function cellClass(season: string, cat: string, year: number): string { + if (!revealedYears().has(year)) return "shm-cell shm-cell--dim"; + const m = mode(); + if (m === "all") return "shm-cell"; + if (m === "extremes") return "shm-cell " + (isExtreme(cat) ? "shm-cell--pulse" : "shm-cell--dim"); + return "shm-cell " + (season === m ? "shm-cell--hl" : "shm-cell--dim"); + } + + const stats = createMemo(() => { + let extDry = 0, extWet = 0, extDrySince2000 = 0, dryRecent = 0; + const ym = props.data.year_max; + const recentFrom = ym - 9; + const lup = lookup(); + for (const y of revealedYears()) { + for (const s of SEASON_ORDER) { + const p = lup[s]?.[y]; + if (!p) continue; + if (p.cat === "extreme_dry") extDry++; + if (p.cat === "extreme_wet") extWet++; + if (p.cat === "extreme_dry" && y >= 2000) extDrySince2000++; + if ((p.cat === "extreme_dry" || p.cat === "dry") && y >= recentFrom) dryRecent++; + } + } + return [ + { n: extDry, lbl: "Sezone hude suše (SPEI < −1,5)" }, + { n: extWet, lbl: "Izjemno mokre sezone (SPEI > 1,5)" }, + { n: extDrySince2000, lbl: "Sezone hude suše od 2000" }, + { n: dryRecent, lbl: `Suho ali suša (${recentFrom}–${ym})` }, + ]; + }); + + function startAnimate() { + animRef = true; + animYear = props.data.year_min; + setAnimating(true); + setRevealedYears(new Set()); + step(); + } + function step() { + if (!animRef) return; + setRevealedYears(prev => { const s = new Set(prev); s.add(animYear); return s; }); + if (animYear >= props.data.year_max) { stopAnimate(); return; } + animYear++; + const delay = animYear > 2005 ? 55 : animYear > 1985 ? 80 : 110; + animTimer = setTimeout(step, delay); + } + function stopAnimate() { + animRef = false; + if (animTimer) clearTimeout(animTimer); + setAnimating(false); + setRevealedYears(new Set(allYears())); + } + + return ( +
+ {/* Controls */} +
+ + {(m) => ( + + )} + + +
+ + {/* Grid */} +
+ + {(season) => ( + <> +
{SEASON_LABEL[season]}
+
+ + {(year) => { + const row = () => lookup()[season]?.[year]; + return ( + }> + {(r) => ( +
setTipData({ row: r(), px: e.clientX, py: e.clientY })} + onMouseMove={(e) => setTipData(prev => prev ? { ...prev, px: e.clientX, py: e.clientY } : null)} + onMouseLeave={() => setTipData(null)} + /> + )} + + ); + }} + +
+ + )} +
+
+ + {/* Year axis */} +
+
+
+ + {(yr) => { + const ys = allYears(); + const pct = (ys.indexOf(yr) / ys.length) * 100; + return {yr}; + }} + +
+
+ + {/* Legend */} +
+ + {([cat, color]) => ( + + + {CAT_LABELS[cat]} + + )} + +
+ + {/* Stats */} +
+ + {(s) => ( +
+
{s.n}
+
{s.lbl}
+
+ )} +
+
+ + {/* Floating tooltip */} + + {(td) => { + const speiSign = td().row.spei >= 0 ? "+" : ""; + return ( +
+ {SEASON_LABEL[td().row.season as Season] ?? td().row.season} {td().row.y} +
+ + {CAT_LABELS[td().row.cat] ?? td().row.cat} +
+ SPEI: {speiSign}{td().row.spei.toFixed(2)}
+ Vodni bilans: {td().row.balance.toFixed(0)} mm P−ET₀
+ {td().row.rank}. najsušnejša {SEASON_LABEL[td().row.season as Season] ?? td().row.season} od {td().row.total} let +
+ ); + }} +
+
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/SpeiTrendChart.tsx b/code/ali-je-vroce-era5/charts/SpeiTrendChart.tsx new file mode 100644 index 00000000..e0c1e5a8 --- /dev/null +++ b/code/ali-je-vroce-era5/charts/SpeiTrendChart.tsx @@ -0,0 +1,303 @@ +import { createSignal, createMemo, For, Show, onMount, onCleanup } from "solid-js"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SpeiTrend { + slope_per_decade: number; + p_value: number; + mk_trend: string; + intercept: number; +} + +interface SpeiSeries { + years: number[]; + spei: number[]; + trend: SpeiTrend | Record; +} + +export interface SpeiStationData { + available: boolean; + stations: Record>; + era5_last: string; + baseline: string; + year_min: number; + year_max: number; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const SEASONS = ["Annual", "Winter", "Spring", "Summer", "Autumn"] as const; +const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const; +type Period = typeof SEASONS[number] | typeof MONTHS[number]; + +const INK = "#0E0E0C"; +const INK_SOFT = "#6B655B"; +const MONO = { fontFamily: "'JetBrains Mono', monospace" }; + +function speiColor(v: number): string { + if (v < -1.5) return "#8b3a0f"; + if (v < -1.0) return "#c2713a"; + if (v < 1.0) return "#aaa49a"; + if (v < 1.5) return "#4a80b0"; + return "#1e4d78"; +} + +// ── Inner Highcharts scatter component ─────────────────────────────────────── + +interface ChartProps { + series: SpeiSeries; + season: string; + baseline: string; +} + +function SpeiScatterChart(props: ChartProps) { + let container!: HTMLDivElement; + let chart: any = null; + + const buildOpts = () => { + const { years, spei, trend } = props.series; + const n = years.length; + const isMonth = (MONTHS as readonly string[]).includes(props.season); + const scaleLabel = isMonth ? "SPEI-30" : "SPEI-3"; + + const scatter = years.map((y, i) => ({ + x: y, y: spei[i], + color: speiColor(spei[i]!), + marker: { radius: 4 }, + })); + + const tr = trend as SpeiTrend | undefined; + const trendLine = tr?.slope_per_decade != null ? (() => { + const sl = tr.slope_per_decade / 10; + const ic = tr.intercept; + return [ + [years[0], +(sl * years[0]! + ic).toFixed(3)], + [years[n - 1], +(sl * years[n - 1]! + ic).toFixed(3)], + ]; + })() : []; + + return { + chart: { type: "scatter", height: 280, backgroundColor: "transparent", animation: false, style: { fontFamily: "'Space Grotesk', sans-serif" } }, + title: { text: "" }, + credits: { enabled: false }, + legend: { enabled: false }, + tooltip: { + formatter(this: any) { + const v = this.y as number; + const cat = v < -1.5 ? "Huda suša" : v < -1.0 ? "Suho" : v < 1.0 ? "Normalno" : v < 1.5 ? "Mokro" : "Zelo mokro"; + const sign = v >= 0 ? "+" : ""; + return `${props.season} ${this.x}
${scaleLabel}: ${sign}${v.toFixed(2)}
${cat}`; + }, + }, + xAxis: { + title: { text: "" }, + labels: { style: { fontSize: "10px", color: INK_SOFT, ...MONO } }, + gridLineWidth: 0, + tickColor: "rgba(14,14,12,0.1)", + }, + yAxis: { + title: { text: scaleLabel, style: { fontSize: "10px", color: INK_SOFT } }, + min: -3, max: 3, + gridLineColor: "rgba(14,14,12,0.06)", + labels: { style: { fontSize: "10px", color: INK_SOFT, ...MONO } }, + plotLines: [ + { value: 0, color: INK, width: 1, dashStyle: "Solid", zIndex: 3 }, + { value: -1.5, color: "#8b3a0f", width: 1, dashStyle: "Dash", zIndex: 3, + label: { text: "huda suša", style: { fontSize: "9px", color: "#8b3a0f", ...MONO } } }, + { value: 1.5, color: "#1e4d78", width: 1, dashStyle: "Dash", zIndex: 3, + label: { text: "zelo mokro", style: { fontSize: "9px", color: "#1e4d78", ...MONO }, align: "right" as const } }, + ], + }, + series: [ + { type: "scatter", data: scatter, zIndex: 4 }, + ...(trendLine.length ? [{ + type: "line", data: trendLine, color: INK, lineWidth: 2, + dashStyle: "Solid", marker: { enabled: false }, enableMouseTracking: false, zIndex: 5, + }] : []), + ], + }; + }; + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + chart = Highcharts.chart(container, buildOpts() as any); + }); + + // in the parent remounts this component on station/period change, + // so no createEffect needed — just clean up on unmount. + onCleanup(() => { chart?.destroy(); chart = null; }); + + return
; +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export interface SpeiTrendChartProps { data: SpeiStationData; } + +export function SpeiTrendChart(props: SpeiTrendChartProps) { + const stations = createMemo(() => Object.keys(props.data.stations).sort()); + + const [station, setStation] = createSignal(stations()[0] ?? ""); + const [period, setPeriod] = createSignal("Summer"); + + const series = createMemo((): SpeiSeries | null => + props.data.stations[station()]?.[period()] ?? null + ); + + const isMonth = createMemo(() => (MONTHS as readonly string[]).includes(period())); + const scaleLabel = createMemo(() => isMonth() ? "SPEI-30" : "SPEI-3"); + + // Trend stats for the header box + const trendStats = createMemo(() => { + const s = series(); + if (!s) return null; + const tr = s.trend as SpeiTrend | undefined; + if (tr?.slope_per_decade == null) return null; + + const slope = tr.slope_per_decade; + const p = tr.p_value; + const n = s.years.length; + const lastYear = s.years[n - 1]!; + const sl = slope / 10; + const ic = tr.intercept; + const curVal = sl * lastYear + ic; + + let thresholdLine = ""; + if (sl !== 0) { + if (sl < 0) { + if (curVal <= -1.5) { + thresholdLine = "Trendna linija je že prečkala prag hude suše (SPEI −1,5)."; + } else { + const yr = Math.round((-1.5 - ic) / sl); + if (yr > lastYear && yr < 2200) thresholdLine = `Pri tem trendu doseže hudo sušo (SPEI −1,5) okoli leta ${yr}.`; + } + } else { + if (curVal >= 1.5) { + thresholdLine = "Trendna linija je že prečkala prag zelo mokrega (SPEI +1,5)."; + } else { + const yr = Math.round((1.5 - ic) / sl); + if (yr > lastYear && yr < 2200) thresholdLine = `Pri tem trendu doseže zelo mokro (SPEI +1,5) okoli leta ${yr}.`; + } + } + } + + const sig = p < 0.05 + ? `statistično značilen (p < 0,05)` + : `ni statistično značilen (p = ${p.toFixed(3)})`; + + const tech = `Theil-Sen naklon: ${slope >= 0 ? "+" : ""}${slope.toFixed(3)} SPEI/desetletje · Mann-Kendall: ${tr.mk_trend} · ${sig}. Negativen trend pomeni, da razmere postajajo sušnejše glede na osnovno obdobje ${props.data.baseline}.${thresholdLine ? " " + thresholdLine : ""}`; + + return { slope, p, tech }; + }); + + // ── Button styles ────────────────────────────────────────────────────────── + + function stationBtnStyle(s: string) { + const active = station() === s; + return { + "font-family": "var(--font-sans)", "font-size": "11px", padding: "4px 10px", + "border-radius": "20px", border: "1px solid var(--color-rule-2)", cursor: "pointer", + background: active ? "var(--color-ink)" : "var(--color-card)", + color: active ? "#fff" : "var(--color-ink-soft)", + }; + } + + function periodBtnStyle(p: Period) { + const active = period() === p; + return { + "font-family": "var(--font-mono)", "font-size": "10px", "letter-spacing": "0.04em", + padding: "3px 9px", "border-radius": "20px", + border: "1px solid var(--color-rule-2)", cursor: "pointer", + background: active ? "var(--color-ink)" : "var(--color-card)", + color: active ? "#fff" : "var(--color-ink-soft)", + }; + } + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ + {/* Station row */} +
+ + {(s) => ( + + )} + +
+ + {/* Season + month row */} +
+ + {(s) => } + + + + {(m) => } + +
+ + {/* Chart card */} +
+
+ + {/* Panel header */} +
+
+
+ {station().replace(/_/g, " ")} — {period()} {scaleLabel()} +
+ + {(s) => ( +
+ {s().years.length} {isMonth() ? "mesecev" : "sezon"} · {s().years[0]}–{s().years[s().years.length - 1]} +
+ )} +
+
+ + {/* Slope box */} + + {(ts) => ( +
+
+ {ts().slope >= 0 ? "+" : ""}{ts().slope.toFixed(2)} +
+
+ SPEI / desetletje +
+
+ )} +
+
+ + {/* Chart */} +
+ + {(s) => } + +
+ + {/* Explanation */} + + {(ts) => ( +

+ {ts().tech} +

+ )} +
+ +

+ Premalo podatkov za izračun trenda. +

+
+ +
+
+ +
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/TodayGauge.tsx b/code/ali-je-vroce-era5/charts/TodayGauge.tsx new file mode 100644 index 00000000..138ef40a --- /dev/null +++ b/code/ali-je-vroce-era5/charts/TodayGauge.tsx @@ -0,0 +1,112 @@ +import { onMount, onCleanup, createEffect } from "solid-js"; +import type { TodayStatus } from "../types.ts"; + +interface Props { + data: TodayStatus; +} + +const CAT_ORDER = ["freezing", "cold", "nope", "hot", "hell"] as const; +const CAT_COLORS = ["#3a5a8a", "#6c8fb6", "#e7d9b8", "#c25a2c", "#962c1a"] as const; +const BOUNDS = [0, 10, 20, 80, 95, 101] as const; + +function dialPosition(catKey: string, pct: number): number { + const idx = Math.max(0, CAT_ORDER.indexOf(catKey as typeof CAT_ORDER[number])); + const lo = BOUNDS[idx], hi = BOUNDS[idx + 1]; + const frac = hi > lo ? Math.min(1, Math.max(0, (pct - lo) / (hi - lo))) : 0.5; + return idx + frac; +} + +export function TodayGauge(props: Props) { + let container!: HTMLDivElement; + let chart: Highcharts.Chart | null = null; + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + await import("highcharts/highcharts-more"); + + const r = props.data; + const catKey = r.category_key ?? "nope"; + const pct = r.percentile ?? 0; + const target = dialPosition(catKey, pct); + + chart = Highcharts.chart(container, { + chart: { + type: "gauge", + backgroundColor: "transparent", + margin: [0, 0, 0, 0], + animation: { duration: 900, easing: "easeOutQuint" }, + }, + title: null, + credits: { enabled: false }, + tooltip: { enabled: false }, + exporting: { enabled: false }, + accessibility: { enabled: false }, + pane: { + startAngle: -90, + endAngle: 90, + center: ["50%", "92%"], + size: "150%", + background: undefined, + }, + yAxis: { + min: 0, + max: CAT_ORDER.length, + tickPositions: [], + minorTickInterval: null as any, + plotBands: CAT_ORDER.map((_, i) => ({ + from: i, + to: i + 1, + color: CAT_COLORS[i], + thickness: 18, + outerRadius: "100%", + })), + lineWidth: 0, + }, + series: [{ + type: "gauge", + data: [0], + dial: { + radius: "62%", + baseWidth: 4, + baseLength: "0%", + rearLength: "0%", + backgroundColor: "#1a1a18", + }, + pivot: { radius: 5, backgroundColor: "#1a1a18" }, + dataLabels: { enabled: false }, + }], + } as Highcharts.Options); + + // Animate needle from 0 → actual value on first render + chart.series[0]?.setData([target], true, { duration: 900 }); + }); + + createEffect(() => { + const catKey = props.data.category_key ?? "nope"; + const pct = props.data.percentile ?? 0; + if (!chart) return; + chart.series[0]?.setData([dialPosition(catKey, pct)], true, { duration: 500 }, false); + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + const tempStyle = () => { + const catKey = props.data.category_key ?? "nope"; + return { + background: props.data.color ?? "#e7d9b8", + color: catKey === "nope" ? "#1a1a18" : "#ffffff", + }; + }; + + return ( +
+
+
+ {props.data.today_temp?.toFixed(1)} °C +
+
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/TropicalChart.tsx b/code/ali-je-vroce-era5/charts/TropicalChart.tsx new file mode 100644 index 00000000..d4d9d4fd --- /dev/null +++ b/code/ali-je-vroce-era5/charts/TropicalChart.tsx @@ -0,0 +1,427 @@ +import { createSignal, createEffect, For, Show, onMount, onCleanup } from "solid-js"; + +interface TropTrend { + model_used: boolean; + rate_per_year: number; + days_per_decade: number; + p_value: number; + x_line: number[]; + y_line: number[]; + ci_low: number[]; + ci_high: number[]; + pi_low?: number[]; + pi_high?: number[]; + fit_year_max: number; + aic: number; + alpha: number; +} + +interface TropStation { + years: number[]; + counts: number[]; + nonzero_count: number; + trend: TropTrend; +} + +interface TropData { + stations: Record; + era5_last: string; +} + +interface Config { + kind: "days" | "nights"; + endpoint: string; + unitLabel: string; + defaultThreshold: number; + minT: number; + maxT: number; + subLabel: (th: number, st: number) => string; + tooltipNoun: string; + plainDesc: (th: number) => string; + plainNoun: string; +} + +const CONFIGS: Record = { + days: { + kind: "days", + endpoint: "/api/tropical_days", + unitLabel: "dni", + defaultThreshold: 30, + minT: 15, + maxT: 45, + subLabel: (th, st) => `Število dni z najvišjo temperaturo nad ${th} °C` + + (st > 1 ? `, v nizih ${st}+ zaporednih dni` : "") + + ` na leto · lapsna korekcija nadmorske višine · ERA5-Land`, + tooltipNoun: "Tropski dnevi", + plainDesc: (th) => `Tropski dan — ko dnevna temperatura preseže ${th} °C — povečuje toplotni stres in zdravstvena tveganja.`, + plainNoun: "tropski dan", + }, + nights: { + kind: "nights", + endpoint: "/api/tropical_nights", + unitLabel: "noči", + defaultThreshold: 20, + minT: 5, + maxT: 35, + subLabel: (th, st) => `Število noči z najnižjo temperaturo nad ${th} °C` + + (st > 1 ? `, v nizih ${st}+ zaporednih noči` : "") + + ` na leto · lapsna korekcija nadmorske višine · ERA5-Land`, + tooltipNoun: "Tropske noči", + plainDesc: (th) => `Tropska noč — ko temperatura čez noč ostane nad ${th} °C — preprečuje telesu okrevanje po dnevni vročini.`, + plainNoun: "tropska noč", + }, +}; + +const INK = "#0E0E0C"; +const INK_SOFT = "#6B655B"; +const ACCENT = "#C25A2C"; +const MONO = { fontFamily: "'JetBrains Mono', monospace" }; + +function pFmt(p: number): string { + return p < 0.001 ? "p < 0.001" : p < 0.01 ? "p < 0.01" : p < 0.05 ? `p = ${p.toFixed(3)}` : `p = ${p.toFixed(3)} (ns)`; +} + +// ── Highcharts inner component ──────────────────────────────────────────────── + +interface ChartProps { + station: string; + series: TropStation; + cfg: Config; + threshold: number; +} + +function TropHighchart(props: ChartProps) { + let container!: HTMLDivElement; + let chart: any = null; + + const buildSeries = () => { + const { years, counts, trend } = props.series; + const currentYear = new Date().getFullYear(); + + const barData = years.map((y, i) => ({ + x: y, y: counts[i]!, + ...(y === currentYear ? { color: ACCENT, opacity: 0.4 } : {}), + })); + + const out: any[] = [ + { + type: "column", + name: props.cfg.tooltipNoun, + data: barData, + color: ACCENT + "99", + groupPadding: 0.05, + pointPadding: 0, + borderWidth: 0, + zIndex: 2, + dataLabels: { + enabled: true, + style: { fontSize: "8px", fontWeight: "400", color: INK_SOFT, textOutline: "none" }, + formatter(this: any) { return this.y || null; }, + }, + }, + ]; + + if (trend?.model_used && trend.x_line) { + const dpd = trend.days_per_decade; + const p = trend.p_value; + + if (trend.pi_low) { + out.push({ + type: "arearange", + name: "95% PI", + data: trend.x_line.map((x, i) => [x, trend.pi_low![i], trend.pi_high![i]]), + color: INK, + fillOpacity: 0.05, + lineWidth: 0, + marker: { enabled: false }, + enableMouseTracking: false, + zIndex: 0, + }); + } + + out.push( + { + type: "arearange", + name: "95% CI", + data: trend.x_line.map((x, i) => [x, trend.ci_low[i], trend.ci_high[i]]), + color: INK, + fillOpacity: 0.12, + lineWidth: 0, + marker: { enabled: false }, + enableMouseTracking: false, + zIndex: 1, + }, + { + type: "line", + name: `NB fit (${dpd >= 0 ? "+" : ""}${dpd.toFixed(1)} ${props.cfg.unitLabel}/des · ${pFmt(p)})`, + data: trend.x_line.map((x, i) => ({ x, y: trend.y_line[i] })), + color: INK, + lineWidth: 2, + dashStyle: "Solid", + marker: { enabled: false }, + zIndex: 3, + } + ); + } + return out; + }; + + const buildXPlotLines = () => { + if (!props.series.trend?.fit_year_max) return []; + return [{ + value: props.series.trend.fit_year_max + 0.5, + color: INK_SOFT, width: 1, dashStyle: "Dot" as const, zIndex: 4, + label: { + text: `trend do ${props.series.trend.fit_year_max}`, rotation: 0, + align: "right" as const, x: -4, y: -4, + style: { fontSize: "9px", color: INK_SOFT, ...MONO }, + }, + }]; + }; + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + await import("highcharts/highcharts-more"); + + chart = Highcharts.chart(container, { + chart: { type: "column", height: 300, backgroundColor: "transparent", animation: false, style: { fontFamily: "Space Grotesk, sans-serif" } }, + title: { text: undefined }, + credits: { enabled: false }, + legend: { enabled: true, itemStyle: { fontSize: "10px", fontWeight: "400", color: INK } }, + tooltip: { + formatter(this: any) { + if (this.series.type === "line") return `${Math.round(this.x)}
Trend: ${this.y!.toFixed(1)} ${props.cfg.unitLabel}`; + if (this.series.type === "arearange") return false as any; + const partial = this.x === new Date().getFullYear() ? " (leto v teku)" : ""; + return `${this.x}${partial}
${props.cfg.tooltipNoun}: ${this.y}`; + }, + }, + xAxis: { + title: { text: null }, + labels: { style: { color: INK_SOFT, fontSize: "10px", ...MONO } }, + gridLineWidth: 0, + tickColor: "rgba(14,14,12,0.1)", + plotLines: buildXPlotLines(), + }, + yAxis: { + title: { text: props.cfg.unitLabel.charAt(0).toUpperCase() + props.cfg.unitLabel.slice(1), style: { fontSize: "10px", color: INK_SOFT } }, + min: 0, + gridLineColor: "rgba(14,14,12,0.06)", + labels: { style: { fontSize: "10px", color: INK_SOFT, ...MONO } }, + }, + series: buildSeries(), + } as any); + }); + + // ── KEY FIX: read reactive props BEFORE early return so deps are always tracked ── + createEffect(() => { + const _series = props.series; // establish reactive dependency + const _threshold = props.threshold; // establish reactive dependency + if (!chart) return; + const series = buildSeries(); + while (chart.series.length) chart.series[0].remove(false); + series.forEach(s => chart.addSeries(s, false)); + chart.xAxis[0].update({ plotLines: buildXPlotLines() }, false); + chart.redraw(false); + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + return
; +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface Props { kind: "days" | "nights"; } + +export function TropicalChart(props: Props) { + const cfg = CONFIGS[props.kind]!; + + const [threshold, setThreshold] = createSignal(cfg.defaultThreshold); + const [streak, setStreak] = createSignal(1); + const [station, setStation] = createSignal(null); + const [data, setData] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(false); + + const load = async () => { + setLoading(true); + setError(false); + try { + const resp = await fetch(`${cfg.endpoint}?threshold=${threshold()}&streak=${streak()}`); + if (!resp.ok) throw new Error(); + const d: TropData = await resp.json(); + if (!d.stations) throw new Error(); + setData(d); + const stations = Object.keys(d.stations).sort(); + if (!station() || !stations.includes(station()!)) { + setStation(stations[0] ?? null); + } + } catch { + setError(true); + } finally { + setLoading(false); + } + }; + + onMount(load); + + const stations = () => Object.keys(data()?.stations ?? {}).sort(); + const series = () => data()?.stations[station() ?? ""] ?? null; + const trend = () => series()?.trend; + + const trendDesc = () => { + const tr = trend(); + if (!tr?.model_used) return null; + const dpd = tr.days_per_decade; + const p = tr.p_value; + const nz = series()?.nonzero_count ?? 0; + + const techLine = + `NB GLM: ${tr.rate_per_year >= 0 ? "+" : ""}${tr.rate_per_year.toFixed(2)}%/leto · ` + + `${dpd >= 0 ? "+" : ""}${dpd.toFixed(1)} ${cfg.unitLabel}/desetletje · ` + + `95% CI · ${p < 0.05 ? `statistično značilen (${pFmt(p)})` : `ni značilen (${pFmt(p)})`} · ` + + `AIC ${tr.aic.toFixed(0)} · α=${tr.alpha}.`; + + const fittedLast = tr.y_line[tr.y_line.length - 1]!; + const proj2050 = Math.round(fittedLast * Math.pow(1 + tr.rate_per_year / 100, 2050 - tr.fit_year_max)); + const dir = dpd > 0 ? "več" : "manj"; + const sig = p < 0.05 ? "statistično značilen trend" : "trend, ki še ni statistično značilen"; + const forward = p < 0.05 && proj2050 > 0 + ? `Če trend nadaljuje, bi bilo do 2050 tipičnih okoli ${proj2050} ${cfg.unitLabel} na leto.` + : `Podatki ne kažejo jasnega signala, a smer spremembe je vredna pozornosti.`; + + const plainLine = `${cfg.plainDesc(threshold())} Postaja kaže ${sig}: grobe ${Math.abs(dpd).toFixed(1)} ${dir} ${cfg.unitLabel} na desetletje. ${forward}`; + + return { tech: techLine, plain: plainLine }; + }; + + const noTrendReason = () => { + const tr = trend(); + if (tr?.model_used) return null; + const nz = series()?.nonzero_count ?? 0; + return nz < 10 + ? `Premalo let z ${cfg.plainNoun}i za izračun trenda (${nz} let z vrednostjo > 0). Potrebnih je vsaj 10.` + : null; + }; + + const INPUT_STYLE = { width: "52px", border: "1px solid var(--color-rule-2)", "border-radius": "6px", padding: "3px 6px", "font-family": "var(--font-mono)", "font-size": "11px", background: "var(--color-card)", color: "var(--color-ink)", "text-align": "center" } as const; + const LABEL_STYLE = { display: "flex", "align-items": "center", gap: "6px", "font-family": "var(--font-mono)", "font-size": "10px", color: "var(--color-ink-soft)", "letter-spacing": "0.08em", "text-transform": "uppercase" } as const; + + return ( +
+ + {/* ── Station picker ── */} + 0}> +
+ + {(s) => ( + + )} + +
+
+ + {/* ── Parameter controls ── */} +
+ + + + + + {cfg.subLabel(threshold(), streak())} + +
+ + {/* ── Chart card ── */} +
+
+ +
+
+ {station()?.replace(/_/g, " ") ?? "—"} +
+ + {(s) => ( +
+ {s().years.length} let · {s().years[0]}–{s().years[s().years.length - 1]} +
+ )} +
+
+ +
+ +
+ + +
+ Podatkov ni mogoče naložiti. +
+
+ + {(s) => ( + + )} + +
+ + {/* Trend description */} + + {(td) => ( +
+

+ {td().tech} +

+

+ {td().plain} +

+
+ )} +
+ + {(r) => ( +

+ {r()} +

+ )} +
+ +
+
+ +
+ ); +} diff --git a/code/ali-je-vroce-era5/charts/YearRoundChart.tsx b/code/ali-je-vroce-era5/charts/YearRoundChart.tsx new file mode 100644 index 00000000..b878e97d --- /dev/null +++ b/code/ali-je-vroce-era5/charts/YearRoundChart.tsx @@ -0,0 +1,159 @@ +import { onMount, onCleanup, createEffect } from "solid-js"; +import type { CalendarData } from "../api.ts"; + +interface Props { + data: CalendarData; + doy: number; + var: string; +} + +const MONTH_DOYS = [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; +const MONTH_MIDS = MONTH_DOYS.map((d, i) => + Math.round((d + (MONTH_DOYS[i + 1] ?? 366)) / 2) +); +const MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + +const IS_PRECIP = new Set(["precipitation_sum","et0_evapotranspiration"]); +const POS_TEMP = [210, 55, 35] as const; // red — warming +const NEG_TEMP = [35, 90, 210] as const; // blue — cooling +const POS_PRECIP = [35, 100, 210] as const; // blue — wetter +const NEG_PRECIP = [180, 105, 25] as const; // amber — drier + +function mdToDoy(month: number, day: number): number { + const jan1 = new Date(2001, 0, 1).getTime(); + return Math.round((new Date(2001, month - 1, day).getTime() - jan1) / 86400000) + 1; +} + +function barColor(trend10: number, p_val: number, variable: string): string { + const isPos = trend10 >= 0; + const isPrecip = IS_PRECIP.has(variable); + const [r, g, b] = isPrecip + ? (isPos ? POS_PRECIP : NEG_PRECIP) + : (isPos ? POS_TEMP : NEG_TEMP); + const alpha = p_val < 0.001 ? 0.95 : p_val < 0.01 ? 0.70 : p_val < 0.05 ? 0.40 : 0.12; + return `rgba(${r},${g},${b},${alpha})`; +} + +export function YearRoundChart(props: Props) { + let container!: HTMLDivElement; + let chart: any = null; + + function buildSeries(data: CalendarData, variable: string) { + return data.rows.map(r => ({ + x: mdToDoy(r.month, r.day), + y: r.trend10, + color: barColor(r.trend10, r.p_val, variable), + p: r.p_val, + })); + } + + onMount(async () => { + const Highcharts = (await import("highcharts")).default; + + const colData = buildSeries(props.data, props.var); + + chart = Highcharts.chart(container, { + chart: { + type: "column", + marginTop: 10, + marginBottom: 34, + marginLeft: 52, + marginRight: 12, + animation: false, + backgroundColor: "transparent", + style: { fontFamily: "Space Grotesk, system-ui, sans-serif" }, + }, + title: { text: undefined }, + subtitle: { text: undefined }, + credits: { enabled: false }, + legend: { enabled: false }, + xAxis: { + min: 0.5, max: 365.5, + tickPositions: MONTH_MIDS, + labels: { + formatter(this: any) { + const idx = MONTH_MIDS.findIndex(m => m === this.value); + return idx >= 0 ? MONTH_LABELS[idx]! : ""; + }, + style: { fontSize: "9px", fontFamily: "'JetBrains Mono', monospace", color: "#6B655B" }, + }, + lineColor: "rgba(14,14,12,0.10)", + tickColor: "rgba(14,14,12,0.10)", + gridLineWidth: 0, + plotLines: [{ + id: "doy-line", + value: props.doy, + color: "#962c1a", + width: 2, + zIndex: 5, + dashStyle: "ShortDash" as Highcharts.DashStyleValue, + }], + }, + yAxis: { + title: { text: null }, + labels: { + format: "{value}", + style: { fontSize: "9px", fontFamily: "'JetBrains Mono', monospace", color: "#6B655B" }, + }, + plotLines: [{ + value: 0, + color: "rgba(14,14,12,0.2)", + width: 1, + }], + gridLineColor: "rgba(14,14,12,0.06)", + }, + plotOptions: { + column: { + animation: false, + grouping: false, + borderWidth: 0, + pointPadding: 0, + groupPadding: 0, + }, + }, + tooltip: { + formatter(this: any) { + const ref = new Date(2001, 0, this.x - 1); + const label = ref.toLocaleDateString("en-GB", { month: "short", day: "numeric" }); + const sign = this.y >= 0 ? "+" : ""; + const pStr = this.point.p < 0.001 ? "< 0.001" : this.point.p.toFixed(3); + return `${label}
Trend: ${sign}${this.y.toFixed(3)} ${props.data.unit}/decade
p = ${pStr}`; + }, + style: { fontSize: "12px", fontFamily: "Space Grotesk, sans-serif" }, + }, + series: [{ + type: "column", + name: "trend/decade", + data: colData, + borderWidth: 0, + }], + } as Highcharts.Options); + }); + + // Update plotLine when DOY changes — no rebuild + createEffect(() => { + const doy = props.doy; + if (!chart) return; + chart.xAxis[0].removePlotLine("doy-line"); + chart.xAxis[0].addPlotLine({ + id: "doy-line", + value: doy, + color: "#962c1a", + width: 2, + zIndex: 5, + dashStyle: "ShortDash", + }); + }); + + // Rebuild series when data or variable changes + createEffect(() => { + const data = props.data; + const variable = props.var; + if (!chart) return; + chart.series[0]?.setData(buildSeries(data, variable), true, false, false); + }); + + onCleanup(() => { chart?.destroy(); chart = null; }); + + return
; +} diff --git a/code/ali-je-vroce-era5/components/HeroCards.tsx b/code/ali-je-vroce-era5/components/HeroCards.tsx new file mode 100644 index 00000000..0950c4f3 --- /dev/null +++ b/code/ali-je-vroce-era5/components/HeroCards.tsx @@ -0,0 +1,367 @@ +import { createResource, Show } from "solid-js"; +import { fetchRegression } from "../api.ts"; +import type { RegressionResponse, RegressionResult } from "../types.ts"; + +// ── Inlined locale (from sl_default.json hero / hero_category / hero_context / climate_risks) ── + +const VERDICT_WARMING = "Sto let segrevanja za {sign}{t100} {unit} – pri trenutnem tempu vsaka {yrs} let doda eno stopinjo."; +const VERDICT_COOLING = "Sto let ohlajanja za {sign}{t100} {unit} – pri trenutnem tempu vsaka {yrs} let odšteje eno stopinjo."; +const VERDICT_NONE = "Na ta dan v letu ni zaznati trenda."; +const METHOD_THEILSEN = "Theil-Sen + TFPW Mann-Kendall"; + +const CATEGORIES: Record = { + catastrophic: "Katastrofalno", + extreme: "Ekstremno", + bad: "Slabo", + moderate: "Zmerno", + baseline: "Izhodiščno", +}; + +const CAT_STYLE: Record = { + catastrophic: { background: "#962c1a", color: "#fff" }, + extreme: { background: "#C25A2C", color: "#fff" }, + bad: { background: "#c2843a", color: "#fff" }, + moderate: { background: "#b5a06a", color: "#fff" }, + baseline: { background: "#6c8fb6", color: "#fff" }, +}; + +const CLIMATE_RISKS: Record = { + Ljubljana: "Mestni toplotni otok — poletni vročinski stres in tveganje hudourniških poplav v Ljubljanski kotlini", + Maribor: "Sušni stres v vinogradništvu — zgodnejše trgatve in premik vinskih sort", + Celje: "Okrepitev poplav Savinje v kombinaciji z naraščajočo poletno sušo", + Kranj: "Izguba alpske snežne odeje, ki zmanjšuje poletni pretok Save", + Koper: "Podaljšanje sredozemske suše — tveganje vdora slane vode in dviga morske gladine", + Novo_Mesto: "Suša reke Krke ogroža pridelavo hmelja in sadja", + Murska_Sobota: "Panonska vročina in suša — najtoplejše temperaturne razlike v Sloveniji", + Nova_Gorica: "Sredozemsko sušenje — ekosistem hladnovodne Soče in kraško vinogradništvo v nevarnosti", + Postojna: "Suša kraškega vodonosnika — jamski ekosistem in endemska favna pod toplotnim stresom", + Ptuj: "Nizki vodostaji Drave — vodni stres v vinogradništvu in zmanjšanje hidroenergetskih zmogljivosti", + Velenje: "Upravljanje z vodo po izkopavanju premoga ob stopnjevanju suše in poplav", + Trbovlje: "Ojačanje vročine v savski soteski in naraščajoče tveganje poplav", + Tolmin: "Stopnjevanje ekstremnih poplav Soče — krčenje ekosistema hladnovodnih rib", + Kocevje: "Propad pragozdov pod napadom lubadarja — izguba habitata velikih zveri", + Ilirska_Bistrica: "Izčrpavanje kraških izvirov — motnja podzemnega rečnega sistema Pivke", + Domzale: "Mestni toplotni otok v kotlini — kombinirani vročinski in sušni stres za Ljubljansko regijo", + Ratece: "Izguba alpske snežne odeje — grožnja smučarskemu gospodarstvu in nestabilnost permafrost pobočij", + Kredarica: "Izguba Triglavskega ledenika — izsušitev izvirnih vod za Slovenijo reke", +}; + +const HERO_CONTEXT: Record> = { + Ljubljana: { + baseline: "Glavno mesto v Ljubljanski kotlini, ki jo obkrožajo hribi in pozimi pastuje hladen zrak. Celinsko podnebje s toplimi poletji in hladnimi zimami, okrepljeno z mestnim toplotnim otokom.", + moderate: "Segrevanje skrajšuje zimsko megleno sezono, a povečuje poletni toplotni stres. Mestno tkivo zadržuje toploto, ponoči postaja vse toplejše po vsej metropolitanski regiji.", + bad: "Vročinski valovi presegajo 35 °C več zaporednih dni. Smrtnost starejših narašča. Kotlinska lega kopiči toploto; poraba klimatskih naprav se poveča in preobremeni omrežje.", + extreme: "Podaljšane vročinske izredne razmere postanejo letni pojav. Reka Ljubljanica poleti splahni, kar ogroža vodooskrbo. Mestna zelenjava trpi pod hudim sušnim stresom.", + catastrophic: "Podaljšani poletni vročinski valovi presegajo hladilne zmogljivosti mesta. Hudourniške poplave iz okrepljenih alpskih padavin večkrat ogrozijo nizko ležečo kotlino.", + }, + Maribor: { + baseline: "Drugo največje mesto, dolina reke Drave, severovzhodna Slovenija. Celinsko podnebje z vročimi poletji. Pomembna vinorodna regija — segrevanje že pomika datume trgatve naprej.", + moderate: "Podaljšana rastna sezona kratkoročno koristi vinu, a naraščajoč sušni stres in tveganje poznih zmrzali po zgodnji brstovitvi začenjata ogrožati pridelke.", + bad: "Datumi trgatve se premaknejo 3–4 tedne naprej. Tradicionalne štajerske sorte trpijo pod vročinskim stresom. Fitosanitarni problemi se bistveno okrepijo.", + extreme: "Tradicionalne štajerske vinogradniške sorte postanejo ekonomsko neobstojne. Povpraševanje po namakanju iz Drave se spopada z ekološkimi zahtevami pretoka.", + catastrophic: "Ponavljajoča se huda sušna leta sesedejo štajersko vinsko gospodarstvo. Ekstremno nizki vodostaji Drave ogrožajo vodooskrbo doline in obrečne ekosisteme.", + }, + Celje: { + baseline: "Dolina reke Savinje, zgodovinsko podvržena hudim poplavam. Celinsko podnebje. Zaprta dolina ustvarja temperaturne inverzije in zmrzalne žepe pozimi.", + moderate: "Segrevanje zmanjšuje zmrzalne dni, a krepi konvektivne padavine, ki sprožajo poplave Savinje, po katerih je mesto že znano.", + bad: "Dogodki, ki so bili prej stletni poplavni viški, se ponavljajo v desetletjih. Protipoplavna infrastruktura, zgrajena za zgodovinske povratne dobe, je sistematično prekoračena.", + extreme: "Kombinirani poletni sušni in zimski poplavni ekstremi hkrati destabilizirajo kmetijstvo in infrastrukturo v dolini Savinje.", + catastrophic: "Pogostejši ekstremni poplavni dogodki preplavljajo staro protipoplavno infrastrukturo. Izmenjava suša–poplava destabilizira kmetijsko savinjsko ravnino.", + }, + Kranj: { + baseline: "Ob vznožju Kamniško-Savinjskih Alp, dolina reke Save, 387 m. Alpski vpliv ohranja razmeroma hladna poletja. Vhod v Triglavski narodni park.", + moderate: "Upadanje alpske snežne odeje zmanjšuje poletni pretok Save, kar vpliva na vodooskrbo za odvodne uporabnike, vključno z Ljubljano.", + bad: "Poletni nizki vodostaji Save postanejo hudi. Hidroelektrična proizvodnja na savski verigi elektrarn v sušnih letih občutno pade.", + extreme: "Umikanje ledenikov v Kamniško-Savinjskih Alpah se pospeši. Kamenje iz destabilizirajočih se pobočij ogroža infrastrukturo v dolini.", + catastrophic: "Izguba zanesljive poletne savenice, ki jo napaja snežna odeja, ogroža hidroelektrično hrbtenico slovenskega elektroenergetskega sistema.", + }, + Koper: { + baseline: "Edino slovensko morsko pristanišče, Jadransko morje, 10 m nadmorske višine. Sredozemsko podnebje: mile zime, suha poletja, burjni vetrovi.", + moderate: "Segrevanje morske gladine podaljšuje kopalno sezono. Tveganje suše se poleti povečuje z naraščajočim sredozemskim sušnim signalom.", + bad: "Vdor slane vode v obalne vodonosnike se krepi. Mediteranske invazivne vrste se ustanavljajo v slovenskih obalnih vodah.", + extreme: "Pogostost in resnost neviht s sodro narašča. Obalno kmetijsko zemljišče se sooča s slanostjo od morskih razpršilcev in vdorov podzemne vode.", + catastrophic: "Dvig gladine Jadranskega morja ogroža pristaniško infrastrukturo in obalno kmetijstvo. Ekstremni burjni dogodki se krepijo z naraščajočimi temperaturnimi gradienti.", + }, + Novo_Mesto: { + baseline: "Dolina reke Krke, jugovzhodna Slovenija, 220 m. Celinsko s ponekod panonskim vplivom. Pomembna regija za sadje — jabolka, hruške in hmelj.", + moderate: "Segrevanje podaljšuje brezzmrzalno rastno sezono, a povečuje poletni sušni stres na povodju reke Krke.", + bad: "Osnovni pretok reke Krke poleti občutno upade. Pridelava hmelja se sooča z vročinskim in sušnim stresom, ki zahteva drago namakanje.", + extreme: "Večletne suše izčrpavajo reko Krko in podzemno vodo. Sadjarstvo in hmeljarstvo zahtevata temeljito prestrukturiranje.", + catastrophic: "Ponavljajoča se sušna leta izčrpavajo osnovni pretok reke Krke. Hmeljarsko in sadjarsko gospodarstvo Dolenjske se sooča s temeljitim prestrukturiranjem.", + }, + Murska_Sobota: { + baseline: "Severovzhodna panonska ravnina, 189 m. Najbolj celinsko postaja — najtoplejša poletja, najhladnejše zime, najmanj padavin. Intenzivno kmetijstvo: koruza, sončnice, pšenica.", + moderate: "Že najbolj sušno izpostavljena regija v Sloveniji. Dni z vročinskim stresom nad 35 °C naraščajo najhitreje. Primanjkljaj kmetijske vode je že občuten.", + bad: "Pridelki koruze in sončnic v sušnih letih padejo za 20–30 %. Poletni ekstremi nad 38 °C postanejo redni.", + extreme: "Večletna sušna zaporedja sesedejo tradicionalno kmetijstvo brez namakanja. Povpraševanje po namakanju preseže trajnostni donos reke Mure.", + catastrophic: "Panonsko kmetijsko gospodarstvo se sooča s propadom pod trajnimi večletnimi sušami. Izčrpavanje podzemne vode se pospeši.", + }, + Nova_Gorica: { + baseline: "Zahodna Slovenija, spodnja dolina Soče, 94 m. Sredozemsko-alpski prehod. Toplo in sončno z burjnimi vetrovi. Pomembna vinorodna regija.", + moderate: "Segrevanje pospešuje sredozemski trend sušenja. Burjni dogodki se morda okrepijo. Kakovost vin se preoblikuje z naraščanjem rastnih stopenj.", + bad: "Poletna suša naredi tradicionalno kraško vinogradništvo vse bolj odvisno od namakanja. Nizki poletni pretoki Soče ogrožajo ekologijo.", + extreme: "Hladnovodni ribolov Soče se sooča s toplotnim izumrtjem v spodnjih dosegih, ko poletne temperature presegajo toleranče.", + catastrophic: "Ekstremna poletna suša naredi tradiconalno kraško in brdiško vinogradništvo neobstojno brez namakanja.", + }, + Postojna: { + baseline: "Kraška planota, 549 m. Znana po temperaturnih inverzijah — hladen zrak se kopiči v kraški depresiji. Temperature v jamskem ekosistemu so stabilne pri 10 °C.", + moderate: "Segrevanje slabi temperaturne inverzije in zmanjšuje ekstremno hlajenje. Temperature v jamskem ekosistemu se začnejo premikati.", + bad: "Notranje jame začnejo izmerljivo naraščati. Pretok kraških izvirov poleti v sušah upada.", + extreme: "Endemska favna Postojnske jame — človeška ribica in jamski hrošči — se sooča s toplotnim in hidrološkim stresom.", + catastrophic: "Napajanje kraškega vodonosnika popusti pod podaljšano sušo. Endemska favna Postojnske jame se sooča s toplotnim stresom brez poti pobega.", + }, + Ptuj: { + baseline: "Najstarejše stalno poseljeno mesto v Sloveniji, dolina reke Drave, 228 m. Severovzhodna celinska cona. Vino, hmelj in žitno kmetijstvo.", + moderate: "Dogodki nizkih vodostajev Drave se pogostijo, kar vpliva na hladilno vodo za hidroelektrarno Formin nizvodno.", + bad: "Datumi trgatve se bistveno premaknejo naprej. Kmetijsko povpraševanje po vodi vse bolj nasprotuje ekološkim zahtevam pretoka.", + extreme: "Ekstremno nizki vodostaji Drave postanejo redni. Hidroelektrarna Formin se v sušnih letih sooča z obveznim omejevanjem.", + catastrophic: "Kombinirani sušni in vročinski ekstremi destabilizirajo kmetijsko gospodarstvo v dravski dolini.", + }, + Velenje: { + baseline: "Šaleška dolina, 405 m, osrednja Slovenija. Zgrajena okoli premogovništva. Gorska celinska mikroklima pod vplivom Šaleških jezer.", + moderate: "Ko premogovništvo upada, podnebni vplivi vključujejo povečano tveganje poplav v posedninskih conah.", + bad: "Šaleška jezera se soočajo z naraščajočim izhlapevanjem in cvetenjem alg. Upravljanje z vodo postane kompleksno.", + extreme: "Prehod po premogu oteži podnebne ekstreme. Načrtovani prehod na obnovljivo energijo se sooča s konflikti rabe tal.", + catastrophic: "Upravljanje z vodo v posedninskih jezerih postane kritično z naraščajočim izhlapevanjem.", + }, + Trbovlje: { + baseline: "Zasavje, savska soteska, 230 m. Najožja poseljena dolina v Sloveniji. Kotlinska lega ustvarja značilne temperaturne inverzije.", + moderate: "Segrevanje zmanjšuje zimske inverzije, ki kopičijo onesnaženost zraka, a povečuje poletni toplotni stres v zaprti dolini.", + bad: "Poletni vročinski valovi v soteski so okrepljeni z njeno geometrijo in industrijskimi toplotnimi viri.", + extreme: "Ekstremni padavinski dogodki nad Trbovljami povečujejo katastrofalno tveganje poplav v soteski.", + catastrophic: "Ekstremni vročinski valovi v soteski so okrepljeni z njeno geometrijo. Tveganje poplav Save narašča z naraščajočimi alpskimi padavinami.", + }, + Tolmin: { + baseline: "Dolina Soče/Isonzo, 194 m. Najtoplejša dolina v Sloveniji kljub alpski legi. Ekstremni padavinski dogodki naredijo to območje najdežnejše v Evropi.", + moderate: "Segrevanje krepi že tako ekstremne padavinske dogodke. Izjemna biotska raznovrstnost doline se sooča z naraščajočim toplotnim pritiskom.", + bad: "Poletni pretoki Soče občutno upadejo. Hladnovodni habitat marmornega postrva se krči navzgor po toku.", + extreme: "Ekstremni poplavni dogodki na sistemu Soča–Idrijca se ponavljajo pogosteje. Smaragdna barva Soče zbledi z umikanjem ledenikov.", + catastrophic: "Katastrofalni poplavni dogodki na Soči postanejo pogostejši. Hladnovodni ribolov in naravni turizem sta temeljno ogrožena.", + }, + Kocevje: { + baseline: "Regija Kočevski Rog, 467 m, pokrita z največjim ostankom pragozdov v Srednji Evropi. Medvedi, risi in volkovi v največji gostoti v Evropi.", + moderate: "Segrevanje premakne sestavo gozda k termofilnim vrstam. Izbruhi lubadarja se pospešijo v toplejših, sušnejših poletjih.", + bad: "Pragozd se sooča s strukturno preobrazbo pod kombiniranim pritiskom suše in lubadarja.", + extreme: "Obsežno odmiranje gozda odpre pragozd tveganju požarov, ki je bilo prej zanemarljivo.", + catastrophic: "Kočevski pragozd se sooča z nepopravljivo strukturno spremembo. Habitat velikih zveri se krči z upadanjem gozda.", + }, + Ilirska_Bistrica: { + baseline: "Pivška kotlina, 440 m. Prehodno območje med sredozemskim in celinskim podnebjem. Reka Pivka izgine pod zemljo v največji jamski sistem na svetu.", + moderate: "Segrevanje zmanjšuje zimsko snežno odejo, ki napaja kraške izvire Pivke.", + bad: "Površinski pretok reke Pivke izgine zgodaj v sezoni, ko upadejo kraški izviri.", + extreme: "Jamski sistem Postojna–Planina se sooča z zmanjšanim pretokom in naraščajočimi temperaturami vode.", + catastrophic: "Kraški izviri, ki napajajo reko Pivko, poleti presahnejo. Edinstveni hidrološki sistem je moten s kaskadnimi učinki.", + }, + Domzale: { + baseline: "Ljubljanska kotlina, 301 m. Primestno in lahko industrijsko. Celinsko podnebje z nekoliko manjšim mestnim toplotnim otokom kot Ljubljana.", + moderate: "Z razširjanjem metropolitanske regije se mestni toplotni otoški učinki krepijo.", + bad: "Poletni toplotni otok po kotlini ustvarja trajen vročinski stres za 400.000 prebivalcev regije.", + extreme: "Kombinirani vročinski in sušni dogodki bistveno zmanjšajo kakovost življenja in povečajo smrtnost.", + catastrophic: "Urbanizacija v kombinaciji s podnebnim segrevanjem ustvari trajen toplotni otok s kumulativnimi učinki na javno zdravje.", + }, + Ratece: { + baseline: "Tarbizijska kotlina, Julijske Alpe, 864 m. Eno najsušnejših mest v Sloveniji kljub alpski legi. Ekstremno hladne zime, obilna snežna odeja.", + moderate: "Globina in trajanje snežne odeje se opazno zmanjšujeta. Čas spomladanskega odtajanja se premika naprej.", + bad: "Smučarsko letovišče Kranjska Gora se sooča z ekonomsko neobstojnimi snežnimi sezonami.", + extreme: "Taljenje permafrosta v Julijskih Alpah sproži naraščajoče kamenje in nestabilnost pobočij.", + catastrophic: "Izguba zanesljive zimske snežne odeje konča smučarsko gospodarstvo v dolini Kranjske Gore.", + }, + Kredarica: { + baseline: "Vrh masiva Triglava, 2514 m — najvišja meteorološka postaja v Sloveniji. Stalni sneg, ostanki ledenikov, ekstremni vetrovi.", + moderate: "Triglavski ledenik — zadnji ledenik v Sloveniji — se vidno umika. Segrevanje je tukaj dvojno od nižinskega.", + bad: "Triglavski ledenik izgubi več kot polovico preostalega volumna. Ikonična silhueta se sooča s trajno spremembo.", + extreme: "Triglavski ledenik popolnoma izgine. Visokogorski ekosistem nad gozdno mejo propade.", + catastrophic: "Popolna izguba Triglavskega ledenika — narodni simbol in vir savinjskih rek. Propad alpskega ekosistema sproži kaskadne učinke.", + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function trendCategory(trend10: number): string { + if (trend10 >= 0.30) return "catastrophic"; + if (trend10 >= 0.20) return "extreme"; + if (trend10 >= 0.10) return "bad"; + if (trend10 >= 0.05) return "moderate"; + return "baseline"; +} + +function stars(p: number): string { + if (p < 0.001) return "★★★ p < 0.001"; + if (p < 0.01) return "★★ p < 0.01"; + if (p < 0.05) return `★ p = ${p.toFixed(3)}`; + return `p = ${p.toFixed(3)} (ns)`; +} + +function ar1Label(ar1: number | null): string { + if (ar1 === null) return "—"; + const abs = Math.abs(ar1); + const d = abs < 0.1 ? "zanemarljiva" : abs < 0.3 ? "šibka" : "zmerna"; + return `AR(1) = ${ar1} · ${d}`; +} + +function verdictHtml(st: RegressionResult["stats"], unit: string): string { + if (st.trend10 === 0) return VERDICT_NONE; + const isPos = st.trend10 > 0; + const sign = isPos ? "+" : "−"; + const t100 = (Math.abs(st.trend10) * 10).toFixed(2); + const yrs = (Math.abs(10 / st.trend10)).toFixed(1); + const tmpl = isPos ? VERDICT_WARMING : VERDICT_COOLING; + return tmpl + .replace("{sign}", sign) + .replace("{t100}", t100) + .replace("{unit}", unit) + .replace("{yrs}", yrs); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + loc: string | null; + doy: number; + dateLabel?: string; +} + +const ACCENT = "var(--color-ink)"; +const INK_SOFT = "var(--color-ink-soft)"; +const MONO = { "font-family": "var(--font-mono)" }; +const SANS = { "font-family": "var(--font-sans)" }; + +export function HeroCards(props: Props) { + const loc = () => props.loc ?? "Ljubljana"; + + const [resp] = createResource( + () => ({ loc: loc(), doy: props.doy }), + ({ loc, doy }) => + fetchRegression({ locs: [loc], var: "temperature_max", doy, window: 7, corr: "corr", method: "theilsen" }), + ); + + return ( + + + + ); +} + +function HeroCard(props: { res: RegressionResult; unit: string; dateLabel: string }) { + const res = () => props.res; + const st = () => res().stats; + const cat = () => trendCategory(st().trend10); + const isPos = () => st().trend10 >= 0; + const sign = () => isPos() ? "+" : "−"; + const signColor = () => isPos() ? "#C25A2C" : "#3A5A8A"; + + const nValues = () => (st() as any).n_values as number | undefined; + + const ctx = () => HERO_CONTEXT[res().loc]?.[cat()] ?? HERO_CONTEXT[res().loc]?.["baseline"]; + const risk = () => CLIMATE_RISKS[res().loc]; + + return ( +
+ + {/* Top row: eyebrow + trend stat + verdict */} +
+ + {/* Left column: eyebrow + stat */} +
+ {/* Eyebrow */} +
+ + {res().loc.replace(/_/g, " ")} + + + + {props.dateLabel} · temperature max + +
+ + {/* Trend number */} +
+ + {sign()}{Math.abs(st().trend10).toFixed(3)} + + + °C / decade + +
+ + {/* Climate risk label */} + +
+
+ Tveganje vpliva: +
+
+ {risk()} +
+
+
+
+ + {/* Right column: verdict + category */} +
+ + {/* Category badge */} +
+ + {CATEGORIES[cat()]} + +
+ + {/* Context text */} + +

+ {ctx()} +

+
+ + {/* Verdict */} +
+

+

+ {METHOD_THEILSEN} +

+
+
+
+ + {/* Stats row */} +
+ {([ + ["Significance", stars(st().p_val)], + [st().metric_lbl, st().metric.toFixed(4)], + ["Sample", nValues() ? `${nValues()!.toLocaleString()} opazovanj · ${st().n_years} let` : `${st().n_years} let`], + ["Autocorrelation", ar1Label(st().ar1)], + ] as [string, string][]).map(([k, v], i) => ( +
+
+ {k} +
+
+ {v} +
+
+ ))} +
+
+ ); +} diff --git a/code/ali-je-vroce-era5/components/RegressionPanel.tsx b/code/ali-je-vroce-era5/components/RegressionPanel.tsx new file mode 100644 index 00000000..117f7a16 --- /dev/null +++ b/code/ali-je-vroce-era5/components/RegressionPanel.tsx @@ -0,0 +1,508 @@ +import { createSignal, createResource, createEffect, createMemo, createContext, useContext, + Show, Suspense, For, lazy } from "solid-js"; +import type { JSXElement } from "solid-js"; +import type { SiteMeta, RegressionResponse } from "../types.ts"; +import { fetchRegression, fetchCalendar } from "../api.ts"; + +const RegressionChart = lazy(() => import("../charts/RegressionChart.tsx").then(m => ({ default: m.RegressionChart }))); +const YearRoundChart = lazy(() => import("../charts/YearRoundChart.tsx").then(m => ({ default: m.YearRoundChart }))); + +const VARIABLES: [string, string][] = [ + ["temperature_max", "Max temperature (°C)"], + ["temperature_min", "Min temperature (°C)"], + ["temperature_mean", "Mean temperature (°C)"], + ["precipitation_sum", "Precipitation (mm)"], + ["et0_evapotranspiration", "ET₀ (mm)"], +]; + +interface ProviderProps { + meta: SiteMeta; + defaultDoy: number; + syncLoc?: () => string | null; + onLocChange?: (loc: string) => void; + children?: JSXElement; +} + +function doyToLabel(doy: number): string { + const d = new Date(Date.UTC(2001, 0, 1)); + d.setUTCDate(d.getUTCDate() + doy - 1); + return d.toLocaleDateString("en-GB", { month: "short", day: "numeric" }); +} + +// ── Store factory ───────────────────────────────────────────────────────────── + +function createStore(props: ProviderProps) { + const defaultLoc = () => props.meta.default_location ?? "Ljubljana"; + + const [selLocs, setSelLocs] = createSignal([defaultLoc()]); + const [selVar, setSelVar] = createSignal("temperature_max"); + const [doy, setDoy] = createSignal(props.defaultDoy); + const [window_, setWindow] = createSignal(30); + const [corr, setCorr] = createSignal(false); + const [useOls, setUseOls] = createSignal(false); + const [locOpen, setLocOpen] = createSignal(false); + + createEffect(() => { + const ext = props.syncLoc?.(); + if (ext) setSelLocs([ext]); + }); + + const params = createMemo(() => ({ + locs: selLocs(), + var: selVar(), + doy: doy(), + window: window_(), + corr: corr() ? "corr" : "raw" as const, + method: useOls() ? "ols" : "theilsen" as const, + })); + const [regData] = createResource(params, fetchRegression); + + const calParams = createMemo(() => ({ + loc: selLocs()[0] ?? defaultLoc(), + var: selVar(), + window_: window_(), + corr: corr() ? "corr" : "raw" as const, + method: useOls() ? "ols" : "theilsen" as const, + })); + const [calData] = createResource( + calParams, + p => fetchCalendar(p.loc, p.var, p.window_, p.corr, p.method), + ); + + const isPrecip = () => selVar() === "precipitation_sum" || selVar() === "et0_evapotranspiration"; + const stats0 = () => (regData()?.results ?? [])[0]?.stats; + const trend10 = () => stats0()?.trend10 ?? 0; + const trendColor = () => { + const t = trend10(); + if (isPrecip()) return t >= 0 ? "#1a5fc8" : "#a05c20"; + return t >= 0 ? "#cc2222" : "#1a5fc8"; + }; + const totalChange = () => { + const s = stats0(); + if (!s) return null; + return (trend10() * s.n_years / 10).toFixed(2); + }; + const locLabel = () => { + const locs = selLocs(); + return locs.length === 1 ? locs[0].replace(/_/g, " ") : `${locs.length} locations`; + }; + const varLabel = () => VARIABLES.find(([k]) => k === selVar())?.[1] ?? selVar(); + const chartTitle = () => `${varLabel().split("(")[0].trim()} · ${doyToLabel(doy())} ±${window_()}d`; + const chartSub = () => selLocs().map(l => l.replace(/_/g, " ")).join(", "); + + function toggleLoc(name: string) { + setSelLocs(prev => { + if (prev.includes(name)) return prev.length > 1 ? prev.filter(l => l !== name) : prev; + return [...prev, name].slice(0, 6); + }); + } + + return { + meta: props.meta, + selLocs, setSelLocs, selVar, setSelVar, + doy, setDoy, window_, setWindow, + corr, setCorr, useOls, setUseOls, + locOpen, setLocOpen, + regData, calData, + isPrecip, stats0, trend10, trendColor, totalChange, + locLabel, varLabel, chartTitle, chartSub, + toggleLoc, doyToLabel, VARIABLES, + }; +} + +type Store = ReturnType; +const RegressionCtx = createContext(); +const useReg = () => useContext(RegressionCtx)!; + +// ── Provider ────────────────────────────────────────────────────────────────── + +export function RegressionPanel(props: ProviderProps) { + const store = createStore(props); + return ( + + {props.children} + + ); +} + +// ── Toolbar ─────────────────────────────────────────────────────────────────── + +export function RegToolbar() { + const s = useReg(); + return ( +
+ + {/* Location */} +
+
+ Location + +
+ +
e.stopPropagation()}> +
+ Select locations (max 6) + +
+ + {(st) => { + const active = () => s.selLocs().includes(st.name); + return ( + + ); + }} + +
+
s.setLocOpen(false)} /> + +
+ + {/* Variable */} +
+ Variable +
+ +
+
+ + {/* Method */} +
+ Method +
+ + +
+
+ + {/* Elevation correction */} + +
+ Elevation corr. +