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:
parent
84e6bfdd82
commit
759979ffce
5 changed files with 169 additions and 9 deletions
|
|
@ -9,7 +9,6 @@
|
|||
<link rel="canonical" href="https://banyaro.app/">
|
||||
|
||||
<!-- Preconnect: externe Hosts frühzeitig verbinden -->
|
||||
<link rel="preconnect" href="https://umami.motocamp.de">
|
||||
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://tile.openstreetmap.org">
|
||||
|
||||
|
|
@ -76,6 +75,7 @@
|
|||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Ban Yaro">
|
||||
|
|
@ -93,9 +93,9 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=664">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=664">
|
||||
<link rel="stylesheet" href="/css/components.css?v=664">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=690">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=690">
|
||||
<link rel="stylesheet" href="/css/components.css?v=690">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -562,12 +562,12 @@
|
|||
<script src="/js/api.js?v=94"></script>
|
||||
<script src="/js/ui.js?v=94"></script>
|
||||
<script src="/js/app.js?v=94"></script>
|
||||
<script src="/js/worlds.js?v=664"></script>
|
||||
<script src="/js/worlds.js?v=690"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
|
||||
<script defer src="https://umami.motocamp.de/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f"></script>
|
||||
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
|
||||
|
||||
|
||||
<!-- Offline-Banner Logik -->
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v664';
|
||||
const CACHE_VERSION = 'by-v690';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
@ -202,7 +202,8 @@ self.addEventListener('fetch', event => {
|
|||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
_cacheMark(url.pathname);
|
||||
caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
|
||||
const toCache = resp.clone();
|
||||
caches.open(CACHE_API).then(c => c.put(event.request, toCache));
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
|||
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
|
||||
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
|
||||
"sunrise,sunset"
|
||||
"&hourly=precipitation_probability,precipitation,weathercode"
|
||||
"&timezone=auto&forecast_days=7"
|
||||
)
|
||||
pollen_url = (
|
||||
|
|
@ -245,6 +246,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
|||
raw = forecast_resp.json()
|
||||
|
||||
daily = raw.get('daily', {})
|
||||
hourly_fc = raw.get('hourly', {})
|
||||
timezone = raw.get('timezone', 'auto')
|
||||
|
||||
dates = daily.get('time', [])
|
||||
|
|
@ -261,6 +263,24 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
|||
sunrises = daily.get('sunrise', [])
|
||||
sunsets = daily.get('sunset', [])
|
||||
|
||||
# --- Hourly precipitation data grouped by day ---
|
||||
hourly_times = hourly_fc.get('time', [])
|
||||
hourly_pp = hourly_fc.get('precipitation_probability', [])
|
||||
hourly_precip = hourly_fc.get('precipitation', [])
|
||||
hourly_wcode = hourly_fc.get('weathercode', [])
|
||||
# Build: date_str → list of {hour, precip_prob, precip, weathercode}
|
||||
_hourly_by_day: dict = {}
|
||||
for idx, ts_str in enumerate(hourly_times):
|
||||
day_str = ts_str[:10] # 'YYYY-MM-DD'
|
||||
hour_str = ts_str[11:16] # 'HH:MM'
|
||||
entry = {
|
||||
'hour': hour_str,
|
||||
'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None,
|
||||
'precip': hourly_precip[idx] if idx < len(hourly_precip) else None,
|
||||
'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None,
|
||||
}
|
||||
_hourly_by_day.setdefault(day_str, []).append(entry)
|
||||
|
||||
# --- Pollen (optional) ---
|
||||
pollen_daily: dict | None = None
|
||||
if not isinstance(pollen_resp, Exception):
|
||||
|
|
@ -361,6 +381,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
|||
'zecken': zecken,
|
||||
'thunderstorm': wcode in {95, 96, 99},
|
||||
'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0),
|
||||
'hourly': _hourly_by_day.get(date_str, []),
|
||||
})
|
||||
|
||||
result = {'timezone': timezone, 'days': days}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue