const STORAGE_KEY = "hexawetter_settings"; const FAV_KEY = "hexawetter_favorites"; const el = (id) => document.getElementById(id); let latestForecast = null; let map = null; let mapMarker = null; let wmsLayers = []; let layerControl = null; let osmLayer = null; let radarLayer = null; let warningLayer = null; let radarTimes = []; let radarFrameIndex = 0; let radarPlayTimer = null; let radarPlaying = false; let mapMode = "radar"; let precipData = null; let precipLayerGroup = null; let hourlyChart = null; let settings = loadSettings(); let searchTimer = null; const WEATHER_ICONS = { 0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️", 45: "🌫️", 48: "🌫️", 51: "🌦️", 53: "🌦️", 55: "🌧️", 61: "🌧️", 63: "🌧️", 65: "🌧️", 71: "🌨️", 73: "🌨️", 75: "❄️", 80: "🌦️", 81: "🌧️", 82: "⛈️", 95: "⛈️", 96: "⛈️", 99: "⛈️", }; function loadSettings() { try { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, forecastHours: 24, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") }; } catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, forecastHours: 24 }; } } function saveSettings() { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } function loadFavorites() { try { return JSON.parse(localStorage.getItem(FAV_KEY) || "[]"); } catch { return []; } } function saveFavorites(items) { localStorage.setItem(FAV_KEY, JSON.stringify(items)); } function weatherIcon(code) { return WEATHER_ICONS[code] || "🌡️"; } function weatherLabel(code) { const map = { 0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt", 45: "Nebel", 48: "Reifnebel", 51: "Leichter Niesel", 53: "Niesel", 55: "Starker Niesel", 61: "Leichter Regen", 63: "Regen", 65: "Starker Regen", 71: "Leichter Schnee", 73: "Schnee", 75: "Starker Schnee", 80: "Regenschauer", 81: "Starke Schauer", 82: "Heftige Schauer", 95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter", }; return map[code] || `Code ${code}`; } function weatherText(code) { return `${weatherIcon(code)} ${weatherLabel(code)}`.trim(); } function convertTemp(c) { if (settings.unitTemp === "fahrenheit") return `${Math.round(c * 9 / 5 + 32)} °F`; return `${Math.round(c)} °C`; } function convertWind(kmh) { if (settings.unitWind === "ms") return `${(kmh / 3.6).toFixed(1)} m/s`; if (settings.unitWind === "kn") return `${(kmh * 0.539957).toFixed(1)} kn`; return `${Math.round(kmh)} km/h`; } function tempValue(celsius) { if (settings.unitTemp === "fahrenheit") return celsius * 9 / 5 + 32; return celsius; } function tempUnitLabel() { return settings.unitTemp === "fahrenheit" ? "°F" : "°C"; } function windUnitLabel() { if (settings.unitWind === "ms") return "m/s"; if (settings.unitWind === "kn") return "kn"; return "km/h"; } function windValue(kmh) { if (settings.unitWind === "ms") return kmh / 3.6; if (settings.unitWind === "kn") return kmh * 0.539957; return kmh; } function chartThemeColors() { const isLight = document.documentElement.dataset.theme === "light"; return { text: isLight ? "#5f5380" : "#cfc9dd", grid: isLight ? "rgba(20, 10, 60, 0.08)" : "rgba(255, 255, 255, 0.08)", }; } function destroyHourlyChart() { if (hourlyChart) { hourlyChart.destroy(); hourlyChart = null; } } function renderHourlyChart(hourly) { if (typeof Chart === "undefined" || !hourly?.time?.length) return; const canvas = el("hourlyChart"); if (!canvas) return; destroyHourlyChart(); const { text: textColor, grid: gridColor } = chartThemeColors(); const tempUnit = tempUnitLabel(); const windUnit = windUnitLabel(); const labels = hourly.time.slice(0, 48).map((time) => new Date(time).toLocaleString("de-DE", { weekday: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }) ); hourlyChart = new Chart(canvas, { type: "line", data: { labels, datasets: [ { type: "bar", label: "Regenwahrscheinlichkeit", data: hourly.precipitation_probability.slice(0, 48).map((v) => v ?? 0), yAxisID: "yRain", backgroundColor: "rgba(57, 120, 255, 0.42)", borderColor: "rgba(57, 120, 255, 0.7)", borderWidth: 1, borderRadius: 4, order: 3, }, { label: `Temperatur (${tempUnit})`, data: hourly.temperature_2m.slice(0, 48).map(tempValue), yAxisID: "yMain", borderColor: "#ff51f9", backgroundColor: "rgba(255, 81, 249, 0.14)", fill: true, tension: 0.35, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2.5, order: 1, }, { label: `Wind (${windUnit})`, data: hourly.wind_speed_10m.slice(0, 48).map((v) => windValue(v ?? 0)), yAxisID: "yMain", borderColor: "#a348ff", borderDash: [5, 4], tension: 0.35, pointRadius: 0, borderWidth: 1.5, fill: false, order: 2, }, ], }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: textColor, usePointStyle: true, boxWidth: 10 }, }, tooltip: { callbacks: { label(ctx) { const label = ctx.dataset.label || ""; if (label.startsWith("Regen")) return `${label}: ${ctx.parsed.y}%`; if (label.startsWith("Temperatur")) return `${label}: ${Math.round(ctx.parsed.y)} ${tempUnit}`; return `${label}: ${ctx.parsed.y.toFixed(1)} ${windUnit}`; }, }, }, }, scales: { x: { ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 14 }, grid: { color: gridColor }, }, yMain: { position: "left", ticks: { color: textColor }, grid: { color: gridColor }, }, yRain: { position: "right", min: 0, max: 100, grid: { drawOnChartArea: false }, ticks: { color: textColor, callback: (v) => `${v}%` }, }, }, }, }); } async function api(path, options = {}) { const res = await fetch(path, options); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status}: ${text}`); } return res.json(); } function getLocation() { return { place: el("place").value || "Standort", lat: Number(el("lat").value), lon: Number(el("lon").value), }; } function setLocation(loc) { el("place").value = loc.place; el("lat").value = loc.lat; el("lon").value = loc.lon; } function formatDateTime(value) { return new Date(value).toLocaleString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } function metric(label, value, hint = "") { return `
${label}${value}${hint ? `${hint}` : ""}
`; } function warnLevelClass(level) { if (level >= 50) return "warn-extreme"; if (level >= 40) return "warn-severe"; if (level >= 30) return "warn-moderate"; if (level >= 20) return "warn-minor"; return "warn-info"; } async function loadHealth() { try { const data = await api("/api/health"); el("health").textContent = `API: online · v${data.version} · ${new Date(data.time_utc).toLocaleTimeString("de-DE")}`; el("health").classList.remove("bad"); } catch { el("health").textContent = "API: offline"; el("health").classList.add("bad"); } } function applyDashboard(dash) { const loc = getLocation(); if (dash.forecast?.data) { latestForecast = dash.forecast; const current = dash.forecast.data.current; el("currentTemp").textContent = convertTemp(current.temperature_2m); el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`; el("forecastSource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source; el("dailySource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source; el("currentDetails").innerHTML = [ metric("Gefühlt", convertTemp(current.apparent_temperature)), metric("Luftfeuchte", `${current.relative_humidity_2m} %`), metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`), metric("Niederschlag", `${current.precipitation ?? 0} mm`), metric("Bewölkung", `${current.cloud_cover ?? 0} %`), metric("Böen", convertWind(current.wind_gusts_10m ?? 0)), ].join(""); renderDaily(dash.forecast.data.daily); renderHourly(dash.forecast.data.hourly); renderHourStrip(dash.forecast.data.hourly); renderForecastOutlook(dash.forecast.data.hourly); } if (dash.observations?.data) { const weather = dash.observations.data.weather; const source = dash.observations.data.sources?.[0]; if (weather) { el("obsTemp").textContent = convertTemp(weather.temperature); el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`; } } if (dash.warnings) { applyWarningsData(dash.warnings); } } function renderForecastOutlook(hourly) { if (!hourly?.time?.length) { el("rainOutlookBig").textContent = "–"; el("rainOutlookMeta").textContent = "Kein Forecast"; return; } let nextRainIdx = -1; let maxProb = 0; hourly.time.slice(0, 24).forEach((_, idx) => { const prob = hourly.precipitation_probability[idx] ?? 0; const precip = hourly.precipitation[idx] ?? 0; if (nextRainIdx === -1 && (prob >= 40 || precip >= 0.15)) nextRainIdx = idx; maxProb = Math.max(maxProb, prob); }); if (nextRainIdx >= 0) { el("rainOutlookBig").textContent = formatDateTime(hourly.time[nextRainIdx]); el("rainOutlookMeta").textContent = `${hourly.precipitation_probability[nextRainIdx] ?? 0}% · ${hourly.precipitation[nextRainIdx] ?? 0} mm/h`; } else { el("rainOutlookBig").textContent = "Trocken"; el("rainOutlookMeta").textContent = `Max. ${maxProb}% in 24 h`; } } function applyWarningsData(data) { const localOnly = el("warningsLocalOnly")?.checked ?? true; const warnings = data.warnings || []; el("warnCount").textContent = String(data.count_local ?? warnings.length); el("warnMeta").textContent = localOnly ? `${data.count_local ?? warnings.length} Warnung(en) für deinen Standort` : `${data.count_total} Warnungen bundesweit (${data.count_local ?? warnings.length} eindeutig)`; const warncellEl = el("warncellInfo"); if (localOnly && data.warncells?.length) { warncellEl.classList.remove("hidden"); warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `${a.name} (${a.warncell_id})`).join(" · ")}`; } else { warncellEl.classList.add("hidden"); warncellEl.innerHTML = ""; } el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
${w.headline || w.event} Level ${w.level} · ${w.level_label}

${w.region_name || ""} · ${w.state || ""}

${(w.description || "").replace(/\n/g, "
")}

${w.instruction ? `

