HexaWetter v1.3.0: Add precipitation map feature, update README, and enhance UI elements

This commit is contained in:
TheOnlyMace
2026-06-19 00:32:22 +02:00
parent 3589f7fdcf
commit c8bf80a2c8
11 changed files with 867 additions and 103 deletions

View File

@@ -14,6 +14,9 @@ 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;
@@ -27,8 +30,8 @@ const WEATHER_ICONS = {
function loadSettings() {
try {
return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") };
} catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120 }; }
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() {
@@ -43,7 +46,11 @@ function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(items));
}
function weatherText(code) {
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",
@@ -51,7 +58,11 @@ function weatherText(code) {
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 `${WEATHER_ICONS[code] || ""} ${map[code] || `Code ${code}`}`.trim();
return map[code] || `Code ${code}`;
}
function weatherText(code) {
return `${weatherIcon(code)} ${weatherLabel(code)}`.trim();
}
function convertTemp(c) {
@@ -270,6 +281,7 @@ function applyDashboard(dash) {
renderDaily(dash.forecast.data.daily);
renderHourly(dash.forecast.data.hourly);
renderHourStrip(dash.forecast.data.hourly);
renderForecastOutlook(dash.forecast.data.hourly);
}
if (dash.observations?.data) {
@@ -281,23 +293,43 @@ function applyDashboard(dash) {
}
}
if (dash.radar) {
el("radarCount").textContent = `${dash.radar.count} online`;
el("radarMeta").textContent = `Produkt ${dash.radar.product?.toUpperCase() || "RV"} · DWD OpenData`;
}
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
? `${warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
? `${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) {
@@ -342,22 +374,75 @@ async function loadForecast() {
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 day = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
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" }) : "";
return `<div class="day">
<strong>${day}</strong>
<span class="muted">${weatherText(daily.weather_code[idx])}</span>
<p class="temp">${convertTemp(daily.temperature_2m_max[idx])} / ${convertTemp(daily.temperature_2m_min[idx])}</p>
<span class="muted">Regen: ${daily.precipitation_sum[idx]} mm · ${daily.precipitation_probability_max[idx] ?? 0}%</span>
<span class="muted block">Wind: ${convertWind(daily.wind_speed_10m_max[idx] ?? 0)} · Böen ${convertWind(daily.wind_gusts_10m_max[idx] ?? 0)}</span>
<span class="muted block">Sonne: ${sunrise} ${sunset}</span>
</div>`;
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("");
}
@@ -419,28 +504,174 @@ async function loadWarnings() {
}
}
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 = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
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 (radarLayer) {
radarLayer.setParams({ TIME: time, _cache: Date.now() });
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 data = await api(`/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`);
radarTimes = data.times || [];
el("radarSlider").max = Math.max(0, radarTimes.length - 1);
if (radarTimes.length) setRadarTime(radarTimes.length - 1);
else el("radarTimeLabel").textContent = "Keine Zeitschritte";
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}`;
}
@@ -494,7 +725,8 @@ async function initMap() {
});
layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map);
el("mapMeta").textContent = "DWD Radar + Warnungen aktiv";
await loadRadarTimes();
showRadarLatest();
void loadRadarTimes();
} catch (err) {
el("mapMeta").textContent = `Karte: ${err.message}`;
}
@@ -508,10 +740,15 @@ function updateMapLocation() {
}
function reloadRadarLayers() {
if (mapMode === "forecast") {
loadPrecipitationMap();
return;
}
wmsLayers.forEach((layer) => {
if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() });
});
loadRadarTimes();
showRadarLatest();
void loadRadarTimes();
}
function toggleWarningsOnMap() {
@@ -598,6 +835,7 @@ 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();
}
@@ -636,14 +874,12 @@ async function loadAll() {
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();
if (map) {
if (mapMode === "forecast") loadPrecipitationMap();
else loadRadarTimes();
}
}
async function init() {
@@ -655,6 +891,9 @@ async function init() {
}
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);
@@ -684,20 +923,22 @@ el("themeToggle").addEventListener("click", () => {
if (latestForecast?.data?.hourly) renderHourlyChart(latestForecast.data.hourly);
});
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
["unitTemp", "unitWind", "radarMinutes", "forecastHours"].forEach((id) => {
el(id).addEventListener("change", (e) => {
const key = id === "radarMinutes" ? "radarMinutes" : id.replace("unit", "unit").replace("Temp", "Temp").replace("Wind", "Wind");
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) loadRadarTimes();
if (id === "radarMinutes" && map && mapMode === "radar") loadRadarTimes();
if (id === "forecastHours" && map && mapMode === "forecast") loadPrecipitationMap();
});
});

View File

@@ -69,7 +69,7 @@
<nav class="tabs" aria-label="Ansichten">
<button class="tab active" data-view="dashboard">Übersicht</button>
<button class="tab" data-view="radar">Radar-Karte</button>
<button class="tab" data-view="radar">Karte</button>
<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>
@@ -78,9 +78,9 @@
<section class="view active" data-view="dashboard">
<section class="grid">
<article class="glass-card card"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article>
<article class="glass-card card card-accent"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article>
<article class="glass-card card card-accent"><h2>Regen · 24 h</h2><p class="big" id="rainOutlookBig"></p><p id="rainOutlookMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>DWD-Beobachtung</h2><p class="big" id="obsTemp"></p><p id="obsMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Radar-Status</h2><p class="big" id="radarCount"></p><p id="radarMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Warnungen</h2><p class="big" id="warnCount"></p><p id="warnMeta" class="muted">Lade…</p></article>
</section>
<section class="glass-card">
@@ -96,12 +96,19 @@
<section class="view" data-view="radar">
<section class="glass-card map-panel">
<div class="section-head">
<div><h2>Radar-Karte</h2><p class="muted no-margin">DWD WMS · OpenStreetMap · Zeitleiste</p></div>
<div>
<h2>Wetterkarte</h2>
<p class="muted no-margin">DWD-Radar · Open-Meteo-Regenvorhersage · OpenStreetMap</p>
</div>
<span class="muted" id="mapMeta">Lade…</span>
</div>
<div class="map-mode-toggle" role="tablist" aria-label="Kartenmodus">
<button type="button" class="map-mode-btn active" data-map-mode="radar">Radar · Vergangenheit</button>
<button type="button" class="map-mode-btn" data-map-mode="forecast">Regenvorhersage · Zukunft</button>
</div>
<div id="map"></div>
<div class="panel-inner" id="radarTimeline">
<div class="section-head"><strong>Radar-Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div>
<div class="section-head"><strong id="timelineTitle">Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div>
<div class="timeline-controls">
<button id="radarFirstBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPrevBtn" class="btn btn-secondary icon-btn"></button>
@@ -111,13 +118,20 @@
<input id="radarSlider" type="range" min="0" max="0" value="0" />
</div>
<div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<label class="inline-check map-mode-radar-only"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span>
</div>
<p class="muted no-margin" id="timelineHint" style="margin-top:8px;font-size:0.85rem">Vergangenheit · 5-Min-Schritte · DWD-Radar</p>
<div class="precip-legend map-mode-forecast-only hidden" id="precipLegend">
<span><i class="swatch light"></i> leicht</span>
<span><i class="swatch medium"></i> mäßig</span>
<span><i class="swatch heavy"></i> stark</span>
<span class="muted">Modell · ~25 km · Open-Meteo</span>
</div>
</div>
<div class="map-actions">
<button id="centerMapBtn" class="btn btn-secondary">Auf Standort zentrieren</button>
<button id="reloadRadarLayersBtn" class="btn btn-secondary">Layer neu laden</button>
<button id="reloadRadarLayersBtn" class="btn btn-secondary">Karte neu laden</button>
</div>
</section>
</section>
@@ -157,9 +171,15 @@
</section>
<section class="view" data-view="daily">
<section class="glass-card">
<div class="section-head"><h2>7-Tage Forecast</h2><span class="muted" id="dailySource"></span></div>
<div class="days" id="dailyForecast"></div>
<section class="glass-card daily-forecast-panel">
<div class="section-head">
<div>
<h2>7-Tage Forecast</h2>
<p class="muted no-margin">Temperatur, Regen &amp; Wind · Open-Meteo</p>
</div>
<span class="muted" id="dailySource"></span>
</div>
<div class="days days-forecast" id="dailyForecast"></div>
</section>
</section>
@@ -170,6 +190,7 @@
<label>Temperatureinheit<select id="unitTemp"><option value="celsius">°C</option><option value="fahrenheit">°F</option></select></label>
<label>Windeinheit<select id="unitWind"><option value="kmh">km/h</option><option value="ms">m/s</option><option value="kn">kn</option></select></label>
<label>Radar-Zeitleiste<select id="radarMinutes"><option value="60">60 Min.</option><option value="90">90 Min.</option><option value="120" selected>120 Min.</option></select></label>
<label>Regen-Karte<select id="forecastHours"><option value="6">6 h</option><option value="12">12 h</option><option value="24" selected>24 h</option></select></label>
<label>Standard-Standort<button id="saveDefaultLocationBtn" class="btn btn-secondary full-width">Aktuellen Ort speichern</button></label>
</div>
</section>

View File

@@ -2,7 +2,7 @@
:root {
--background-color: #0d0821;
--background-gradient: linear-gradient(135deg, #0d0821 0%, #1a1230 100%);
--background-gradient: linear-gradient(180deg, #0d0821 0%, #1a1230 100%);
--primary-color: #ff51f9;
--accent-color-1: #a348ff;
--accent-color-2: #3978ff;
@@ -32,7 +32,7 @@
html[data-theme="light"] {
--background-color: #f4f0ff;
--background-gradient: linear-gradient(135deg, #f4f0ff 0%, #ebe4ff 100%);
--background-gradient: linear-gradient(180deg, #f4f0ff 0%, #ebe4ff 100%);
--text-primary: #1b1238;
--text-secondary: #5f5380;
--glass-bg: rgba(255, 255, 255, 0.82);
@@ -42,12 +42,32 @@ html[data-theme="light"] {
* { box-sizing: border-box; margin: 0; padding: 0; }
html {
height: 100%;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-primary);
background: var(--background-gradient);
background-color: var(--background-color);
background-image: var(--background-gradient);
background-attachment: fixed;
background-size: cover;
line-height: 1.6;
min-height: 100vh;
min-height: 100%;
min-height: 100dvh;
display: flex;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
}
input,
textarea,
select,
[contenteditable="true"] {
user-select: text;
-webkit-user-select: text;
}
a { color: var(--primary-color); text-decoration: none; }
@@ -131,7 +151,9 @@ html[data-theme="light"] .site-header { background: rgba(244, 240, 255, 0.88); }
}
.status-pill.bad { border-color: rgba(255, 77, 109, 0.45); color: #ffb4c2; }
.app-main { padding: var(--space-4) 0 var(--space-5); }
.app-main {
padding: var(--space-4) 0 var(--space-5);
}
.hero-copy { margin-bottom: var(--space-3); }
.hero-copy h1 {
@@ -271,6 +293,51 @@ html[data-theme="light"] .search-results { background: #fff; }
border-color: transparent;
}
.card-accent {
border-color: rgba(57, 120, 255, 0.35);
background: linear-gradient(180deg, rgba(57, 120, 255, 0.08), var(--glass-bg));
}
.map-mode-toggle {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.map-mode-btn {
border-radius: var(--radius-lg);
padding: 0.55rem 0.9rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
font-weight: 600;
}
.map-mode-btn.active {
background: linear-gradient(135deg, rgba(57,120,255,0.85), rgba(255,81,249,0.85));
border-color: transparent;
}
.precip-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 8px;
font-size: 0.82rem;
}
.precip-legend .swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 4px;
margin-right: 4px;
vertical-align: middle;
}
.precip-legend .swatch.light { background: rgba(57, 120, 255, 0.45); }
.precip-legend .swatch.medium { background: rgba(57, 120, 255, 0.75); }
.precip-legend .swatch.heavy { background: rgba(255, 81, 249, 0.75); }
.view { display: none; }
.view.active { display: block; }
@@ -293,7 +360,7 @@ html[data-theme="light"] .search-results { background: #fff; }
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.metric, .day, .hour-card, .source-item, .source-status, .warning-card, .admin-stat {
.metric, .hour-card, .source-item, .source-status, .warning-card, .admin-stat {
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 14px;
@@ -308,6 +375,134 @@ html[data-theme="light"] .search-results { background: #fff; }
gap: 12px;
}
.daily-forecast-panel { overflow: hidden; }
.days-forecast {
display: flex;
gap: 14px;
overflow-x: auto;
padding: 4px 2px 10px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.days-forecast::-webkit-scrollbar { height: 6px; }
.days-forecast::-webkit-scrollbar-thumb {
background: rgba(255, 81, 249, 0.35);
border-radius: 999px;
}
.day-card {
flex: 0 0 min(172px, 44vw);
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 14px;
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
background: rgba(0, 0, 0, 0.18);
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
html[data-theme="light"] .day-card { background: rgba(255, 255, 255, 0.72); }
.day-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 81, 249, 0.35);
box-shadow: 0 10px 28px rgba(255, 81, 249, 0.12);
}
.day-card-today {
border-color: rgba(255, 81, 249, 0.55);
background: linear-gradient(165deg, rgba(255, 81, 249, 0.14), rgba(57, 120, 255, 0.1));
box-shadow: 0 8px 24px rgba(255, 81, 249, 0.15);
}
.day-card.rain-mid { border-color: rgba(57, 120, 255, 0.4); }
.day-card.rain-high { border-color: rgba(57, 120, 255, 0.55); background: linear-gradient(165deg, rgba(57, 120, 255, 0.12), rgba(0,0,0,0.18)); }
.day-card-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.day-card-label {
font-weight: 800;
font-size: 0.95rem;
letter-spacing: 0.01em;
}
.day-card-date {
font-size: 0.78rem;
color: var(--text-secondary);
text-transform: capitalize;
}
.day-card-icon {
font-size: 2.4rem;
line-height: 1;
margin: 2px 0;
}
.day-card-condition {
font-size: 0.82rem;
color: var(--text-secondary);
min-height: 2.5em;
line-height: 1.25;
}
.day-card-temps {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
}
.day-temp-max {
font-size: 1.35rem;
font-weight: 800;
color: var(--highlight-color);
}
.day-temp-min {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-secondary);
}
.day-temp-track {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.day-temp-range {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color));
}
.day-card-rain {
display: grid;
gap: 6px;
font-size: 0.78rem;
color: var(--text-secondary);
}
.day-card-rain .bar { margin: 0; }
.day-card-meta {
display: grid;
gap: 4px;
font-size: 0.74rem;
color: var(--text-secondary);
margin-top: auto;
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@media (min-width: 960px) {
.days-forecast {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
overflow: visible;
padding-bottom: 0;
}
.day-card { flex: unset; min-width: 0; }
}
.hourly-chart-wrap {
position: relative;
height: min(340px, 48vh);
@@ -409,8 +604,10 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
.rendered-frame figcaption { padding: 8px 10px; font-size: 0.78rem; color: var(--text-secondary); }
.site-footer {
flex-shrink: 0;
margin-top: auto;
border-top: 1px solid var(--glass-border);
background: rgba(0,0,0,0.22);
background: transparent;
padding: var(--space-4) 0;
color: var(--text-secondary);
font-size: 0.86rem;
@@ -428,7 +625,12 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
.leaflet-control-layers, .leaflet-popup-content-wrapper { color: #1b1238; }
/* Admin */
.admin-page { min-height: 100vh; }
.admin-page {
min-height: 100%;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.admin-login-wrap {
min-height: calc(100vh - 80px);
display: grid;
@@ -465,7 +667,12 @@ th { color: var(--text-secondary); font-size: 0.8rem; }
color: var(--text-secondary);
}
.legal-page { max-width: 760px; margin: 40px auto; padding: 0 20px; }
.legal-page {
max-width: 760px;
margin: 40px auto;
padding: 0 20px 40px;
width: 100%;
}
.legal-page h1 { font-family: "Russo One", sans-serif; }
@media (max-width: 1000px) {