HexaWetter v1.3.0: Add precipitation map feature, update README, and enhance UI elements

This commit is contained in:
TheOnlyMace
2026-06-19 00:32:22 +02:00
parent 3589f7fdcf
commit c8bf80a2c8
11 changed files with 867 additions and 103 deletions

View File

@@ -38,6 +38,12 @@ CACHE_TTL_GEOCODE=86400
CACHE_TTL_WMS_TIMES=120 CACHE_TTL_WMS_TIMES=120
CACHE_TTL_HEALTH_SOURCES=90 CACHE_TTL_HEALTH_SOURCES=90
CACHE_TTL_DASHBOARD=60 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') # Security (generate with: python scripts/setup_secrets.py 'your-secure-password')
API_KEY= API_KEY=

View File

@@ -13,6 +13,7 @@ cd HexaWetter
- **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast - **Dashboard**: Aktuelles Wetter, DWD-Station, 48h-Stundenansicht, 7-Tage-Forecast
- **Radar-Karte**: Leaflet + OpenStreetMap + DWD WMS (Niederschlagsradar, Warnungen) - **Radar-Karte**: Leaflet + OpenStreetMap + DWD WMS (Niederschlagsradar, Warnungen)
- **Regenvorhersage-Karte**: Open-Meteo-Modell als Kartenoverlay (624 h, stündlich)
- **Radar-Animation**: Zeitleiste (60120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück - **Radar-Animation**: Zeitleiste (60120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück
- **Warnungen**: DWD-Warnungen mit Standortfilter (Landkreis-Matching) - **Warnungen**: DWD-Warnungen mit Standortfilter (Landkreis-Matching)
- **Ortssuche**: PLZ/Ort via Nominatim-Proxy, GPS, Favoriten - **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/health` | Status inkl. Quellen-Healthchecks |
| `GET /api/forecast` | 7-Tage-Forecast | | `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/observations` | DWD-Beobachtung (Bright Sky) |
| `GET /api/warnings` | DWD-Warnungen (standortbezogen) | | `GET /api/warnings` | DWD-Warnungen (standortbezogen) |
| `GET /api/geocode/search` | Ortssuche | | `GET /api/geocode/search` | Ortssuche |

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path from pathlib import Path
APP_NAME = "HexaWetter" APP_NAME = "HexaWetter"
APP_VERSION = "1.2.0" APP_VERSION = "1.3.0"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar" 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_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120"))
CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90")) CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90"))
CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60")) 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")) RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))

View File

@@ -22,6 +22,9 @@ from app.config import (
DEFAULT_LON, DEFAULT_LON,
DEFAULT_PLACE, DEFAULT_PLACE,
DWD_WMS_URL, DWD_WMS_URL,
PRECIP_MAP_HOURS_DEFAULT,
PRECIP_MAP_SPAN_DEG,
PRECIP_MAP_STEP_DEG,
RADAR_PRODUCT, RADAR_PRODUCT,
RATE_LIMIT_PER_MINUTE, RATE_LIMIT_PER_MINUTE,
RAW_RADAR_DIR, RAW_RADAR_DIR,
@@ -40,9 +43,10 @@ from app.security import (
validate_security_config, validate_security_config,
verify_request_api_key, 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.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -200,6 +204,22 @@ async def forecast(
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc 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") @app.get("/api/observations")
async def observations( async def observations(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90), 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( async def radar_wms_times(
layer: str = Query("dwd:Niederschlagsradar"), layer: str = Query("dwd:Niederschlagsradar"),
minutes: int = Query(120, ge=15, le=180), minutes: int = Query(120, ge=15, le=180),
quick: bool = Query(False),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
if quick:
return await fetch_wms_time_steps_quick(layer, minutes=minutes)
return await fetch_wms_time_steps(layer, minutes=minutes) return await fetch_wms_time_steps(layer, minutes=minutes)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc

View File

@@ -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&current=temperature_2m"), _probe("open_meteo", f"{OPEN_METEO_BASE_URL}/forecast?latitude=52.5&longitude=13.4&current=temperature_2m"),
_probe("brightsky", f"{BRIGHTSKY_BASE_URL}/current_weather?lat=52.5&lon=13.4"), _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_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), _probe("dwd_warnings", DWD_WARNINGS_URL, timeout=4.0),
) )
results = list(probes) results = list(probes)

View File

@@ -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

View File

@@ -98,6 +98,50 @@ async def fetch_all_warnings() -> list[dict[str, Any]]:
return warnings 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]: async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
warnings = await fetch_all_warnings() warnings = await fetch_all_warnings()
warncells = await resolve_warncells(lat, lon) 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: if local_only:
result_warnings = local result_warnings = _dedupe_warnings(local)
else: else:
result_warnings = warnings[:100] result_warnings = _dedupe_warnings(warnings[:100])
return { return {
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"count_total": len(warnings), "count_total": len(warnings),
"count_local": len(local), "count_local": len(result_warnings),
"count_matched_raw": len(local),
"warnings": result_warnings, "warnings": result_warnings,
"warncells": warncells.get("areas", []), "warncells": warncells.get("areas", []),
"matched_cell_ids": sorted(cell_ids), "matched_cell_ids": sorted(cell_ids),

View File

@@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
import logging
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from app.cache import cache_get, cache_set 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.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: 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)) 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]: def _parse_time_dimension(value: str) -> tuple[datetime, datetime, int]:
start_raw, end_raw, step_raw = value.split("/") start_raw, end_raw, step_raw = value.split("/")
start = datetime.fromisoformat(start_raw.replace("Z", "+00:00")) start = _parse_wms_datetime(start_raw)
end = datetime.fromisoformat(end_raw.replace("Z", "+00:00")) end = _parse_wms_datetime(end_raw)
step_minutes = _parse_iso_duration_minutes(step_raw) step_minutes = _parse_iso_duration_minutes(step_raw)
return start, end, step_minutes return start, end, step_minutes
@@ -31,63 +43,155 @@ def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> l
current = start current = start
delta = timedelta(minutes=step_minutes) delta = timedelta(minutes=step_minutes)
while current <= end: 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 current += delta
return steps 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) 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(): for layer in root.iter():
if not layer.tag.endswith("Layer"): if not layer.tag.endswith("Layer"):
continue continue
name_el = layer.find("wms:Name", NS) or layer.find("Name") name_el = layer.find(f"{WMS_TAG}Name")
if name_el is None or name_el.text != target: if not _layer_name_matches(layer_name, name_el.text if name_el is not None else None):
continue continue
for dim in layer: for dim in layer:
if dim.tag.endswith("Dimension") and (dim.attrib.get("name") or "").lower() == "time": if not dim.tag.endswith("Dimension"):
return (dim.text or "").strip() continue
return None 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: 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) cached = await cache_get(cache_key)
if cached: if cached:
return cached return cached
step_minutes = 5
latest_available: datetime | None = None
all_steps: list[str] = []
source = "capabilities"
try:
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"} params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
client = get_client() client = get_client()
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0) response = await client.get(DWD_WMS_URL, params=params, timeout=20.0)
response.raise_for_status() response.raise_for_status()
dimension = _find_layer_dimension(response.text, layer) dimension, step_minutes, latest_available = _find_layer_times(response.text, layer)
if not dimension: if dimension:
raise ValueError(f"No TIME dimension found for layer {layer}") all_steps = _steps_from_dimension(dimension)
if "/" in dimension:
start, end, step_minutes = _parse_time_dimension(dimension)
all_steps = _generate_time_steps(start, end, step_minutes)
else: else:
all_steps = [item.strip() for item in dimension.split(",") if item.strip()] 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) if not all_steps:
filtered = [] all_steps = _fallback_time_steps(minutes, step_minutes)
for step in all_steps: source = "fallback"
try:
ts = datetime.fromisoformat(step.replace("Z", "+00:00")) filtered = _filter_time_steps(all_steps, minutes, latest_available, step_minutes)
except ValueError: if not filtered:
continue filtered = _fallback_time_steps(minutes, step_minutes)
if ts >= cutoff: source = "fallback"
filtered.append(step)
payload = { payload = {
"layer": layer, "layer": layer,
"minutes": minutes, "minutes": minutes,
"step_minutes": _parse_iso_duration_minutes("PT5M") if "/" in dimension else 5, "step_minutes": step_minutes,
"count": len(filtered), "count": len(filtered),
"times": filtered, "times": filtered,
"latest": filtered[-1] if filtered else None, "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) await cache_set(cache_key, payload, CACHE_TTL_WMS_TIMES)
return payload return payload

View File

@@ -14,6 +14,9 @@ let radarTimes = [];
let radarFrameIndex = 0; let radarFrameIndex = 0;
let radarPlayTimer = null; let radarPlayTimer = null;
let radarPlaying = false; let radarPlaying = false;
let mapMode = "radar";
let precipData = null;
let precipLayerGroup = null;
let hourlyChart = null; let hourlyChart = null;
let settings = loadSettings(); let settings = loadSettings();
let searchTimer = null; let searchTimer = null;
@@ -27,8 +30,8 @@ const WEATHER_ICONS = {
function loadSettings() { function loadSettings() {
try { try {
return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") }; 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 }; } } catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, forecastHours: 24 }; }
} }
function saveSettings() { function saveSettings() {
@@ -43,7 +46,11 @@ function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(items)); localStorage.setItem(FAV_KEY, JSON.stringify(items));
} }
function weatherText(code) { function weatherIcon(code) {
return WEATHER_ICONS[code] || "🌡️";
}
function weatherLabel(code) {
const map = { const map = {
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", 0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt",
45: "Nebel", 48: "Reifnebel", 51: "Leichter Niesel", 53: "Niesel", 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", 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", 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) { function convertTemp(c) {
@@ -270,6 +281,7 @@ function applyDashboard(dash) {
renderDaily(dash.forecast.data.daily); renderDaily(dash.forecast.data.daily);
renderHourly(dash.forecast.data.hourly); renderHourly(dash.forecast.data.hourly);
renderHourStrip(dash.forecast.data.hourly); renderHourStrip(dash.forecast.data.hourly);
renderForecastOutlook(dash.forecast.data.hourly);
} }
if (dash.observations?.data) { 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) { if (dash.warnings) {
applyWarningsData(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) { function applyWarningsData(data) {
const localOnly = el("warningsLocalOnly")?.checked ?? true; const localOnly = el("warningsLocalOnly")?.checked ?? true;
const warnings = data.warnings || []; const warnings = data.warnings || [];
el("warnCount").textContent = String(data.count_local ?? warnings.length); el("warnCount").textContent = String(data.count_local ?? warnings.length);
el("warnMeta").textContent = localOnly el("warnMeta").textContent = localOnly
? `${warnings.length} Warnung(en) für deinen Standort` ? `${data.count_local ?? warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`; : `${data.count_total} Warnungen bundesweit (${data.count_local ?? warnings.length} eindeutig)`;
const warncellEl = el("warncellInfo"); const warncellEl = el("warncellInfo");
if (localOnly && data.warncells?.length) { if (localOnly && data.warncells?.length) {
@@ -342,22 +374,75 @@ async function loadForecast() {
renderDaily(data.data.daily); renderDaily(data.data.daily);
renderHourly(data.data.hourly); renderHourly(data.data.hourly);
renderHourStrip(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) { function renderDaily(daily) {
if (!daily?.time?.length) {
el("dailyForecast").innerHTML = `<p class="muted">Kein Forecast verfügbar.</p>`;
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) => { el("dailyForecast").innerHTML = daily.time.map((date, idx) => {
const d = new Date(date + "T12:00:00"); const d = new Date(`${date}T12:00:00`);
const day = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); const label = dailyLabel(date);
const sunrise = daily.sunrise?.[idx] ? new Date(daily.sunrise[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : ""; const dateLine = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
const sunset = daily.sunset?.[idx] ? new Date(daily.sunset[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : ""; const code = daily.weather_code[idx];
return `<div class="day"> const tMax = daily.temperature_2m_max[idx];
<strong>${day}</strong> const tMin = daily.temperature_2m_min[idx];
<span class="muted">${weatherText(daily.weather_code[idx])}</span> const rainProb = daily.precipitation_probability_max[idx] ?? 0;
<p class="temp">${convertTemp(daily.temperature_2m_max[idx])} / ${convertTemp(daily.temperature_2m_min[idx])}</p> const rainMm = daily.precipitation_sum[idx] ?? 0;
<span class="muted">Regen: ${daily.precipitation_sum[idx]} mm · ${daily.precipitation_probability_max[idx] ?? 0}%</span> const wind = daily.wind_speed_10m_max[idx] ?? 0;
<span class="muted block">Wind: ${convertWind(daily.wind_speed_10m_max[idx] ?? 0)} · Böen ${convertWind(daily.wind_gusts_10m_max[idx] ?? 0)}</span> const gusts = daily.wind_gusts_10m_max[idx] ?? 0;
<span class="muted block">Sonne: ${sunrise} ${sunset}</span> const sunrise = daily.sunrise?.[idx]
</div>`; ? 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 `<article class="day-card${isToday ? " day-card-today" : ""}${rainClass ? ` ${rainClass}` : ""}">
<header class="day-card-head">
<span class="day-card-label">${label}</span>
<span class="day-card-date">${dateLine}</span>
</header>
<div class="day-card-icon" aria-hidden="true">${weatherIcon(code)}</div>
<p class="day-card-condition">${weatherLabel(code)}</p>
<div class="day-card-temps">
<span class="day-temp-max">${convertTemp(tMax)}</span>
<div class="day-temp-track" title="${convertTemp(tMin)} ${convertTemp(tMax)}">
<i class="day-temp-range" style="left:${barLeft}%;width:${barWidth}%"></i>
</div>
<span class="day-temp-min">${convertTemp(tMin)}</span>
</div>
<div class="day-card-rain">
<div class="bar"><i style="width:${Math.min(rainProb, 100)}%"></i></div>
<span>${rainProb}% · ${rainMm} mm</span>
</div>
<footer class="day-card-meta">
<span>💨 ${convertWind(wind)} · Böen ${convertWind(gusts)}</span>
<span>☀️ ${sunrise} ${sunset}</span>
</footer>
</article>`;
}).join(""); }).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) { function setRadarTime(index) {
if (!radarTimes.length) return; if (!radarTimes.length) return;
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
const time = radarTimes[radarFrameIndex]; const time = radarTimes[radarFrameIndex];
const isLatest = mapMode === "radar" && radarFrameIndex === radarTimes.length - 1;
el("radarSlider").value = radarFrameIndex; el("radarSlider").value = radarFrameIndex;
el("radarTimeLabel").textContent = formatDateTime(time); el("radarTimeLabel").textContent = isLatest
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; ? `Aktuell · ${formatDateTime(time)}`
: formatDateTime(time);
el("radarFrameInfo").textContent = mapMode === "forecast"
? `Stunde ${radarFrameIndex + 1} / ${radarTimes.length} · +${radarFrameIndex} h`
: `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (radarLayer) { if (mapMode === "forecast") {
radarLayer.setParams({ TIME: time, _cache: Date.now() }); 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() { async function loadRadarTimes() {
if (mapMode !== "radar") return;
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
const minutes = settings.radarMinutes || 120; const minutes = settings.radarMinutes || 120;
const previousTime = radarTimes.length && radarFrameIndex < radarTimes.length - 1
? radarTimes[radarFrameIndex]
: null;
try { try {
const data = await api(`/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`); const quickData = await api(
radarTimes = data.times || []; `/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}&quick=1`,
el("radarSlider").max = Math.max(0, radarTimes.length - 1); );
if (radarTimes.length) setRadarTime(radarTimes.length - 1); applyRadarTimeline(quickData, { previousTime });
else el("radarTimeLabel").textContent = "Keine Zeitschritte";
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) { } catch (err) {
el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`; el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`;
} }
@@ -494,7 +725,8 @@ async function initMap() {
}); });
layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map); layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map);
el("mapMeta").textContent = "DWD Radar + Warnungen aktiv"; el("mapMeta").textContent = "DWD Radar + Warnungen aktiv";
await loadRadarTimes(); showRadarLatest();
void loadRadarTimes();
} catch (err) { } catch (err) {
el("mapMeta").textContent = `Karte: ${err.message}`; el("mapMeta").textContent = `Karte: ${err.message}`;
} }
@@ -508,10 +740,15 @@ function updateMapLocation() {
} }
function reloadRadarLayers() { function reloadRadarLayers() {
if (mapMode === "forecast") {
loadPrecipitationMap();
return;
}
wmsLayers.forEach((layer) => { wmsLayers.forEach((layer) => {
if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() }); if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() });
}); });
loadRadarTimes(); showRadarLatest();
void loadRadarTimes();
} }
function toggleWarningsOnMap() { function toggleWarningsOnMap() {
@@ -598,6 +835,7 @@ function applySettingsToUI() {
el("unitTemp").value = settings.unitTemp; el("unitTemp").value = settings.unitTemp;
el("unitWind").value = settings.unitWind; el("unitWind").value = settings.unitWind;
el("radarMinutes").value = String(settings.radarMinutes); el("radarMinutes").value = String(settings.radarMinutes);
el("forecastHours").value = String(settings.forecastHours || 24);
applyTheme(); applyTheme();
} }
@@ -636,14 +874,12 @@ async function loadAll() {
await loadDashboard(); await loadDashboard();
} catch { } catch {
await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]); 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(); updateMapLocation();
if (map) loadRadarTimes(); if (map) {
if (mapMode === "forecast") loadPrecipitationMap();
else loadRadarTimes();
}
} }
async function init() { async function init() {
@@ -655,6 +891,9 @@ async function init() {
} }
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); 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("loadBtn").addEventListener("click", loadAll);
el("centerMapBtn").addEventListener("click", updateMapLocation); el("centerMapBtn").addEventListener("click", updateMapLocation);
el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers);
@@ -684,20 +923,22 @@ el("themeToggle").addEventListener("click", () => {
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly); if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
}); });
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => { ["unitTemp", "unitWind", "radarMinutes", "forecastHours"].forEach((id) => {
el(id).addEventListener("change", (e) => { 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 === "unitTemp") settings.unitTemp = e.target.value;
if (id === "unitWind") settings.unitWind = e.target.value; if (id === "unitWind") settings.unitWind = e.target.value;
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
if (id === "forecastHours") settings.forecastHours = Number(e.target.value);
saveSettings(); saveSettings();
if (latestForecast) { if (latestForecast) {
renderDaily(latestForecast.data.daily); renderDaily(latestForecast.data.daily);
renderHourly(latestForecast.data.hourly); renderHourly(latestForecast.data.hourly);
renderHourStrip(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly);
renderForecastOutlook(latestForecast.data.hourly);
loadForecast(); loadForecast();
} }
if (id === "radarMinutes" && map) loadRadarTimes(); if (id === "radarMinutes" && map && mapMode === "radar") loadRadarTimes();
if (id === "forecastHours" && map && mapMode === "forecast") loadPrecipitationMap();
}); });
}); });