${w.instruction}

` : ""} Gültig: ${w.start ? formatDateTime(w.start) : "–"} – ${w.end ? formatDateTime(w.end) : "–"}
`).join("") : `

${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}

`; } async function loadForecast() { const loc = getLocation(); const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`); latestForecast = data; const current = data.data.current; el("currentTemp").textContent = convertTemp(current.temperature_2m); el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`; el("forecastSource").textContent = data.cached ? `${data.source} (Cache)` : data.source; el("dailySource").textContent = data.cached ? `${data.source} (Cache)` : data.source; el("currentDetails").innerHTML = [ metric("Gefühlt", convertTemp(current.apparent_temperature)), metric("Luftfeuchte", `${current.relative_humidity_2m} %`), metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`), metric("Niederschlag", `${current.precipitation ?? 0} mm`), metric("Bewölkung", `${current.cloud_cover ?? 0} %`), metric("Böen", convertWind(current.wind_gusts_10m ?? 0)), ].join(""); renderDaily(data.data.daily); renderHourly(data.data.hourly); renderHourStrip(data.data.hourly); renderForecastOutlook(data.data.hourly); } function dailyLabel(dateStr) { const d = new Date(`${dateStr}T12:00:00`); const today = new Date(); today.setHours(12, 0, 0, 0); const diff = Math.round((d - today) / 86400000); if (diff === 0) return "Heute"; if (diff === 1) return "Morgen"; return d.toLocaleDateString("de-DE", { weekday: "long" }); } function renderDaily(daily) { if (!daily?.time?.length) { el("dailyForecast").innerHTML = `

Kein Forecast verfügbar.

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

${weatherLabel(code)}

${convertTemp(tMax)}
${convertTemp(tMin)}
${rainProb}% · ${rainMm} mm
`; }).join(""); } function renderHourly(hourly) { el("hourlyTable").innerHTML = hourly.time.slice(0, 48).map((time, idx) => ` ${formatDateTime(time)} ${weatherText(hourly.weather_code[idx])} ${convertTemp(hourly.temperature_2m[idx])} ${hourly.precipitation_probability[idx] ?? 0}% · ${hourly.precipitation[idx] ?? 0} mm ${hourly.cloud_cover[idx] ?? 0}% ${convertWind(hourly.wind_speed_10m[idx] ?? 0)} ${convertWind(hourly.wind_gusts_10m[idx] ?? 0)} `).join(""); renderHourlyChart(hourly); } function renderHourStrip(hourly) { el("hourStrip").innerHTML = hourly.time.slice(0, 12).map((time, idx) => { const label = new Date(time).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); const rain = hourly.precipitation_probability[idx] ?? 0; return `
${label} ${convertTemp(hourly.temperature_2m[idx])} ${weatherText(hourly.weather_code[idx])}
Regen ${rain}% · Wind ${convertWind(hourly.wind_speed_10m[idx] ?? 0)}
`; }).join(""); } async function loadObservations() { const loc = getLocation(); try { const data = await api(`/api/observations?lat=${loc.lat}&lon=${loc.lon}`); const weather = data.data.weather; const source = data.data.sources?.[0]; if (!weather) throw new Error("Keine Beobachtung gefunden"); el("obsTemp").textContent = convertTemp(weather.temperature); el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`; } catch (err) { el("obsTemp").textContent = "–"; el("obsMeta").textContent = `Keine DWD-Beobachtung: ${err.message}`; } } async function loadWarnings() { const loc = getLocation(); const localOnly = el("warningsLocalOnly")?.checked ?? true; try { const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); applyWarningsData(data); } catch (err) { el("warnCount").textContent = "–"; el("warnMeta").textContent = err.message; el("warningsList").innerHTML = `

Warnungen nicht verfügbar: ${err.message}

`; el("warncellInfo")?.classList.add("hidden"); } } function setMapMode(mode) { mapMode = mode; document.querySelectorAll(".map-mode-btn").forEach((btn) => btn.classList.toggle("active", btn.dataset.mapMode === mode)); document.querySelectorAll(".map-mode-radar-only").forEach((node) => node.classList.toggle("hidden", mode !== "radar")); document.querySelectorAll(".map-mode-forecast-only").forEach((node) => node.classList.toggle("hidden", mode !== "forecast")); el("timelineTitle").textContent = mode === "radar" ? "Radar-Zeitleiste" : "Vorhersage-Zeitleiste"; el("timelineHint").textContent = mode === "radar" ? "Vergangenheit · 5-Min-Schritte · DWD-Radar" : "Zukunft · stündlich · Open-Meteo-Modell (~25 km)"; if (!map) return; if (mode === "radar") { clearPrecipLayer(); if (radarLayer && !map.hasLayer(radarLayer)) radarLayer.addTo(map); showRadarLatest(); void loadRadarTimes(); } else { if (radarLayer && map.hasLayer(radarLayer)) map.removeLayer(radarLayer); loadPrecipitationMap(); } } function precipColor(mm, prob) { const score = Math.max(Number(mm) || 0, ((Number(prob) || 0) / 100) * 2.5); if (score < 0.08) return null; if (score < 0.35) return "rgba(57, 120, 255, 0.38)"; if (score < 1) return "rgba(57, 120, 255, 0.58)"; if (score < 2.5) return "rgba(255, 81, 249, 0.55)"; return "rgba(255, 40, 120, 0.72)"; } function clearPrecipLayer() { if (precipLayerGroup && map) { map.removeLayer(precipLayerGroup); precipLayerGroup = null; } } function renderPrecipLayer(hourIndex) { if (!map || !precipData?.cells?.length) return; clearPrecipLayer(); precipLayerGroup = L.layerGroup().addTo(map); const half = (precipData.step_deg || 0.25) / 2; precipData.cells.forEach((cell) => { const color = precipColor(cell.precipitation[hourIndex], cell.probability[hourIndex]); if (!color) return; L.rectangle([[cell.lat - half, cell.lon - half], [cell.lat + half, cell.lon + half]], { color: "transparent", fillColor: color, fillOpacity: 1, weight: 0, interactive: false, }).addTo(precipLayerGroup); }); } async function loadPrecipitationMap() { const loc = getLocation(); const hours = settings.forecastHours || 24; el("mapMeta").textContent = "Lade Regenvorhersage…"; try { const data = await api(`/api/forecast/precipitation-map?lat=${loc.lat}&lon=${loc.lon}&hours=${hours}`); precipData = data; radarTimes = data.times || []; el("radarSlider").max = Math.max(0, radarTimes.length - 1); el("mapMeta").textContent = `Regenvorhersage · ${data.cell_count} Felder · ${hours} h${data.cached ? " · Cache" : ""}`; if (radarTimes.length) { setRadarTime(0); el("radarFrameInfo").textContent = `${radarTimes.length} Stunden · Modell Open-Meteo`; } else { el("radarTimeLabel").textContent = "Keine Vorhersagedaten"; } } catch (err) { el("mapMeta").textContent = `Vorhersage: ${err.message}`; el("radarTimeLabel").textContent = err.message; } } function applyRadarWmsTime(time, isLatest) { if (!radarLayer) return; if (isLatest) { delete radarLayer.wmsParams.TIME; delete radarLayer.wmsParams.time; } else { radarLayer.setParams({ TIME: time }); } radarLayer.setParams({ _cache: Date.now() }); if (radarLayer._map) radarLayer.redraw(); } function showRadarLatest() { if (!radarLayer || mapMode !== "radar" || !map) return; applyRadarWmsTime(null, true); if (radarTimes.length) { radarFrameIndex = radarTimes.length - 1; el("radarSlider").value = radarFrameIndex; el("radarTimeLabel").textContent = `Aktuell · ${formatDateTime(radarTimes[radarFrameIndex])}`; } else { el("radarTimeLabel").textContent = "Aktuell"; el("radarFrameInfo").textContent = "Lade Verlauf…"; } } function setRadarTime(index) { if (!radarTimes.length) return; radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1)); const time = radarTimes[radarFrameIndex]; const isLatest = mapMode === "radar" && radarFrameIndex === radarTimes.length - 1; el("radarSlider").value = radarFrameIndex; el("radarTimeLabel").textContent = isLatest ? `Aktuell · ${formatDateTime(time)}` : formatDateTime(time); el("radarFrameInfo").textContent = mapMode === "forecast" ? `Stunde ${radarFrameIndex + 1} / ${radarTimes.length} · +${radarFrameIndex} h` : `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`; if (mapMode === "forecast") { renderPrecipLayer(radarFrameIndex); } else if (radarLayer) { applyRadarWmsTime(time, isLatest); } } function applyRadarTimeline(data, { previousTime = null } = {}) { radarTimes = data.times || []; el("radarSlider").max = Math.max(0, radarTimes.length - 1); if (!radarTimes.length) { el("radarTimeLabel").textContent = "Keine Zeitschritte"; return; } if (previousTime) { const idx = radarTimes.indexOf(previousTime); if (idx >= 0) { setRadarTime(idx); return; } } radarFrameIndex = radarTimes.length - 1; el("radarSlider").value = radarFrameIndex; el("radarTimeLabel").textContent = `Aktuell · ${formatDateTime(data.latest || radarTimes[radarFrameIndex])}`; el("radarFrameInfo").textContent = data.partial ? `${radarTimes.length} Frames · Verlauf wird verfeinert…` : `${radarTimes.length} Frames · bis ${formatDateTime(data.latest)}`; applyRadarWmsTime(radarTimes[radarFrameIndex], true); } async function loadRadarTimes() { if (mapMode !== "radar") return; const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar"; const minutes = settings.radarMinutes || 120; const previousTime = radarTimes.length && radarFrameIndex < radarTimes.length - 1 ? radarTimes[radarFrameIndex] : null; try { const quickData = await api( `/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}&quick=1`, ); applyRadarTimeline(quickData, { previousTime }); const fullData = await api( `/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`, ); applyRadarTimeline(fullData, { previousTime: radarFrameIndex < radarTimes.length - 1 ? radarTimes[radarFrameIndex] : null, }); } catch (err) { el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`; } } function toggleRadarPlay() { if (radarPlaying) { clearInterval(radarPlayTimer); radarPlayTimer = null; radarPlaying = false; el("radarPlayBtn").textContent = "▶"; return; } if (radarTimes.length < 2) return; radarPlaying = true; el("radarPlayBtn").textContent = "⏸"; radarPlayTimer = setInterval(() => { const next = radarFrameIndex + 1; if (next >= radarTimes.length) setRadarTime(0); else setRadarTime(next); }, 800); } async function initMap() { if (map || typeof L === "undefined") return; const loc = getLocation(); map = L.map("map", { zoomControl: true }).setView([loc.lat, loc.lon], 8); osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap-Mitwirkende", }).addTo(map); mapMarker = L.marker([loc.lat, loc.lon]).addTo(map).bindPopup(loc.place); try { const config = await api("/api/radar/wms"); const overlays = {}; wmsLayers = config.layers.map((layer) => { const wms = L.tileLayer.wms(config.service_url, { layers: layer.layer, format: "image/png", transparent: true, version: "1.1.1", opacity: layer.opacity ?? 0.7, attribution: config.attribution, }); overlays[layer.title] = wms; if (layer.id === "niederschlagsradar") radarLayer = wms; if (layer.id === "warnungen_gemeinden") warningLayer = wms; if (layer.enabled) wms.addTo(map); return { ...layer, instance: wms }; }); layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map); el("mapMeta").textContent = "DWD Radar + Warnungen aktiv"; showRadarLatest(); void loadRadarTimes(); } catch (err) { el("mapMeta").textContent = `Karte: ${err.message}`; } } function updateMapLocation() { if (!map || !mapMarker) return; const loc = getLocation(); map.setView([loc.lat, loc.lon], Math.max(map.getZoom(), 8)); mapMarker.setLatLng([loc.lat, loc.lon]).bindPopup(loc.place); } function reloadRadarLayers() { if (mapMode === "forecast") { loadPrecipitationMap(); return; } wmsLayers.forEach((layer) => { if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() }); }); showRadarLatest(); void loadRadarTimes(); } function toggleWarningsOnMap() { if (!warningLayer || !map) return; if (el("showWarningsOnMap").checked) warningLayer.addTo(map); else map.removeLayer(warningLayer); } async function searchPlaces(query) { if (query.length < 2) { el("searchResults").classList.add("hidden"); return; } try { const data = await api(`/api/geocode/search?q=${encodeURIComponent(query)}`); const results = data.results || []; el("searchResults").innerHTML = results.length ? results.map((r, i) => `
  • ${r.place} ${r.label}
  • `).join("") : `
  • Keine Treffer
  • `; el("searchResults").classList.remove("hidden"); el("searchResults").querySelectorAll("li[data-index]").forEach((item) => { item.addEventListener("click", () => { const r = results[Number(item.dataset.index)]; setLocation({ place: r.place, lat: r.lat, lon: r.lon }); el("searchResults").classList.add("hidden"); loadAll(); }); }); } catch { el("searchResults").innerHTML = `
  • Suche fehlgeschlagen
  • `; el("searchResults").classList.remove("hidden"); } } function renderFavorites() { const favs = loadFavorites(); const panel = el("favoritesPanel"); if (!favs.length) { panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); el("favoritesList").innerHTML = favs.map((f, i) => ` `).join(""); el("favoritesList").querySelectorAll(".chip").forEach((btn) => { btn.addEventListener("click", () => { const f = favs[Number(btn.dataset.index)]; setLocation(f); loadAll(); }); }); } function addFavorite() { const loc = getLocation(); const favs = loadFavorites().filter((f) => f.place !== loc.place || f.lat !== loc.lat); favs.unshift(loc); saveFavorites(favs.slice(0, 8)); renderFavorites(); } function useGeolocation() { if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition(async (pos) => { const lat = pos.coords.latitude; const lon = pos.coords.longitude; try { const data = await api(`/api/geocode/reverse?lat=${lat}&lon=${lon}`); setLocation({ place: data.result?.place || "GPS-Standort", lat, lon }); } catch { setLocation({ place: "GPS-Standort", lat, lon }); } loadAll(); }); } function applyTheme() { document.documentElement.dataset.theme = settings.theme; el("themeToggle").textContent = settings.theme === "dark" ? "🌙" : "☀️"; } function applySettingsToUI() { el("unitTemp").value = settings.unitTemp; el("unitWind").value = settings.unitWind; el("radarMinutes").value = String(settings.radarMinutes); el("forecastHours").value = String(settings.forecastHours || 24); applyTheme(); } async function loadDefaultLocation() { const stored = settings.defaultLocation; if (stored) { setLocation(stored); return; } try { const data = await api("/api/location/default"); setLocation(data); } catch { /* fallback values in HTML */ } } function activateView(name) { document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.view === name)); document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name)); if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150)); if (name === "warnings") loadWarnings(); if (name === "hourly" && hourlyChart) setTimeout(() => hourlyChart.resize(), 80); } async function loadDashboard() { const loc = getLocation(); const localOnly = el("warningsLocalOnly")?.checked ?? true; const dash = await api(`/api/dashboard?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`); applyDashboard(dash); if (dash.bundle_cached) { el("forecastSource").textContent += " · Bundle-Cache"; } return dash; } async function loadAll() { loadHealth(); const loc = getLocation(); try { await loadDashboard(); } catch { await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]); } updateMapLocation(); if (map) { if (mapMode === "forecast") loadPrecipitationMap(); else loadRadarTimes(); } } async function init() { applySettingsToUI(); await loadDefaultLocation(); renderFavorites(); await loadAll(); setInterval(loadHealth, 60000); } document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view))); document.querySelectorAll(".map-mode-btn").forEach((btn) => { btn.addEventListener("click", () => setMapMode(btn.dataset.mapMode)); }); el("loadBtn").addEventListener("click", loadAll); el("centerMapBtn").addEventListener("click", updateMapLocation); el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers); el("favBtn").addEventListener("click", addFavorite); el("geoBtn").addEventListener("click", useGeolocation); el("warningsLocalOnly")?.addEventListener("change", loadWarnings); el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap); el("radarPlayBtn").addEventListener("click", toggleRadarPlay); el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1)); el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1)); el("radarFirstBtn").addEventListener("click", () => setRadarTime(0)); el("radarLastBtn").addEventListener("click", () => setRadarTime(radarTimes.length - 1)); el("radarSlider").addEventListener("input", (e) => setRadarTime(Number(e.target.value))); el("placeSearch").addEventListener("input", (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => searchPlaces(e.target.value.trim()), 300); }); document.addEventListener("click", (e) => { if (!e.target.closest(".search-wrap")) el("searchResults").classList.add("hidden"); }); el("themeToggle").addEventListener("click", () => { settings.theme = settings.theme === "dark" ? "light" : "dark"; saveSettings(); applyTheme(); if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly); }); ["unitTemp", "unitWind", "radarMinutes", "forecastHours"].forEach((id) => { el(id).addEventListener("change", (e) => { if (id === "unitTemp") settings.unitTemp = e.target.value; if (id === "unitWind") settings.unitWind = e.target.value; if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value); if (id === "forecastHours") settings.forecastHours = Number(e.target.value); saveSettings(); if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); renderForecastOutlook(latestForecast.data.hourly); loadForecast(); } if (id === "radarMinutes" && map && mapMode === "radar") loadRadarTimes(); if (id === "forecastHours" && map && mapMode === "forecast") loadPrecipitationMap(); }); }); el("saveDefaultLocationBtn").addEventListener("click", () => { settings.defaultLocation = getLocation(); saveSettings(); el("saveDefaultLocationBtn").textContent = "Gespeichert ✓"; setTimeout(() => { el("saveDefaultLocationBtn").textContent = "Aktuellen Ort als Standard speichern"; }, 2000); }); init();