HexaWetter v1.3.0: Add precipitation map feature, update README, and enhance UI elements
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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 (6–24 h, stündlich)
|
||||||
- **Radar-Animation**: Zeitleiste (60–120 Min., 5-Min-Schritte), Play/Pause, Vor/Zurück
|
- **Radar-Animation**: Zeitleiste (60–120 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 |
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("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("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)
|
||||||
|
|||||||
102
api/app/services/precipitation_map.py
Normal file
102
api/app/services/precipitation_map.py
Normal 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
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
step_minutes = 5
|
||||||
client = get_client()
|
latest_available: datetime | None = None
|
||||||
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
|
all_steps: list[str] = []
|
||||||
response.raise_for_status()
|
source = "capabilities"
|
||||||
dimension = _find_layer_dimension(response.text, layer)
|
|
||||||
if not dimension:
|
|
||||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
|
||||||
|
|
||||||
if "/" in dimension:
|
try:
|
||||||
start, end, step_minutes = _parse_time_dimension(dimension)
|
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
||||||
all_steps = _generate_time_steps(start, end, step_minutes)
|
client = get_client()
|
||||||
else:
|
response = await client.get(DWD_WMS_URL, params=params, timeout=20.0)
|
||||||
all_steps = [item.strip() for item in dimension.split(",") if item.strip()]
|
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)
|
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
|
||||||
|
|||||||
327
frontend/app.js
327
frontend/app.js
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user