From c8bf80a2c8c0b721f8da01c4e7af1216b75673e2 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Fri, 19 Jun 2026 00:32:22 +0200 Subject: [PATCH] HexaWetter v1.3.0: Add precipitation map feature, update README, and enhance UI elements --- .env.example | 6 + README.md | 2 + api/app/config.py | 7 +- api/app/main.py | 25 +- api/app/services/health.py | 10 +- api/app/services/precipitation_map.py | 102 ++++++++ api/app/services/warnings.py | 51 +++- api/app/services/wms.py | 174 +++++++++++--- frontend/app.js | 327 ++++++++++++++++++++++---- frontend/index.html | 41 +++- frontend/style.css | 225 +++++++++++++++++- 11 files changed, 867 insertions(+), 103 deletions(-) create mode 100644 api/app/services/precipitation_map.py diff --git a/.env.example b/.env.example index 0ce94ec..bb15aff 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,12 @@ CACHE_TTL_GEOCODE=86400 CACHE_TTL_WMS_TIMES=120 CACHE_TTL_HEALTH_SOURCES=90 CACHE_TTL_DASHBOARD=60 +CACHE_TTL_PRECIP_MAP=900 + +# Regenvorhersage-Karte (Open-Meteo Gitternetz) +PRECIP_MAP_HOURS_DEFAULT=24 +PRECIP_MAP_SPAN_DEG=3.0 +PRECIP_MAP_STEP_DEG=0.25 # Security (generate with: python scripts/setup_secrets.py 'your-secure-password') API_KEY= diff --git a/README.md b/README.md index ba13a1d..739ca5b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ cd HexaWetter - **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast - **Radar-Karte**: Leaflet + OpenStreetMap + DWD WMS (Niederschlagsradar, Warnungen) +- **Regenvorhersage-Karte**: Open-Meteo-Modell als Kartenoverlay (6–24 h, stündlich) - **Radar-Animation**: Zeitleiste (60–120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück - **Warnungen**: DWD-Warnungen mit Standortfilter (Landkreis-Matching) - **Ortssuche**: PLZ/Ort via Nominatim-Proxy, GPS, Favoriten @@ -65,6 +66,7 @@ Web-UI: `http://localhost:8080` · API (lokal): `http://127.0.0.1:8081/api/healt |----------|--------------| | `GET /api/health` | Status inkl. Quellen-Healthchecks | | `GET /api/forecast` | 7-Tage-Forecast | +| `GET /api/forecast/precipitation-map` | Regenvorhersage-Gitternetz für Karte | | `GET /api/observations` | DWD-Beobachtung (Bright Sky) | | `GET /api/warnings` | DWD-Warnungen (standortbezogen) | | `GET /api/geocode/search` | Ortssuche | diff --git a/api/app/config.py b/api/app/config.py index a748a57..20f6c4d 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -2,7 +2,7 @@ import os from pathlib import Path APP_NAME = "HexaWetter" -APP_VERSION = "1.2.0" +APP_VERSION = "1.3.0" DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) RAW_RADAR_DIR = DATA_DIR / "raw" / "radar" @@ -39,6 +39,11 @@ CACHE_TTL_GEOCODE = int(os.getenv("CACHE_TTL_GEOCODE", "86400")) CACHE_TTL_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120")) CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90")) CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60")) +CACHE_TTL_PRECIP_MAP = int(os.getenv("CACHE_TTL_PRECIP_MAP", "900")) + +PRECIP_MAP_SPAN_DEG = float(os.getenv("PRECIP_MAP_SPAN_DEG", "3.0")) +PRECIP_MAP_STEP_DEG = float(os.getenv("PRECIP_MAP_STEP_DEG", "0.25")) +PRECIP_MAP_HOURS_DEFAULT = int(os.getenv("PRECIP_MAP_HOURS_DEFAULT", "24")) RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")) diff --git a/api/app/main.py b/api/app/main.py index 9000edf..2eab102 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -22,6 +22,9 @@ from app.config import ( DEFAULT_LON, DEFAULT_PLACE, DWD_WMS_URL, + PRECIP_MAP_HOURS_DEFAULT, + PRECIP_MAP_SPAN_DEG, + PRECIP_MAP_STEP_DEG, RADAR_PRODUCT, RATE_LIMIT_PER_MINUTE, RAW_RADAR_DIR, @@ -40,9 +43,10 @@ from app.security import ( validate_security_config, verify_request_api_key, ) +from app.services.precipitation_map import get_precipitation_map from app.services.warnings import fetch_warnings_for_location from app.services.weather import get_forecast, get_observations -from app.services.wms import fetch_wms_time_steps +from app.services.wms import fetch_wms_time_steps, fetch_wms_time_steps_quick logging.basicConfig( level=logging.INFO, @@ -200,6 +204,22 @@ async def forecast( raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc +@app.get("/api/forecast/precipitation-map") +async def forecast_precipitation_map( + lat: float = Query(DEFAULT_LAT, ge=-90, le=90), + lon: float = Query(DEFAULT_LON, ge=-180, le=180), + hours: int = Query(PRECIP_MAP_HOURS_DEFAULT, ge=6, le=24), + span_deg: float = Query(PRECIP_MAP_SPAN_DEG, ge=1.5, le=6.0), + step_deg: float = Query(PRECIP_MAP_STEP_DEG, ge=0.15, le=0.5), +) -> dict[str, Any]: + try: + return await get_precipitation_map(lat, lon, hours=hours, span_deg=span_deg, step_deg=step_deg) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Precipitation map unavailable: {exc}") from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Precipitation map error: {exc}") from exc + + @app.get("/api/observations") async def observations( lat: float = Query(DEFAULT_LAT, ge=-90, le=90), @@ -291,8 +311,11 @@ async def radar_wms() -> dict[str, Any]: async def radar_wms_times( layer: str = Query("dwd:Niederschlagsradar"), minutes: int = Query(120, ge=15, le=180), + quick: bool = Query(False), ) -> dict[str, Any]: try: + if quick: + return await fetch_wms_time_steps_quick(layer, minutes=minutes) return await fetch_wms_time_steps(layer, minutes=minutes) except Exception as exc: raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc diff --git a/api/app/services/health.py b/api/app/services/health.py index 43eacae..aa11ebb 100644 --- a/api/app/services/health.py +++ b/api/app/services/health.py @@ -53,7 +53,15 @@ async def check_sources() -> list[dict[str, Any]]: _probe("open_meteo", f"{OPEN_METEO_BASE_URL}/forecast?latitude=52.5&longitude=13.4¤t=temperature_2m"), _probe("brightsky", f"{BRIGHTSKY_BASE_URL}/current_weather?lat=52.5&lon=13.4"), _probe("dwd_radar", f"{DWD_RADAR_BASE_URL}/rv/", timeout=4.0), - _probe("dwd_wms", f"{DWD_WMS_URL}?service=WMS&request=GetCapabilities", timeout=6.0), + _probe( + "dwd_wms", + ( + f"{DWD_WMS_URL}?service=WMS&version=1.1.1&request=GetMap" + "&layers=dwd:Niederschlagsradar&format=image/png&transparent=true" + "&width=64&height=64&srs=EPSG:4326&bbox=8,50,9,51" + ), + timeout=5.0, + ), _probe("dwd_warnings", DWD_WARNINGS_URL, timeout=4.0), ) results = list(probes) diff --git a/api/app/services/precipitation_map.py b/api/app/services/precipitation_map.py new file mode 100644 index 0000000..a3332cc --- /dev/null +++ b/api/app/services/precipitation_map.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any + +from app.cache import cache_get, cache_set +from app.config import CACHE_TTL_PRECIP_MAP, OPEN_METEO_BASE_URL +from app.http_client import fetch_json + +BATCH_SIZE = 80 +DEFAULT_SPAN_DEG = 3.0 +DEFAULT_STEP_DEG = 0.25 + + +def _coord_key(lat: float, lon: float) -> tuple[float, float]: + return round(lat, 2), round(lon, 2) + + +def _build_grid(center_lat: float, center_lon: float, span_deg: float, step_deg: float) -> list[tuple[float, float]]: + half = span_deg / 2 + points: list[tuple[float, float]] = [] + lat = center_lat - half + while lat <= center_lat + half + 1e-9: + lon = center_lon - half + while lon <= center_lon + half + 1e-9: + points.append((round(lat, 2), round(lon, 2))) + lon += step_deg + lat += step_deg + return points + + +async def _fetch_batch(lats: list[float], lons: list[float], hours: int) -> list[dict[str, Any]]: + data = await fetch_json( + f"{OPEN_METEO_BASE_URL}/forecast", + params={ + "latitude": ",".join(str(v) for v in lats), + "longitude": ",".join(str(v) for v in lons), + "hourly": "precipitation,precipitation_probability", + "forecast_hours": hours, + "timezone": "Europe/Berlin", + }, + timeout=30.0, + ) + if isinstance(data, list): + return data + return [data] + + +async def get_precipitation_map( + lat: float, + lon: float, + hours: int = 24, + span_deg: float = DEFAULT_SPAN_DEG, + step_deg: float = DEFAULT_STEP_DEG, +) -> dict[str, Any]: + hours = max(6, min(hours, 24)) + span_deg = max(1.5, min(span_deg, 6.0)) + step_deg = max(0.15, min(step_deg, 0.5)) + + lat_k, lon_k = _coord_key(lat, lon) + cache_key = f"precip_map:{lat_k}:{lon_k}:{hours}:{span_deg}:{step_deg}" + cached = await cache_get(cache_key) + if cached: + return {**cached, "cached": True} + + grid = _build_grid(lat, lon, span_deg, step_deg) + cells: list[dict[str, Any]] = [] + times: list[str] | None = None + + for offset in range(0, len(grid), BATCH_SIZE): + batch = grid[offset : offset + BATCH_SIZE] + lats = [point[0] for point in batch] + lons = [point[1] for point in batch] + results = await _fetch_batch(lats, lons, hours) + for (point_lat, point_lon), item in zip(batch, results, strict=False): + hourly = item.get("hourly") or {} + cell_times = hourly.get("time") or [] + if times is None: + times = cell_times + cells.append( + { + "lat": point_lat, + "lon": point_lon, + "precipitation": hourly.get("precipitation") or [0.0] * len(cell_times), + "probability": hourly.get("precipitation_probability") or [0] * len(cell_times), + } + ) + + payload = { + "source": OPEN_METEO_BASE_URL, + "model": "Open-Meteo ICON/DWD", + "center": {"lat": lat, "lon": lon}, + "span_deg": span_deg, + "step_deg": step_deg, + "hours": hours, + "times": times or [], + "cells": cells, + "cell_count": len(cells), + "note": "Modellvorhersage, keine Radarmessung. Auflösung grob (~25 km).", + "cached": False, + } + await cache_set(cache_key, payload, CACHE_TTL_PRECIP_MAP) + return payload diff --git a/api/app/services/warnings.py b/api/app/services/warnings.py index 049a036..69b30d7 100644 --- a/api/app/services/warnings.py +++ b/api/app/services/warnings.py @@ -98,6 +98,50 @@ async def fetch_all_warnings() -> list[dict[str, Any]]: return warnings +def _warning_identity_key(warning: dict[str, Any]) -> tuple[Any, ...]: + return ( + warning.get("event"), + warning.get("headline"), + warning.get("level"), + warning.get("start"), + warning.get("end"), + warning.get("description"), + warning.get("instruction"), + ) + + +def _dedupe_warnings(warnings: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: dict[tuple[Any, ...], dict[str, Any]] = {} + for warning in warnings: + key = _warning_identity_key(warning) + region = warning.get("region_name") + if key not in merged: + merged[key] = { + **warning, + "_cell_ids": [warning["cell_id"]], + "_regions": [region] if region else [], + } + continue + entry = merged[key] + if warning["cell_id"] not in entry["_cell_ids"]: + entry["_cell_ids"].append(warning["cell_id"]) + if region and region not in entry["_regions"]: + entry["_regions"].append(region) + + result: list[dict[str, Any]] = [] + for entry in merged.values(): + regions = entry.pop("_regions", []) + cell_ids = entry.pop("_cell_ids", [entry.get("cell_id")]) + if len(regions) > 1: + entry["region_name"] = " · ".join(regions) + elif regions: + entry["region_name"] = regions[0] + entry["cell_id"] = cell_ids[0] if len(cell_ids) == 1 else ",".join(cell_ids) + result.append(entry) + result.sort(key=lambda w: (-w["level"], w.get("start") or "")) + return result + + async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]: warnings = await fetch_all_warnings() warncells = await resolve_warncells(lat, lon) @@ -111,15 +155,16 @@ async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = ] if local_only: - result_warnings = local + result_warnings = _dedupe_warnings(local) else: - result_warnings = warnings[:100] + result_warnings = _dedupe_warnings(warnings[:100]) return { "lat": lat, "lon": lon, "count_total": len(warnings), - "count_local": len(local), + "count_local": len(result_warnings), + "count_matched_raw": len(local), "warnings": result_warnings, "warncells": warncells.get("areas", []), "matched_cell_ids": sorted(cell_ids), diff --git a/api/app/services/wms.py b/api/app/services/wms.py index 71101bd..7372424 100644 --- a/api/app/services/wms.py +++ b/api/app/services/wms.py @@ -1,14 +1,18 @@ from __future__ import annotations +import logging import re from datetime import datetime, timedelta, timezone from xml.etree import ElementTree as ET from app.cache import cache_get, cache_set -from app.http_client import get_client from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL +from app.http_client import get_client -NS = {"wms": "http://www.opengis.net/wms"} +logger = logging.getLogger(__name__) + +WMS_NS = "http://www.opengis.net/wms" +WMS_TAG = f"{{{WMS_NS}}}" def _parse_iso_duration_minutes(duration: str) -> int: @@ -18,10 +22,18 @@ def _parse_iso_duration_minutes(duration: str) -> int: return int(match.group(1)) +def _parse_wms_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _format_wms_datetime(value: datetime) -> str: + return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + def _parse_time_dimension(value: str) -> tuple[datetime, datetime, int]: start_raw, end_raw, step_raw = value.split("/") - start = datetime.fromisoformat(start_raw.replace("Z", "+00:00")) - end = datetime.fromisoformat(end_raw.replace("Z", "+00:00")) + start = _parse_wms_datetime(start_raw) + end = _parse_wms_datetime(end_raw) step_minutes = _parse_iso_duration_minutes(step_raw) return start, end, step_minutes @@ -31,63 +43,155 @@ def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> l current = start delta = timedelta(minutes=step_minutes) while current <= end: - steps.append(current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")) + steps.append(_format_wms_datetime(current)) current += delta return steps -def _find_layer_dimension(xml_text: str, layer_name: str) -> str | None: +def _align_down(value: datetime, step_minutes: int) -> datetime: + aligned = value.replace(second=0, microsecond=0) + return aligned - timedelta(minutes=aligned.minute % step_minutes) + + +def _layer_name_matches(requested: str, actual: str | None) -> bool: + if not actual: + return False + if requested == actual: + return True + return requested.split(":")[-1] == actual + + +def _find_layer_times(xml_text: str, layer_name: str) -> tuple[str | None, int, datetime | None]: root = ET.fromstring(xml_text) - target = layer_name.split(":")[-1] + time_dimension: str | None = None + step_minutes = 5 + latest_available: datetime | None = None + for layer in root.iter(): if not layer.tag.endswith("Layer"): continue - name_el = layer.find("wms:Name", NS) or layer.find("Name") - if name_el is None or name_el.text != target: + name_el = layer.find(f"{WMS_TAG}Name") + if not _layer_name_matches(layer_name, name_el.text if name_el is not None else None): continue for dim in layer: - if dim.tag.endswith("Dimension") and (dim.attrib.get("name") or "").lower() == "time": - return (dim.text or "").strip() - return None + if not dim.tag.endswith("Dimension"): + continue + dim_name = (dim.attrib.get("name") or "").lower() + if dim_name == "time": + time_dimension = (dim.text or "").strip() or None + if time_dimension and "/" in time_dimension: + _, _, step_raw = time_dimension.split("/") + step_minutes = _parse_iso_duration_minutes(step_raw) + elif dim_name == "reference_time": + default = (dim.attrib.get("default") or "").strip() + if default: + try: + latest_available = _parse_wms_datetime(default) + except ValueError: + pass + if time_dimension: + return time_dimension, step_minutes, latest_available + return None, step_minutes, latest_available + + +def _filter_time_steps( + all_steps: list[str], + minutes: int, + latest_available: datetime | None = None, + step_minutes: int = 5, +) -> list[str]: + now = _align_down(datetime.now(timezone.utc), step_minutes) + upper = latest_available or now + if upper > now: + upper = now + cutoff = now - timedelta(minutes=minutes) + + filtered: list[str] = [] + for step in all_steps: + try: + ts = _parse_wms_datetime(step) + except ValueError: + continue + if cutoff <= ts <= upper: + filtered.append(step) + return filtered + + +def _fallback_time_steps(minutes: int, step_minutes: int = 5) -> list[str]: + now = _align_down(datetime.now(timezone.utc), step_minutes) + start = now - timedelta(minutes=minutes) + return _generate_time_steps(start, now, step_minutes) + + +def _steps_from_dimension(dimension: str) -> list[str]: + if "/" in dimension: + start, end, step_minutes = _parse_time_dimension(dimension) + return _generate_time_steps(start, end, step_minutes) + return [item.strip() for item in dimension.split(",") if item.strip()] + + +async def fetch_wms_time_steps_quick(layer: str, minutes: int = 120) -> dict: + step_minutes = 5 + filtered = _fallback_time_steps(minutes, step_minutes) + return { + "layer": layer, + "minutes": minutes, + "step_minutes": step_minutes, + "count": len(filtered), + "times": filtered, + "latest": filtered[-1] if filtered else None, + "latest_available": filtered[-1] if filtered else None, + "source": "fallback", + "partial": True, + } async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict: - cache_key = f"wms:times:{layer}:{minutes}" + cache_key = f"wms:times:v2:{layer}:{minutes}" cached = await cache_get(cache_key) if cached: return cached - params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"} - client = get_client() - response = await client.get(DWD_WMS_URL, params=params, timeout=15.0) - response.raise_for_status() - dimension = _find_layer_dimension(response.text, layer) - if not dimension: - raise ValueError(f"No TIME dimension found for layer {layer}") + step_minutes = 5 + latest_available: datetime | None = None + all_steps: list[str] = [] + source = "capabilities" - if "/" in dimension: - start, end, step_minutes = _parse_time_dimension(dimension) - all_steps = _generate_time_steps(start, end, step_minutes) - else: - all_steps = [item.strip() for item in dimension.split(",") if item.strip()] + try: + params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"} + client = get_client() + response = await client.get(DWD_WMS_URL, params=params, timeout=20.0) + response.raise_for_status() + dimension, step_minutes, latest_available = _find_layer_times(response.text, layer) + if dimension: + all_steps = _steps_from_dimension(dimension) + else: + source = "fallback" + logger.warning("No TIME dimension in WMS capabilities for layer %s", layer) + except Exception as exc: + source = "fallback" + logger.warning("WMS GetCapabilities failed for layer %s: %s", layer, exc) - cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes) - filtered = [] - for step in all_steps: - try: - ts = datetime.fromisoformat(step.replace("Z", "+00:00")) - except ValueError: - continue - if ts >= cutoff: - filtered.append(step) + if not all_steps: + all_steps = _fallback_time_steps(minutes, step_minutes) + source = "fallback" + + filtered = _filter_time_steps(all_steps, minutes, latest_available, step_minutes) + if not filtered: + filtered = _fallback_time_steps(minutes, step_minutes) + source = "fallback" payload = { "layer": layer, "minutes": minutes, - "step_minutes": _parse_iso_duration_minutes("PT5M") if "/" in dimension else 5, + "step_minutes": step_minutes, "count": len(filtered), "times": filtered, "latest": filtered[-1] if filtered else None, + "latest_available": _format_wms_datetime(latest_available) if latest_available else None, + "source": source, + "partial": False, + "note": "Niederschlagsradar zeigt vergangene Messungen, keine Vorhersage in die Zukunft.", } await cache_set(cache_key, payload, CACHE_TTL_WMS_TIMES) return payload diff --git a/frontend/app.js b/frontend/app.js index 562368e..dc2fd8e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -14,6 +14,9 @@ let radarTimes = []; let radarFrameIndex = 0; let radarPlayTimer = null; let radarPlaying = false; +let mapMode = "radar"; +let precipData = null; +let precipLayerGroup = null; let hourlyChart = null; let settings = loadSettings(); let searchTimer = null; @@ -27,8 +30,8 @@ const WEATHER_ICONS = { function loadSettings() { try { - return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") }; - } catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120 }; } + return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, forecastHours: 24, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") }; + } catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, forecastHours: 24 }; } } function saveSettings() { @@ -43,7 +46,11 @@ function saveFavorites(items) { localStorage.setItem(FAV_KEY, JSON.stringify(items)); } -function weatherText(code) { +function weatherIcon(code) { + return WEATHER_ICONS[code] || "🌡️"; +} + +function weatherLabel(code) { const map = { 0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", 45: "Nebel", 48: "Reifnebel", 51: "Leichter Niesel", 53: "Niesel", @@ -51,7 +58,11 @@ function weatherText(code) { 71: "Leichter Schnee", 73: "Schnee", 75: "Starker Schnee", 80: "Regenschauer", 81: "Starke Schauer", 82: "Heftige Schauer", 95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter", }; - return `${WEATHER_ICONS[code] || ""} ${map[code] || `Code ${code}`}`.trim(); + return map[code] || `Code ${code}`; +} + +function weatherText(code) { + return `${weatherIcon(code)} ${weatherLabel(code)}`.trim(); } function convertTemp(c) { @@ -270,6 +281,7 @@ function applyDashboard(dash) { renderDaily(dash.forecast.data.daily); renderHourly(dash.forecast.data.hourly); renderHourStrip(dash.forecast.data.hourly); + renderForecastOutlook(dash.forecast.data.hourly); } if (dash.observations?.data) { @@ -281,23 +293,43 @@ function applyDashboard(dash) { } } - if (dash.radar) { - el("radarCount").textContent = `${dash.radar.count} online`; - el("radarMeta").textContent = `Produkt ${dash.radar.product?.toUpperCase() || "RV"} · DWD OpenData`; - } - if (dash.warnings) { applyWarningsData(dash.warnings); } } +function renderForecastOutlook(hourly) { + if (!hourly?.time?.length) { + el("rainOutlookBig").textContent = "–"; + el("rainOutlookMeta").textContent = "Kein Forecast"; + return; + } + + let nextRainIdx = -1; + let maxProb = 0; + hourly.time.slice(0, 24).forEach((_, idx) => { + const prob = hourly.precipitation_probability[idx] ?? 0; + const precip = hourly.precipitation[idx] ?? 0; + if (nextRainIdx === -1 && (prob >= 40 || precip >= 0.15)) nextRainIdx = idx; + maxProb = Math.max(maxProb, prob); + }); + + if (nextRainIdx >= 0) { + el("rainOutlookBig").textContent = formatDateTime(hourly.time[nextRainIdx]); + el("rainOutlookMeta").textContent = `${hourly.precipitation_probability[nextRainIdx] ?? 0}% · ${hourly.precipitation[nextRainIdx] ?? 0} mm/h`; + } else { + el("rainOutlookBig").textContent = "Trocken"; + el("rainOutlookMeta").textContent = `Max. ${maxProb}% in 24 h`; + } +} + function applyWarningsData(data) { const localOnly = el("warningsLocalOnly")?.checked ?? true; const warnings = data.warnings || []; el("warnCount").textContent = String(data.count_local ?? warnings.length); el("warnMeta").textContent = localOnly - ? `${warnings.length} Warnung(en) für deinen Standort` - : `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`; + ? `${data.count_local ?? warnings.length} Warnung(en) für deinen Standort` + : `${data.count_total} Warnungen bundesweit (${data.count_local ?? warnings.length} eindeutig)`; const warncellEl = el("warncellInfo"); if (localOnly && data.warncells?.length) { @@ -342,22 +374,75 @@ async function loadForecast() { renderDaily(data.data.daily); renderHourly(data.data.hourly); renderHourStrip(data.data.hourly); + renderForecastOutlook(data.data.hourly); +} + +function dailyLabel(dateStr) { + const d = new Date(`${dateStr}T12:00:00`); + const today = new Date(); + today.setHours(12, 0, 0, 0); + const diff = Math.round((d - today) / 86400000); + if (diff === 0) return "Heute"; + if (diff === 1) return "Morgen"; + return d.toLocaleDateString("de-DE", { weekday: "long" }); } function renderDaily(daily) { + if (!daily?.time?.length) { + el("dailyForecast").innerHTML = `

