Files
HexaWetter/frontend/app.js

953 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<div class="metric"><span>${label}</span><strong>${value}</strong>${hint ? `<small>${hint}</small>` : ""}</div>`;
}
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) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
}
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 = `<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) => {
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 `<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("");
}
function renderHourly(hourly) {
el("hourlyTable").innerHTML = hourly.time.slice(0, 48).map((time, idx) => `
<tr>
<td>${formatDateTime(time)}</td>
<td>${weatherText(hourly.weather_code[idx])}</td>
<td>${convertTemp(hourly.temperature_2m[idx])}</td>
<td>${hourly.precipitation_probability[idx] ?? 0}% · ${hourly.precipitation[idx] ?? 0} mm</td>
<td>${hourly.cloud_cover[idx] ?? 0}%</td>
<td>${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</td>
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr>
`).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 `<div class="hour-card">
<strong>${label}</strong>
<span>${convertTemp(hourly.temperature_2m[idx])}</span>
<small>${weatherText(hourly.weather_code[idx])}</small>
<div class="bar"><i style="width:${Math.min(rain, 100)}%"></i></div>
<small>Regen ${rain}% · Wind ${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</small>
</div>`;
}).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 = `<p class="muted">Warnungen nicht verfügbar: ${err.message}</p>`;
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: "&copy; 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) => `
<li role="option" data-index="${i}">
<strong>${r.place}</strong>
<span>${r.label}</span>
</li>
`).join("") : `<li class="muted">Keine Treffer</li>`;
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 = `<li class="muted">Suche fehlgeschlagen</li>`;
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) => `
<button class="chip" data-index="${i}">${f.place}</button>
`).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();