HexaWetter v1.2.0: Security, Admin, Radar, UI
This commit is contained in:
298
frontend/app.js
298
frontend/app.js
@@ -10,12 +10,11 @@ let layerControl = null;
|
||||
let osmLayer = null;
|
||||
let radarLayer = null;
|
||||
let warningLayer = null;
|
||||
let localRadarOverlay = null;
|
||||
let radarTimes = [];
|
||||
let radarFrameIndex = 0;
|
||||
let radarPlayTimer = null;
|
||||
let radarPlaying = false;
|
||||
let renderedFrames = [];
|
||||
let hourlyChart = null;
|
||||
let settings = loadSettings();
|
||||
let searchTimer = null;
|
||||
|
||||
@@ -44,14 +43,6 @@ function saveFavorites(items) {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "–";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes, unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
|
||||
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function weatherText(code) {
|
||||
const map = {
|
||||
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt",
|
||||
@@ -74,6 +65,139 @@ function convertWind(kmh) {
|
||||
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) {
|
||||
@@ -126,15 +250,6 @@ async function loadHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceStatus() {
|
||||
try {
|
||||
const data = await api("/api/health/sources");
|
||||
renderSourceStatus(data.sources || []);
|
||||
} catch {
|
||||
el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar";
|
||||
}
|
||||
}
|
||||
|
||||
function applyDashboard(dash) {
|
||||
const loc = getLocation();
|
||||
if (dash.forecast?.data) {
|
||||
@@ -207,18 +322,6 @@ function applyWarningsData(data) {
|
||||
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
|
||||
}
|
||||
|
||||
function renderSourceStatus(sources) {
|
||||
const bad = sources.filter((s) => s.status === "error").length;
|
||||
el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar";
|
||||
el("sourceStatusGrid").innerHTML = sources.map((s) => `
|
||||
<div class="source-status ${s.status}">
|
||||
<strong>${s.name}</strong>
|
||||
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
|
||||
${s.error ? `<small>${s.error}</small>` : ""}
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function loadForecast() {
|
||||
const loc = getLocation();
|
||||
const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`);
|
||||
@@ -270,6 +373,7 @@ function renderHourly(hourly) {
|
||||
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
renderHourlyChart(hourly);
|
||||
}
|
||||
|
||||
function renderHourStrip(hourly) {
|
||||
@@ -306,33 +410,7 @@ async function loadWarnings() {
|
||||
const localOnly = el("warningsLocalOnly")?.checked ?? true;
|
||||
try {
|
||||
const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
|
||||
const warnings = data.warnings || [];
|
||||
el("warnCount").textContent = String(data.count_local ?? warnings.length);
|
||||
el("warnMeta").textContent = localOnly
|
||||
? `${warnings.length} Warnung(en) für deinen Standort`
|
||||
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
|
||||
|
||||
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>`;
|
||||
applyWarningsData(data);
|
||||
} catch (err) {
|
||||
el("warnCount").textContent = "–";
|
||||
el("warnMeta").textContent = err.message;
|
||||
@@ -341,56 +419,6 @@ async function loadWarnings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRadarFiles() {
|
||||
const latest = await api("/api/radar/latest?limit=12");
|
||||
el("radarCount").textContent = `${latest.count} online`;
|
||||
el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`;
|
||||
const local = await api("/api/radar/files");
|
||||
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
|
||||
<tr><td>${file.name}</td><td>${formatBytes(file.size_bytes)}</td>
|
||||
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
|
||||
<td><a href="${file.download_url}">Download</a></td></tr>
|
||||
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
|
||||
try {
|
||||
const rendered = await api("/api/radar/rendered?limit=12");
|
||||
renderedFrames = rendered.frames || [];
|
||||
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
|
||||
el("renderedFrames").innerHTML = renderedFrames.length ? renderedFrames.map((f) => `
|
||||
<figure class="rendered-frame">
|
||||
<img src="${f.image_url}" alt="Radar ${f.filename}" loading="lazy" />
|
||||
<figcaption>${f.filename}</figcaption>
|
||||
</figure>
|
||||
`).join("") : `<p class="muted">Noch keine gerenderten Frames. Renderer-Worker abwarten.</p>`;
|
||||
} catch {
|
||||
el("renderedCount").textContent = "–";
|
||||
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRadar() {
|
||||
const button = el("syncRadarBtn");
|
||||
button.disabled = true;
|
||||
button.textContent = "Sync läuft…";
|
||||
try {
|
||||
await api("/api/radar/sync?limit=6", { method: "POST" });
|
||||
await loadRadarFiles();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Radar jetzt syncen";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
const data = await api("/api/sources");
|
||||
el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => `
|
||||
<div class="source-item"><span>${key}</span><code>${String(value)}</code></div>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
el("sourceList").innerHTML = `<p class="muted">${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setRadarTime(index) {
|
||||
if (!radarTimes.length) return;
|
||||
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
|
||||
@@ -399,24 +427,11 @@ function setRadarTime(index) {
|
||||
el("radarTimeLabel").textContent = formatDateTime(time);
|
||||
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
|
||||
|
||||
if (el("useLocalRadar").checked && renderedFrames.length) {
|
||||
showLocalRadarFrame(radarFrameIndex);
|
||||
} else if (radarLayer) {
|
||||
if (radarLayer) {
|
||||
radarLayer.setParams({ TIME: time, _cache: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
function showLocalRadarFrame(index) {
|
||||
if (!map) return;
|
||||
const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)];
|
||||
if (!frame?.bounds) return;
|
||||
const b = frame.bounds;
|
||||
const bounds = [[b.south, b.west], [b.north, b.east]];
|
||||
if (localRadarOverlay) map.removeLayer(localRadarOverlay);
|
||||
localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 });
|
||||
localRadarOverlay.addTo(map);
|
||||
}
|
||||
|
||||
async function loadRadarTimes() {
|
||||
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
|
||||
const minutes = settings.radarMinutes || 120;
|
||||
@@ -600,11 +615,33 @@ function activateView(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() {
|
||||
await loadHealth();
|
||||
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]);
|
||||
loadHealth();
|
||||
const loc = getLocation();
|
||||
try {
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
await Promise.allSettled([loadForecast(), loadObservations(), loadWarnings()]);
|
||||
const radar = await api("/api/radar/latest?limit=12").catch(() => null);
|
||||
if (radar) {
|
||||
el("radarCount").textContent = `${radar.count} online`;
|
||||
el("radarMeta").textContent = `Produkt ${radar.product.toUpperCase()} · DWD OpenData`;
|
||||
}
|
||||
}
|
||||
updateMapLocation();
|
||||
if (map) loadRadarTimes();
|
||||
}
|
||||
@@ -614,20 +651,17 @@ async function init() {
|
||||
await loadDefaultLocation();
|
||||
renderFavorites();
|
||||
await loadAll();
|
||||
setInterval(loadHealth, 30000);
|
||||
setInterval(loadHealth, 60000);
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view)));
|
||||
el("loadBtn").addEventListener("click", loadAll);
|
||||
el("syncRadarBtn").addEventListener("click", syncRadar);
|
||||
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("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex));
|
||||
|
||||
el("radarPlayBtn").addEventListener("click", toggleRadarPlay);
|
||||
el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1));
|
||||
el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1));
|
||||
@@ -647,6 +681,7 @@ el("themeToggle").addEventListener("click", () => {
|
||||
settings.theme = settings.theme === "dark" ? "light" : "dark";
|
||||
saveSettings();
|
||||
applyTheme();
|
||||
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
|
||||
});
|
||||
|
||||
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
|
||||
@@ -656,7 +691,12 @@ el("themeToggle").addEventListener("click", () => {
|
||||
if (id === "unitWind") settings.unitWind = e.target.value;
|
||||
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
|
||||
saveSettings();
|
||||
if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); }
|
||||
if (latestForecast) {
|
||||
renderDaily(latestForecast.data.daily);
|
||||
renderHourly(latestForecast.data.hourly);
|
||||
renderHourStrip(latestForecast.data.hourly);
|
||||
loadForecast();
|
||||
}
|
||||
if (id === "radarMinutes" && map) loadRadarTimes();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user