View File

@@ -69,7 +69,7 @@
<nav class="tabs" aria-label="Ansichten"> <nav class="tabs" aria-label="Ansichten">
<button class="tab active" data-view="dashboard">Übersicht</button> <button class="tab active" data-view="dashboard">Übersicht</button>
<button class="tab" data-view="radar">Radar-Karte</button> <button class="tab" data-view="radar">Karte</button>
<button class="tab" data-view="warnings">Warnungen</button> <button class="tab" data-view="warnings">Warnungen</button>
<button class="tab" data-view="hourly">Stunden</button> <button class="tab" data-view="hourly">Stunden</button>
<button class="tab" data-view="daily">7 Tage</button> <button class="tab" data-view="daily">7 Tage</button>
@@ -78,9 +78,9 @@
<section class="view active" data-view="dashboard"> <section class="view active" data-view="dashboard">
<section class="grid"> <section class="grid">
<article class="glass-card card"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article> <article class="glass-card card card-accent"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article>
<article class="glass-card card card-accent"><h2>Regen · 24 h</h2><p class="big" id="rainOutlookBig"></p><p id="rainOutlookMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>DWD-Beobachtung</h2><p class="big" id="obsTemp"></p><p id="obsMeta" class="muted">Lade…</p></article> <article class="glass-card card"><h2>DWD-Beobachtung</h2><p class="big" id="obsTemp"></p><p id="obsMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Radar-Status</h2><p class="big" id="radarCount"></p><p id="radarMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Warnungen</h2><p class="big" id="warnCount"></p><p id="warnMeta" class="muted">Lade…</p></article> <article class="glass-card card"><h2>Warnungen</h2><p class="big" id="warnCount"></p><p id="warnMeta" class="muted">Lade…</p></article>
</section> </section>
<section class="glass-card"> <section class="glass-card">
@@ -96,12 +96,19 @@
<section class="view" data-view="radar"> <section class="view" data-view="radar">
<section class="glass-card map-panel"> <section class="glass-card map-panel">
<div class="section-head"> <div class="section-head">
<div><h2>Radar-Karte</h2><p class="muted no-margin">DWD WMS · OpenStreetMap · Zeitleiste</p></div> <div>
<h2>Wetterkarte</h2>
<p class="muted no-margin">DWD-Radar · Open-Meteo-Regenvorhersage · OpenStreetMap</p>
</div>
<span class="muted" id="mapMeta">Lade…</span> <span class="muted" id="mapMeta">Lade…</span>
</div> </div>
<div class="map-mode-toggle" role="tablist" aria-label="Kartenmodus">
<button type="button" class="map-mode-btn active" data-map-mode="radar">Radar · Vergangenheit</button>
<button type="button" class="map-mode-btn" data-map-mode="forecast">Regenvorhersage · Zukunft</button>
</div>
<div id="map"></div> <div id="map"></div>
<div class="panel-inner" id="radarTimeline"> <div class="panel-inner" id="radarTimeline">
<div class="section-head"><strong>Radar-Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div> <div class="section-head"><strong id="timelineTitle">Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div>
<div class="timeline-controls"> <div class="timeline-controls">
<button id="radarFirstBtn" class="btn btn-secondary icon-btn"></button> <button id="radarFirstBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPrevBtn" class="btn btn-secondary icon-btn"></button> <button id="radarPrevBtn" class="btn btn-secondary icon-btn"></button>
@@ -111,13 +118,20 @@
<input id="radarSlider" type="range" min="0" max="0" value="0" /> <input id="radarSlider" type="range" min="0" max="0" value="0" />
</div> </div>
<div class="timeline-meta"> <div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label> <label class="inline-check map-mode-radar-only"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span> <span class="muted" id="radarFrameInfo"></span>
</div> </div>
<p class="muted no-margin" id="timelineHint" style="margin-top:8px;font-size:0.85rem">Vergangenheit · 5-Min-Schritte · DWD-Radar</p>
<div class="precip-legend map-mode-forecast-only hidden" id="precipLegend">
<span><i class="swatch light"></i> leicht</span>
<span><i class="swatch medium"></i> mäßig</span>
<span><i class="swatch heavy"></i> stark</span>
<span class="muted">Modell · ~25 km · Open-Meteo</span>
</div>
</div> </div>
<div class="map-actions"> <div class="map-actions">
<button id="centerMapBtn" class="btn btn-secondary">Auf Standort zentrieren</button> <button id="centerMapBtn" class="btn btn-secondary">Auf Standort zentrieren</button>
<button id="reloadRadarLayersBtn" class="btn btn-secondary">Layer neu laden</button> <button id="reloadRadarLayersBtn" class="btn btn-secondary">Karte neu laden</button>
</div> </div>
</section> </section>
</section> </section>
@@ -157,9 +171,15 @@
</section> </section>
<section class="view" data-view="daily"> <section class="view" data-view="daily">
<section class="glass-card"> <section class="glass-card daily-forecast-panel">
<div class="section-head"><h2>7-Tage Forecast</h2><span class="muted" id="dailySource"></span></div> <div class="section-head">
<div class="days" id="dailyForecast"></div> <div>
<h2>7-Tage Forecast</h2>
<p class="muted no-margin">Temperatur, Regen &amp; Wind · Open-Meteo</p>
</div>
<span class="muted" id="dailySource"></span>
</div>
<div class="days days-forecast" id="dailyForecast"></div>
</section> </section>
</section> </section>
@@ -170,6 +190,7 @@
<label>Temperatureinheit<select id="unitTemp"><option value="celsius">°C</option><option value="fahrenheit">°F</option></select></label> <label>Temperatureinheit<select id="unitTemp"><option value="celsius">°C</option><option value="fahrenheit">°F</option></select></label>
<label>Windeinheit<select id="unitWind"><option value="kmh">km/h</option><option value="ms">m/s</option><option value="kn">kn</option></select></label> <label>Windeinheit<select id="unitWind"><option value="kmh">km/h</option><option value="ms">m/s</option><option value="kn">kn</option></select></label>
<label>Radar-Zeitleiste<select id="radarMinutes"><option value="60">60 Min.</option><option value="90">90 Min.</option><option value="120" selected>120 Min.</option></select></label> <label>Radar-Zeitleiste<select id="radarMinutes"><option value="60">60 Min.</option><option value="90">90 Min.</option><option value="120" selected>120 Min.</option></select></label>
<label>Regen-Karte<select id="forecastHours"><option value="6">6 h</option><option value="12">12 h</option><option value="24" selected>24 h</option></select></label>
<label>Standard-Standort<button id="saveDefaultLocationBtn" class="btn btn-secondary full-width">Aktuellen Ort speichern</button></label> <label>Standard-Standort<button id="saveDefaultLocationBtn" class="btn btn-secondary full-width">Aktuellen Ort speichern</button></label>
</div> </div>
</section> </section>