Kein Forecast verfügbar.

`; + return; + } + + const weekMin = Math.min(...daily.temperature_2m_min); + const weekMax = Math.max(...daily.temperature_2m_max); + const tempSpan = Math.max(weekMax - weekMin, 1); + el("dailyForecast").innerHTML = daily.time.map((date, idx) => { - const d = new Date(date + "T12:00:00"); - const day = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); - const sunrise = daily.sunrise?.[idx] ? new Date(daily.sunrise[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "–"; - const sunset = daily.sunset?.[idx] ? new Date(daily.sunset[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "–"; - return `
- ${day} - ${weatherText(daily.weather_code[idx])} -

${convertTemp(daily.temperature_2m_max[idx])} / ${convertTemp(daily.temperature_2m_min[idx])}

- Regen: ${daily.precipitation_sum[idx]} mm · ${daily.precipitation_probability_max[idx] ?? 0}% - Wind: ${convertWind(daily.wind_speed_10m_max[idx] ?? 0)} · Böen ${convertWind(daily.wind_gusts_10m_max[idx] ?? 0)} - Sonne: ${sunrise} – ${sunset} -
`; + const d = new Date(`${date}T12:00:00`); + const label = dailyLabel(date); + const dateLine = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); + const code = daily.weather_code[idx]; + const tMax = daily.temperature_2m_max[idx]; + const tMin = daily.temperature_2m_min[idx]; + const rainProb = daily.precipitation_probability_max[idx] ?? 0; + const rainMm = daily.precipitation_sum[idx] ?? 0; + const wind = daily.wind_speed_10m_max[idx] ?? 0; + const gusts = daily.wind_gusts_10m_max[idx] ?? 0; + const sunrise = daily.sunrise?.[idx] + ? new Date(daily.sunrise[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + : "–"; + const sunset = daily.sunset?.[idx] + ? new Date(daily.sunset[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + : "–"; + + const barLeft = ((tMin - weekMin) / tempSpan) * 100; + const barWidth = Math.max(((tMax - tMin) / tempSpan) * 100, 10); + const isToday = idx === 0; + const rainClass = rainProb >= 60 || rainMm >= 5 ? "rain-high" : rainProb >= 30 || rainMm >= 1 ? "rain-mid" : ""; + + return `
+
+ ${label} + ${dateLine} +
+ +

