Feature: Stündliche Niederschlagswahrscheinlichkeit auf Wetter-Seite (SW by-v690)

- Backend: Open-Meteo Forecast-Request um hourly precipitation_probability,
  precipitation und weathercode erweitert; stündliche Daten werden pro Tag
  gruppiert und im API-Response unter "hourly" je Tag ausgeliefert
- Frontend: Neue _renderRainTimeline()-Funktion rendert horizontale
  Balken-Zeitskala für alle 24 Stunden des gewählten Tages; bei "Heute"
  wird automatisch zur aktuellen Stunde gescrollt und "jetzt" hervorgehoben;
  Farb-Gradient von hellgrau (<10%) bis dunkelblau (≥75%)
- SW/APP_VER/CSS auf 690 gebumpt
This commit is contained in:
rene 2026-05-04 20:06:30 +02:00
parent 84e6bfdd82
commit 759979ffce
5 changed files with 169 additions and 9 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '664'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '690'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -187,6 +187,11 @@ window.Page_wetter = (() => {
style="margin-bottom:var(--space-4)">
</div>
<!-- Niederschlagswahrscheinlichkeit Zeitskala -->
<div id="wttr-rain" class="section-card"
style="margin-bottom:var(--space-4)">
</div>
<!-- Hunde-Wetter -->
<div id="wttr-dog" class="section-card">
</div>
@ -198,11 +203,13 @@ window.Page_wetter = (() => {
_selDay = parseInt(card.dataset.wttrDay);
_updateStrip();
_renderDetail();
_renderRainTimeline();
_renderDog();
});
});
_renderDetail();
_renderRainTimeline();
_renderDog();
}
@ -380,6 +387,137 @@ window.Page_wetter = (() => {
`;
}
// ----------------------------------------------------------
// NIEDERSCHLAGS-ZEITSKALA (stündlich)
// ----------------------------------------------------------
function _renderRainTimeline() {
const el = _container.querySelector('#wttr-rain');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const hourly = d.hourly || [];
// Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben
const entries = hourly.filter(h => h.precip_prob != null);
if (!entries.length) { el.style.display = 'none'; return; }
el.style.display = '';
// Für "Heute" (Tag 0): ab jetzt, sonst alle 24h
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes();
let slots = entries;
if (_selDay === 0) {
// Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext)
const pastCutoff = now.getHours() - 2;
slots = entries.filter(h => {
const hHour = parseInt(h.hour.split(':')[0]);
return hHour >= pastCutoff;
});
// Falls nichts übrig bleibt, zeige alles
if (!slots.length) slots = entries;
}
// Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind)
const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0));
// Farb-Funktion: blau basierend auf Wahrscheinlichkeit
function _rainColor(prob) {
if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen
if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau
if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau
if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau
return 'rgba(29,78,216,1)'; // dunkelblau
}
// Aktuell aktiver Slot (nur bei Heute)
const currentHour = now.getHours();
const bars = slots.map(h => {
const prob = h.precip_prob ?? 0;
const hHour = parseInt(h.hour.split(':')[0]);
const isNow = _selDay === 0 && hHour === currentHour;
const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe
const color = _rainColor(prob);
const labelHour = h.hour.substring(0, 2); // 'HH'
return `
<div style="display:flex;flex-direction:column;align-items:center;
gap:2px;min-width:38px;flex-shrink:0;position:relative">
<!-- Prozentzahl oben (nur bei 20%) -->
<div style="font-size:9px;color:var(--c-text-secondary);
height:13px;line-height:13px;font-weight:600">
${prob >= 20 ? prob + '%' : ''}
</div>
<!-- Balken-Container mit fixer Höhe -->
<div style="height:56px;display:flex;align-items:flex-end;width:100%">
<div style="
width:100%;
height:${barH}px;
background:${color};
border-radius:3px 3px 0 0;
transition:height .3s;
${isNow ? 'box-shadow:0 0 0 1.5px var(--c-primary);border-radius:3px 3px 0 0;' : ''}
"></div>
</div>
<!-- Stunden-Label unten -->
<div style="font-size:9px;font-weight:${isNow ? '700' : '400'};
color:${isNow ? 'var(--c-primary)' : 'var(--c-text-secondary)'};
line-height:1">
${isNow ? 'jetzt' : labelHour + 'h'}
</div>
</div>
`;
}).join('');
// Gibt es überhaupt nennenswerten Niederschlag?
const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10);
const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)';
const titleIcon = hasRain ? 'cloud-rain' : 'cloud';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:${titleColor}">
<use href="/icons/phosphor.svg#${titleIcon}"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:700">
Niederschlagswahrscheinlichkeit
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto">
${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')}
</span>
</div>
<!-- Baseline -->
<div style="position:relative">
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;
scrollbar-width:none;padding-bottom:2px">
<div style="display:flex;gap:3px;min-width:max-content;padding:0 2px">
${bars}
</div>
</div>
<!-- 0%-Linie -->
<div style="height:1px;background:var(--c-border);margin-top:2px"></div>
</div>
${!hasRain ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-2);text-align:center">
Kein Regen erwartet
</div>` : ''}
`;
// Scroll zum aktuellen Slot wenn Heute
if (_selDay === 0) {
requestAnimationFrame(() => {
const wrap = el.querySelector('div[style*="overflow-x"]');
if (!wrap) return;
const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour);
if (nowIdx > 2) {
wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap
}
});
}
}
// ----------------------------------------------------------
// HUNDE-WETTER
// ----------------------------------------------------------