View File

@@ -2,7 +2,7 @@
:root { :root {
--background-color: #0d0821; --background-color: #0d0821;
--background-gradient: linear-gradient(135deg, #0d0821 0%, #1a1230 100%); --background-gradient: linear-gradient(180deg, #0d0821 0%, #1a1230 100%);
--primary-color: #ff51f9; --primary-color: #ff51f9;
--accent-color-1: #a348ff; --accent-color-1: #a348ff;
--accent-color-2: #3978ff; --accent-color-2: #3978ff;
@@ -32,7 +32,7 @@
html[data-theme="light"] { html[data-theme="light"] {
--background-color: #f4f0ff; --background-color: #f4f0ff;
--background-gradient: linear-gradient(135deg, #f4f0ff 0%, #ebe4ff 100%); --background-gradient: linear-gradient(180deg, #f4f0ff 0%, #ebe4ff 100%);
--text-primary: #1b1238; --text-primary: #1b1238;
--text-secondary: #5f5380; --text-secondary: #5f5380;
--glass-bg: rgba(255, 255, 255, 0.82); --glass-bg: rgba(255, 255, 255, 0.82);
@@ -42,12 +42,32 @@ html[data-theme="light"] {
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html {
height: 100%;
}
body { body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-primary); color: var(--text-primary);
background: var(--background-gradient); background-color: var(--background-color);
background-image: var(--background-gradient);
background-attachment: fixed;
background-size: cover;
line-height: 1.6; line-height: 1.6;
min-height: 100vh; min-height: 100%;
min-height: 100dvh;
display: flex;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
}
input,
textarea,
select,
[contenteditable="true"] {
user-select: text;
-webkit-user-select: text;
} }
a { color: var(--primary-color); text-decoration: none; } a { color: var(--primary-color); text-decoration: none; }
@@ -131,7 +151,9 @@ html[data-theme="light"] .site-header { background: rgba(244, 240, 255, 0.88); }
} }
.status-pill.bad { border-color: rgba(255, 77, 109, 0.45); color: #ffb4c2; } .status-pill.bad { border-color: rgba(255, 77, 109, 0.45); color: #ffb4c2; }
.app-main { padding: var(--space-4) 0 var(--space-5); } .app-main {
padding: var(--space-4) 0 var(--space-5);
}
.hero-copy { margin-bottom: var(--space-3); } .hero-copy { margin-bottom: var(--space-3); }
.hero-copy h1 { .hero-copy h1 {
@@ -271,6 +293,51 @@ html[data-theme="light"] .search-results { background: #fff; }
border-color: transparent; border-color: transparent;
} }
.card-accent {
border-color: rgba(57, 120, 255, 0.35);
background: linear-gradient(180deg, rgba(57, 120, 255, 0.08), var(--glass-bg));
}
.map-mode-toggle {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.map-mode-btn {
border-radius: var(--radius-lg);
padding: 0.55rem 0.9rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
font-weight: 600;
}
.map-mode-btn.active {
background: linear-gradient(135deg, rgba(57,120,255,0.85), rgba(255,81,249,0.85));
border-color: transparent;
}
.precip-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 8px;
font-size: 0.82rem;
}
.precip-legend .swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 4px;
margin-right: 4px;
vertical-align: middle;
}
.precip-legend .swatch.light { background: rgba(57, 120, 255, 0.45); }
.precip-legend .swatch.medium { background: rgba(57, 120, 255, 0.75); }
.precip-legend .swatch.heavy { background: rgba(255, 81, 249, 0.75); }
.view { display: none; } .view { display: none; }
.view.active { display: block; } .view.active { display: block; }
@@ -293,7 +360,7 @@ html[data-theme="light"] .search-results { background: #fff; }
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
.metric, .day, .hour-card, .source-item, .source-status, .warning-card, .admin-stat { .metric, .hour-card, .source-item, .source-status, .warning-card, .admin-stat {
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: 14px; padding: 14px;
@@ -308,6 +375,134 @@ html[data-theme="light"] .search-results { background: #fff; }
gap: 12px; gap: 12px;
} }
.daily-forecast-panel { overflow: hidden; }
.days-forecast {
display: flex;
gap: 14px;
overflow-x: auto;
padding: 4px 2px 10px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.days-forecast::-webkit-scrollbar { height: 6px; }
.days-forecast::-webkit-scrollbar-thumb {
background: rgba(255, 81, 249, 0.35);
border-radius: 999px;
}
.day-card {
flex: 0 0 min(172px, 44vw);
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 14px;
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
background: rgba(0, 0, 0, 0.18);
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
html[data-theme="light"] .day-card { background: rgba(255, 255, 255, 0.72); }
.day-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 81, 249, 0.35);
box-shadow: 0 10px 28px rgba(255, 81, 249, 0.12);
}
.day-card-today {
border-color: rgba(255, 81, 249, 0.55);
background: linear-gradient(165deg, rgba(255, 81, 249, 0.14), rgba(57, 120, 255, 0.1));
box-shadow: 0 8px 24px rgba(255, 81, 249, 0.15);
}
.day-card.rain-mid { border-color: rgba(57, 120, 255, 0.4); }
.day-card.rain-high { border-color: rgba(57, 120, 255, 0.55); background: linear-gradient(165deg, rgba(57, 120, 255, 0.12), rgba(0,0,0,0.18)); }
.day-card-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.day-card-label {
font-weight: 800;
font-size: 0.95rem;
letter-spacing: 0.01em;
}
.day-card-date {
font-size: 0.78rem;
color: var(--text-secondary);
text-transform: capitalize;
}
.day-card-icon {
font-size: 2.4rem;
line-height: 1;
margin: 2px 0;
}
.day-card-condition {
font-size: 0.82rem;
color: var(--text-secondary);
min-height: 2.5em;
line-height: 1.25;
}
.day-card-temps {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
}
.day-temp-max {
font-size: 1.35rem;
font-weight: 800;
color: var(--highlight-color);
}
.day-temp-min {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-secondary);
}
.day-temp-track {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.day-temp-range {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color));
}
.day-card-rain {
display: grid;
gap: 6px;
font-size: 0.78rem;
color: var(--text-secondary);
}
.day-card-rain .bar { margin: 0; }
.day-card-meta {
display: grid;
gap: 4px;
font-size: 0.74rem;
color: var(--text-secondary);
margin-top: auto;
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@media (min-width: 960px) {
.days-forecast {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
overflow: visible;
padding-bottom: 0;
}
.day-card { flex: unset; min-width: 0; }
}
.hourly-chart-wrap { .hourly-chart-wrap {
position: relative; position: relative;
height: min(340px, 48vh); height: min(340px, 48vh);
@@ -409,8 +604,10 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
.rendered-frame figcaption { padding: 8px 10px; font-size: 0.78rem; color: var(--text-secondary); } .rendered-frame figcaption { padding: 8px 10px; font-size: 0.78rem; color: var(--text-secondary); }
.site-footer { .site-footer {
flex-shrink: 0;
margin-top: auto;
border-top: 1px solid var(--glass-border); border-top: 1px solid var(--glass-border);
background: rgba(0,0,0,0.22); background: transparent;
padding: var(--space-4) 0; padding: var(--space-4) 0;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.86rem; font-size: 0.86rem;
@@ -428,7 +625,12 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
.leaflet-control-layers, .leaflet-popup-content-wrapper { color: #1b1238; } .leaflet-control-layers, .leaflet-popup-content-wrapper { color: #1b1238; }
/* Admin */ /* Admin */
.admin-page { min-height: 100vh; } .admin-page {
min-height: 100%;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.admin-login-wrap { .admin-login-wrap {
min-height: calc(100vh - 80px); min-height: calc(100vh - 80px);
display: grid; display: grid;
@@ -465,7 +667,12 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
color: var(--text-secondary); color: var(--text-secondary);
} }
.legal-page { max-width: 760px; margin: 40px auto; padding: 0 20px; } .legal-page {
max-width: 760px;
margin: 40px auto;
padding: 0 20px 40px;
width: 100%;
}
.legal-page h1 { font-family: "Russo One", sans-serif; } .legal-page h1 { font-family: "Russo One", sans-serif; }
@media (max-width: 1000px) { @media (max-width: 1000px) {