HexaWetter v1.2.0: Security, Admin, Radar, UI
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Admin – HexaWetter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
@@ -47,6 +48,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button>
|
||||
<button id="syncRadarBtn" class="btn btn-secondary">Radar syncen</button>
|
||||
<button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +82,20 @@
|
||||
</form>
|
||||
<p class="muted" id="settingsMsg" style="margin-top:10px"></p>
|
||||
</section>
|
||||
|
||||
<section class="glass-card">
|
||||
<div class="section-head">
|
||||
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">Lokale DWD-Dateien und gerenderte Frames</p></div>
|
||||
<span class="muted" id="renderedCount">–</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th></th></tr></thead>
|
||||
<tbody id="radarFiles"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rendered-grid" id="renderedFrames" style="margin-top:16px"></div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -35,6 +35,67 @@ function formatBytes(bytes) {
|
||||
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
|
||||
}
|
||||
|
||||
async function fetchBlobUrl(path) {
|
||||
const token = getToken();
|
||||
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async function downloadAdminFile(path, filename) {
|
||||
const url = await fetchBlobUrl(path);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function loadRadarData() {
|
||||
try {
|
||||
const local = await api("/api/admin/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><button type="button" class="btn btn-secondary" data-download="${file.download_url}" data-name="${file.name}">Download</button></td>
|
||||
</tr>
|
||||
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
|
||||
el("radarFiles").querySelectorAll("[data-download]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => downloadAdminFile(btn.dataset.download, btn.dataset.name));
|
||||
});
|
||||
} catch {
|
||||
el("radarFiles").innerHTML = `<tr><td colspan="4" class="muted">Rohdaten nicht verfügbar</td></tr>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await api("/api/admin/radar/rendered?limit=12");
|
||||
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
|
||||
const frames = rendered.frames || [];
|
||||
if (!frames.length) {
|
||||
el("renderedFrames").innerHTML = `<p class="muted">Noch keine gerenderten Frames.</p>`;
|
||||
return;
|
||||
}
|
||||
el("renderedFrames").innerHTML = frames.map((f, i) => `
|
||||
<figure class="rendered-frame" id="renderedFrame${i}">
|
||||
<div class="muted" style="padding:40px;text-align:center">Lade…</div>
|
||||
<figcaption>${f.filename}</figcaption>
|
||||
</figure>
|
||||
`).join("");
|
||||
await Promise.allSettled(frames.map(async (f, i) => {
|
||||
const url = await fetchBlobUrl(f.image_url);
|
||||
const figure = el(`renderedFrame${i}`);
|
||||
if (!figure) return;
|
||||
figure.innerHTML = `<img src="${url}" alt="Radar ${f.filename}" loading="lazy" /><figcaption>${f.filename}</figcaption>`;
|
||||
}));
|
||||
} catch {
|
||||
el("renderedCount").textContent = "–";
|
||||
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
el("loginSection").classList.remove("admin-hidden");
|
||||
el("dashboardSection").classList.add("admin-hidden");
|
||||
@@ -92,6 +153,7 @@ async function loadDashboard() {
|
||||
el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
|
||||
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
|
||||
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
|
||||
await loadRadarData();
|
||||
}
|
||||
|
||||
el("loginForm").addEventListener("submit", async (e) => {
|
||||
@@ -124,6 +186,23 @@ el("clearCacheBtn").addEventListener("click", async () => {
|
||||
await loadDashboard();
|
||||
});
|
||||
|
||||
el("syncRadarBtn").addEventListener("click", async () => {
|
||||
const button = el("syncRadarBtn");
|
||||
button.disabled = true;
|
||||
const label = button.textContent;
|
||||
button.textContent = "Sync läuft…";
|
||||
try {
|
||||
const result = await api("/api/admin/radar/sync?limit=6", { method: "POST" });
|
||||
alert(`Sync abgeschlossen: ${result.downloaded?.length || 0} neu, ${result.skipped?.length || 0} übersprungen`);
|
||||
await loadRadarData();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = label;
|
||||
}
|
||||
});
|
||||
|
||||
el("settingsForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
<button class="tab" data-view="warnings">Warnungen</button>
|
||||
<button class="tab" data-view="hourly">Stunden</button>
|
||||
<button class="tab" data-view="daily">7 Tage</button>
|
||||
<button class="tab" data-view="raw">Rohdaten</button>
|
||||
<button class="tab" data-view="settings">Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
@@ -92,10 +91,6 @@
|
||||
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
|
||||
<div class="hour-strip" id="hourStrip"></div>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<div class="section-head"><h2>Datenquellen-Status</h2><span class="muted" id="sourceStatusSummary">Prüfe…</span></div>
|
||||
<div class="source-status-grid" id="sourceStatusGrid"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="radar">
|
||||
@@ -116,7 +111,6 @@
|
||||
<input id="radarSlider" type="range" min="0" max="0" value="0" />
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
<label class="inline-check"><input type="checkbox" id="useLocalRadar" /> Lokale Render-Frames</label>
|
||||
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
|
||||
<span class="muted" id="radarFrameInfo"></span>
|
||||
</div>
|
||||
@@ -144,8 +138,21 @@
|
||||
|
||||
<section class="view" data-view="hourly">
|
||||
<section class="glass-card">
|
||||
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr></thead><tbody id="hourlyTable"></tbody></table></div>
|
||||
<div class="section-head">
|
||||
<h2>Stundenansicht</h2>
|
||||
<span class="muted">48 Stunden · Temperatur, Regen & Wind</span>
|
||||
</div>
|
||||
<div class="hourly-chart-wrap">
|
||||
<canvas id="hourlyChart" aria-label="Stundenverlauf Temperatur, Regen und Wind"></canvas>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr>
|
||||
</thead>
|
||||
<tbody id="hourlyTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -156,24 +163,6 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="raw">
|
||||
<section class="glass-card">
|
||||
<div class="section-head">
|
||||
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">DWD OpenData · lokaler Renderer</p></div>
|
||||
<button id="syncRadarBtn" class="btn btn-secondary">Sync starten</button>
|
||||
</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th>Download</th></tr></thead><tbody id="radarFiles"></tbody></table></div>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<div class="section-head"><h2>Gerenderte Frames</h2><span class="muted" id="renderedCount"></span></div>
|
||||
<div class="rendered-grid" id="renderedFrames"></div>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<div class="section-head"><h2>Datenquellen</h2></div>
|
||||
<div class="source-list" id="sourceList"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="settings">
|
||||
<section class="glass-card">
|
||||
<h2>Einstellungen</h2>
|
||||
@@ -213,6 +202,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -307,6 +307,19 @@ html[data-theme="light"] .search-results { background: #fff; }
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hourly-chart-wrap {
|
||||
position: relative;
|
||||
height: min(340px, 48vh);
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem 0.5rem 0.25rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
html[data-theme="light"] .hourly-chart-wrap {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.hour-card span { display: block; font-size: 1.35rem; font-weight: 800; margin: 0.4rem 0; }
|
||||
.bar { height: 6px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.12); margin: 0.7rem 0; }
|
||||
.bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); }
|
||||
|
||||
Reference in New Issue
Block a user