${weatherLabel(code)}

+
+ ${convertTemp(tMax)} +
+ +
+ ${convertTemp(tMin)} +
+
+
+ ${rainProb}% · ${rainMm} mm +
+ +
`; }).join(""); } @@ -419,28 +504,174 @@ async function loadWarnings() { } } +function setMapMode(mode) { + mapMode = mode; + document.querySelectorAll(".map-mode-btn").forEach((btn) => btn.classList.toggle("active", btn.dataset.mapMode === mode)); + document.querySelectorAll(".map-mode-radar-only").forEach((node) => node.classList.toggle("hidden", mode !== "radar")); + document.querySelectorAll(".map-mode-forecast-only").forEach((node) => node.classList.toggle("hidden", mode !== "forecast")); + el("timelineTitle").textContent = mode === "radar" ? "Radar-Zeitleiste" : "Vorhersage-Zeitleiste"; + el("timelineHint").textContent = mode === "radar" + ? "Vergangenheit · 5-Min-Schritte · DWD-Radar" + : "Zukunft · stündlich · Open-Meteo-Modell (~25 km)"; + + if (!map) return; + if (mode === "radar") { + clearPrecipLayer(); + if (radarLayer && !map.hasLayer(radarLayer)) radarLayer.addTo(map); + showRadarLatest(); + void loadRadarTimes(); + } else { + if (radarLayer && map.hasLayer(radarLayer)) map.removeLayer(radarLayer); + loadPrecipitationMap(); + } +} + +function precipColor(mm, prob) { + const score = Math.max(Number(mm) || 0, ((Number(prob) || 0) / 100) * 2.5); + if (score < 0.08) return null; + if (score < 0.35) return "rgba(57, 120, 255, 0.38)"; + if (score < 1) return "rgba(57, 120, 255, 0.58)"; + if (score < 2.5) return "rgba(255, 81, 249, 0.55)"; + return "rgba(255, 40, 120, 0.72)"; +} + +function clearPrecipLayer() { + if (precipLayerGroup && map) { + map.removeLayer(precipLayerGroup); + precipLayerGroup = null; + } +} + +function renderPrecipLayer(hourIndex) { + if (!map || !precipData?.cells?.length) return; + clearPrecipLayer(); + precipLayerGroup = L.layerGroup().addTo(map); + const half = (precipData.step_deg || 0.25) / 2; + precipData.cells.forEach((cell) => { + const color = precipColor(cell.precipitation[hourIndex], cell.probability[hourIndex]); + if (!color) return; + L.rectangle([[cell.lat - half, cell.lon - half], [cell.lat + half, cell.lon + half]], { + color: "transparent", + fillColor: color, + fillOpacity: 1, + weight: 0, + interactive: false, + }).addTo(precipLayerGroup); + }); +} + +async function loadPrecipitationMap() { + const loc = getLocation(); + const hours = settings.forecastHours || 24; + el("mapMeta").textContent = "Lade Regenvorhersage…"; + try { + const data = await api(`/api/forecast/precipitation-map?lat=${loc.lat}&lon=${loc.lon}&hours=${hours}`); + precipData = data; + radarTimes = data.times || []; + el("radarSlider").max = Math.max(0, radarTimes.length - 1); + el("mapMeta").textContent = `Regenvorhersage · ${data.cell_count} Felder · ${hours} h${data.cached ? " · Cache" : ""}`; + if (radarTimes.length) { + setRadarTime(0); + el("radarFrameInfo").textContent = `${radarTimes.length} Stunden · Modell Open-Meteo`; + } else { + el("radarTimeLabel").textContent = "Keine Vorhersagedaten"; + } + } catch (err) { + el("mapMeta").textContent = `Vorhersage: ${err.message}`; + el("radarTimeLabel").textContent = err.message; + } +} + +function applyRadarWmsTime(time, isLatest) { + if (!radarLayer) return; + if (isLatest) { + delete radarLayer.wmsParams.TIME; + delete radarLayer.wmsParams.time; + } else { + radarLayer.setParams({ TIME: time }); + } + radarLayer.setParams({ _cache: Date.now() }); + if (radarLayer._map) radarLayer.redraw(); +} + +function showRadarLatest() { + if (!radarLayer || mapMode !== "radar" || !map) return; + applyRadarWmsTime(null, true); + if (radarTimes.length) { + radarFrameIndex = radarTimes.length - 1; + el("radarSlider").value = radarFrameIndex; + el("radarTimeLabel").textContent = `Aktuell · ${formatDateTime(radarTimes[radarFrameIndex])}`; + } else { + el("radarTimeLabel").textContent = "Aktuell"; + el("radarFrameInfo").textContent = "Lade Verlauf…"; + } +} + function setRadarTime(index) { if (!radarTimes.length) return; radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); const time = radarTimes[radarFrameIndex]; + const isLatest = mapMode === "radar" && radarFrameIndex === radarTimes.length - 1; el("radarSlider").value = radarFrameIndex; - el("radarTimeLabel").textContent = formatDateTime(time); - el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; + el("radarTimeLabel").textContent = isLatest + ? `Aktuell · ${formatDateTime(time)}` + : formatDateTime(time); + el("radarFrameInfo").textContent = mapMode === "forecast" + ? `Stunde ${radarFrameIndex + 1} / ${radarTimes.length} · +${radarFrameIndex} h` + : `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; - if (radarLayer) { - radarLayer.setParams({ TIME: time, _cache: Date.now() }); + if (mapMode === "forecast") { + renderPrecipLayer(radarFrameIndex); + } else if (radarLayer) { + applyRadarWmsTime(time, isLatest); } } +function applyRadarTimeline(data, { previousTime = null } = {}) { + radarTimes = data.times || []; + el("radarSlider").max = Math.max(0, radarTimes.length - 1); + if (!radarTimes.length) { + el("radarTimeLabel").textContent = "Keine Zeitschritte"; + return; + } + + if (previousTime) { + const idx = radarTimes.indexOf(previousTime); + if (idx >= 0) { + setRadarTime(idx); + return; + } + } + + radarFrameIndex = radarTimes.length - 1; + el("radarSlider").value = radarFrameIndex; + el("radarTimeLabel").textContent = `Aktuell · ${formatDateTime(data.latest || radarTimes[radarFrameIndex])}`; + el("radarFrameInfo").textContent = data.partial + ? `${radarTimes.length} Frames · Verlauf wird verfeinert…` + : `${radarTimes.length} Frames · bis ${formatDateTime(data.latest)}`; + applyRadarWmsTime(radarTimes[radarFrameIndex], true); +} + async function loadRadarTimes() { + if (mapMode !== "radar") return; const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const minutes = settings.radarMinutes || 120; + const previousTime = radarTimes.length && radarFrameIndex < radarTimes.length - 1 + ? radarTimes[radarFrameIndex] + : null; + try { - const data = await api(`/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`); - radarTimes = data.times || []; - el("radarSlider").max = Math.max(0, radarTimes.length - 1); - if (radarTimes.length) setRadarTime(radarTimes.length - 1); - else el("radarTimeLabel").textContent = "Keine Zeitschritte"; + const quickData = await api( + `/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}&quick=1`, + ); + applyRadarTimeline(quickData, { previousTime }); + + const fullData = await api( + `/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`, + ); + applyRadarTimeline(fullData, { + previousTime: radarFrameIndex < radarTimes.length - 1 ? radarTimes[radarFrameIndex] : null, + }); } catch (err) { el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`; } @@ -494,7 +725,8 @@ async function initMap() { }); layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map); el("mapMeta").textContent = "DWD Radar + Warnungen aktiv"; - await loadRadarTimes(); + showRadarLatest(); + void loadRadarTimes(); } catch (err) { el("mapMeta").textContent = `Karte: ${err.message}`; } @@ -508,10 +740,15 @@ function updateMapLocation() { } function reloadRadarLayers() { + if (mapMode === "forecast") { + loadPrecipitationMap(); + return; + } wmsLayers.forEach((layer) => { if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() }); }); - loadRadarTimes(); + showRadarLatest(); + void loadRadarTimes(); } function toggleWarningsOnMap() { @@ -598,6 +835,7 @@ function applySettingsToUI() { el("unitTemp").value = settings.unitTemp; el("unitWind").value = settings.unitWind; el("radarMinutes").value = String(settings.radarMinutes); + el("forecastHours").value = String(settings.forecastHours || 24); applyTheme(); } @@ -636,14 +874,12 @@ async function loadAll() { await loadDashboard(); } catch { await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]); - const radar = await api("/api/radar/latest?limit=12").catch(() => null); - if (radar) { - el("radarCount").textContent = `${radar.count} online`; - el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`; - } } updateMapLocation(); - if (map) loadRadarTimes(); + if (map) { + if (mapMode === "forecast") loadPrecipitationMap(); + else loadRadarTimes(); + } } async function init() { @@ -655,6 +891,9 @@ async function init() { } document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); +document.querySelectorAll(".map-mode-btn").forEach((btn) => { + btn.addEventListener("click", () => setMapMode(btn.dataset.mapMode)); +}); el("loadBtn").addEventListener("click", loadAll); el("centerMapBtn").addEventListener("click", updateMapLocation); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); @@ -684,20 +923,22 @@ el("themeToggle").addEventListener("click", () => { if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly); }); -["unitTemp", "unitWind", "radarMinutes"].forEach((id) => { +["unitTemp", "unitWind", "radarMinutes", "forecastHours"].forEach((id) => { el(id).addEventListener("change", (e) => { - const key = id === "radarMinutes" ? "radarMinutes" : id.replace("unit", "unit").replace("Temp", "Temp").replace("Wind", "Wind"); if (id === "unitTemp") settings.unitTemp = e.target.value; if (id === "unitWind") settings.unitWind = e.target.value; if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); + if (id === "forecastHours") settings.forecastHours = Number(e.target.value); saveSettings(); if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); + renderForecastOutlook(latestForecast.data.hourly); loadForecast(); } - if (id === "radarMinutes" && map) loadRadarTimes(); + if (id === "radarMinutes" && map && mapMode === "radar") loadRadarTimes(); + if (id === "forecastHours" && map && mapMode === "forecast") loadPrecipitationMap(); }); }); diff --git a/frontend/index.html b/frontend/index.html index eacbfa8..b60c58a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -69,7 +69,7 @@