953 lines
34 KiB
JavaScript
953 lines
34 KiB
JavaScript
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: "© 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();
|