- 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
719 lines
29 KiB
JavaScript
719 lines
29 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Wetter (7-Tage-Wettervorhersage)
|
|
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
|
|
============================================================ */
|
|
|
|
window.Page_wetter = (() => {
|
|
|
|
// ----------------------------------------------------------
|
|
// KONSTANTEN
|
|
// ----------------------------------------------------------
|
|
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
|
|
const WMO_ICON = {
|
|
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
|
|
45:'cloud-fog', 48:'cloud-fog',
|
|
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
|
|
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
|
|
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
|
|
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
|
|
85:'cloud-snow', 86:'cloud-snow',
|
|
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
|
|
};
|
|
// Farben passend zum Wetter (für Icon-Tinting)
|
|
const WMO_COLOR = {
|
|
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
|
|
45:'#94A3B8', 48:'#94A3B8',
|
|
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
|
|
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
|
|
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
|
|
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
|
|
85:'#7DD3FC', 86:'#38BDF8',
|
|
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
|
|
};
|
|
function _wmoIcon(code, size = '2rem', extraStyle = '') {
|
|
const name = WMO_ICON[code] || 'cloud';
|
|
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
|
|
return `<svg class="ph-icon" aria-hidden="true"
|
|
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
|
|
<use href="/icons/phosphor.svg#${name}"></use>
|
|
</svg>`;
|
|
}
|
|
|
|
const WMO_DESC = {
|
|
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
|
|
45:'Nebel', 48:'Gefrierender Nebel',
|
|
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
|
|
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
|
|
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
|
|
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
|
|
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
|
|
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
|
|
};
|
|
|
|
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
|
|
|
// ----------------------------------------------------------
|
|
// MODUL-STATE
|
|
// ----------------------------------------------------------
|
|
let _container = null;
|
|
let _appState = null;
|
|
let _data = null;
|
|
let _selDay = 0;
|
|
let _loading = false;
|
|
|
|
// ----------------------------------------------------------
|
|
// INIT
|
|
// ----------------------------------------------------------
|
|
async function init(container, appState) {
|
|
_container = container;
|
|
_appState = appState;
|
|
_selDay = 0;
|
|
_renderShell();
|
|
_tryAutoLocate();
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// REFRESH
|
|
// ----------------------------------------------------------
|
|
async function refresh() {
|
|
_selDay = 0;
|
|
_renderShell();
|
|
_tryAutoLocate();
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// RENDER — Grundstruktur
|
|
// ----------------------------------------------------------
|
|
function _renderShell() {
|
|
_container.innerHTML = `
|
|
<div id="wttr-body">
|
|
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
|
|
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
|
|
<p style="color:var(--c-text-secondary)">Standort wird ermittelt…</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// STANDORT AUTOMATISCH ERMITTELN
|
|
// ----------------------------------------------------------
|
|
async function _tryAutoLocate() {
|
|
try {
|
|
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
|
|
await _loadData(pos.lat, pos.lon);
|
|
} catch {
|
|
_showLocationError();
|
|
}
|
|
}
|
|
|
|
function _showLocationError() {
|
|
const body = _container.querySelector('#wttr-body');
|
|
if (!body) return;
|
|
body.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
|
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
|
|
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
|
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
|
|
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
|
|
</p>
|
|
<button class="btn btn-primary" id="wttr-btn-retry">
|
|
${UI.icon('map-pin')} Nochmal versuchen
|
|
</button>
|
|
</div>
|
|
`;
|
|
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
|
|
_renderShell();
|
|
_tryAutoLocate();
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// DATEN LADEN
|
|
// ----------------------------------------------------------
|
|
async function _loadData(lat, lon) {
|
|
if (_loading) return;
|
|
_loading = true;
|
|
try {
|
|
_data = await API.weather.forecast(lat, lon);
|
|
_selDay = 0;
|
|
_renderWeather();
|
|
} catch {
|
|
const body = _container.querySelector('#wttr-body');
|
|
if (body) body.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
|
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">⚠️</div>
|
|
<h3 style="margin-bottom:var(--space-2)">Wetter nicht verfügbar</h3>
|
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
|
|
Die Wetterdaten konnten nicht geladen werden.
|
|
</p>
|
|
<button class="btn btn-primary" id="wttr-btn-reload">
|
|
${UI.icon('arrow-clockwise')} Erneut laden
|
|
</button>
|
|
</div>
|
|
`;
|
|
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
|
|
refresh();
|
|
});
|
|
} finally {
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// HAUPT-RENDER
|
|
// ----------------------------------------------------------
|
|
function _renderWeather() {
|
|
const body = _container.querySelector('#wttr-body');
|
|
if (!body || !_data) return;
|
|
|
|
const days = _data.days || [];
|
|
if (!days.length) return;
|
|
|
|
body.innerHTML = `
|
|
<!-- 7-Tage-Strip -->
|
|
<div id="wttr-strip-wrap"
|
|
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
|
|
margin-bottom:var(--space-4);
|
|
scrollbar-width:none">
|
|
<div id="wttr-strip"
|
|
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
|
|
${days.map((d, i) => _dayCard(d, i)).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail-Card -->
|
|
<div id="wttr-detail" class="section-card"
|
|
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>
|
|
`;
|
|
|
|
// Strip-Klick-Events
|
|
body.querySelectorAll('[data-wttr-day]').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
_selDay = parseInt(card.dataset.wttrDay);
|
|
_updateStrip();
|
|
_renderDetail();
|
|
_renderRainTimeline();
|
|
_renderDog();
|
|
});
|
|
});
|
|
|
|
_renderDetail();
|
|
_renderRainTimeline();
|
|
_renderDog();
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// STRIP AKTUALISIEREN (aktiver Tag)
|
|
// ----------------------------------------------------------
|
|
function _updateStrip() {
|
|
const body = _container.querySelector('#wttr-body');
|
|
if (!body) return;
|
|
const days = _data?.days || [];
|
|
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
|
|
const active = i === _selDay;
|
|
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
|
card.style.color = active ? '#fff' : 'var(--c-text)';
|
|
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
|
card.style.transform = active ? 'translateY(-2px)' : '';
|
|
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
|
|
// Temperatur-Farbe im aktiven Zustand
|
|
const tempEl = card.querySelector('.wttr-temp');
|
|
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
|
const precipEl = card.querySelector('.wttr-precip');
|
|
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// TAG-KARTE (Strip)
|
|
// ----------------------------------------------------------
|
|
function _dayCard(d, i) {
|
|
const active = i === _selDay;
|
|
const dateObj = new Date(d.date);
|
|
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
|
|
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
|
const col = active ? '#fff' : 'var(--c-text)';
|
|
const shadow = active
|
|
? '0 4px 12px rgba(196,132,58,0.3)'
|
|
: '0 1px 3px rgba(0,0,0,0.07)';
|
|
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
|
|
const transform = active ? 'translateY(-2px)' : '';
|
|
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
|
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
|
|
|
return `
|
|
<div data-wttr-day="${i}"
|
|
style="display:flex;flex-direction:column;align-items:center;
|
|
min-width:72px;padding:var(--space-3) var(--space-2);
|
|
border-radius:var(--radius);border:1.5px solid ${border};
|
|
background:${bg};color:${col};cursor:pointer;
|
|
box-shadow:${shadow};transform:${transform};
|
|
transition:all .15s;user-select:none">
|
|
<span style="font-size:var(--text-xs);font-weight:600;
|
|
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
|
|
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
|
|
<span class="wttr-temp"
|
|
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
|
|
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
|
|
</span>
|
|
<span class="wttr-precip"
|
|
style="font-size:10px;color:${textMut};margin-top:2px">
|
|
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
|
|
</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// DETAIL-CARD
|
|
// ----------------------------------------------------------
|
|
function _renderDetail() {
|
|
const el = _container.querySelector('#wttr-detail');
|
|
if (!el || !_data) return;
|
|
const d = (_data.days || [])[_selDay];
|
|
if (!d) return;
|
|
|
|
const desc = WMO_DESC[d.weathercode] || '';
|
|
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
|
|
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
|
|
const bft = _beaufort(d.wind_kmh ?? 0);
|
|
const windDir = d.wind_dir_deg ?? 0;
|
|
const compass = d.wind_dir ?? _compass(windDir);
|
|
|
|
// Sunrise/Sunset Balken
|
|
const now = new Date();
|
|
const sunriseStr = d.sunrise || '';
|
|
const sunsetStr = d.sunset || '';
|
|
let sunPct = 0;
|
|
if (sunriseStr && sunsetStr) {
|
|
const [rH, rM] = sunriseStr.split(':').map(Number);
|
|
const [sH, sM] = sunsetStr.split(':').map(Number);
|
|
const riseMin = rH * 60 + rM;
|
|
const setMin = sH * 60 + sM;
|
|
const curMin = now.getHours() * 60 + now.getMinutes();
|
|
sunPct = _selDay === 0
|
|
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
|
|
: 0;
|
|
}
|
|
|
|
el.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
|
${_wmoIcon(d.weathercode, '3.5rem')}
|
|
<div>
|
|
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
|
|
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
|
|
${Math.round(d.temp_max)}°
|
|
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
|
|
/ ${Math.round(d.temp_min)}°
|
|
</span>
|
|
</div>
|
|
${d.feels_max != null ? `
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
|
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sonnenaufgang / -untergang -->
|
|
${sunriseStr && sunsetStr ? `
|
|
<div style="margin-bottom:var(--space-4)">
|
|
<div style="display:flex;justify-content:space-between;
|
|
font-size:var(--text-xs);color:var(--c-text-secondary);
|
|
margin-bottom:var(--space-1)">
|
|
<span style="display:flex;align-items:center;gap:4px">
|
|
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
|
|
${_esc(sunriseStr)}
|
|
</span>
|
|
<span style="display:flex;align-items:center;gap:4px">
|
|
${_esc(sunsetStr)}
|
|
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
|
|
</span>
|
|
</div>
|
|
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
|
<div style="height:100%;width:${sunPct}%;
|
|
background:linear-gradient(90deg,#f97316,#facc15);
|
|
border-radius:999px;transition:width .4s"></div>
|
|
</div>
|
|
</div>` : ''}
|
|
|
|
<!-- Wind -->
|
|
<div style="display:flex;align-items:center;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius);
|
|
background:var(--c-bg-card);border:1px solid var(--c-border);
|
|
margin-bottom:var(--space-3)">
|
|
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
|
|
${UI.icon('arrow-up')}
|
|
</span>
|
|
<div style="flex:1">
|
|
<div style="font-size:var(--text-sm);font-weight:600">
|
|
${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h
|
|
</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(bft)}</div>
|
|
</div>
|
|
${d.precip_sum != null ? `
|
|
<div style="text-align:right">
|
|
<div style="font-size:var(--text-sm);font-weight:600">
|
|
${d.precip_sum} mm
|
|
</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Niederschlag</div>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
<!-- UV-Index -->
|
|
<div>
|
|
<div style="display:flex;justify-content:space-between;
|
|
font-size:var(--text-xs);margin-bottom:4px">
|
|
<span style="color:var(--c-text-secondary)">UV-Index</span>
|
|
<span style="font-weight:600;color:${uvColor}">
|
|
${d.uv_index ?? 0} — ${_esc(uvLabel)}
|
|
</span>
|
|
</div>
|
|
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
|
<div style="height:100%;width:${uvPct}%;background:${uvColor};
|
|
border-radius:999px;transition:width .4s"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// 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
|
|
// ----------------------------------------------------------
|
|
function _renderDog() {
|
|
const el = _container.querySelector('#wttr-dog');
|
|
if (!el || !_data) return;
|
|
const d = (_data.days || [])[_selDay];
|
|
if (!d) return;
|
|
|
|
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
|
|
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
|
|
margin-bottom:var(--space-4)">
|
|
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
|
Hunde-Wetter
|
|
</h3>`;
|
|
|
|
// Asphalt-Temperatur
|
|
if (d.asphalt_temp != null) {
|
|
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
|
|
html += `
|
|
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius);
|
|
background:${aspColor}1a;border:1px solid ${aspColor}55;
|
|
margin-bottom:var(--space-3)">
|
|
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
|
<div style="flex:1">
|
|
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
|
|
Asphalt ~${Math.round(d.asphalt_temp)}°C — ${_esc(aspText)}
|
|
</div>
|
|
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
|
${_esc(aspAdvice)}
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Pfoten-Kälteschutz
|
|
if (d.paw_cold) {
|
|
html += `
|
|
<div style="display:flex;align-items:center;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius);
|
|
background:#3b82f61a;border:1px solid #3b82f655;
|
|
margin-bottom:var(--space-3)">
|
|
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
|
|
<div style="font-size:var(--text-sm)">
|
|
<strong>Kälteschutz für Pfoten:</strong>
|
|
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Gewitter
|
|
if (d.thunderstorm) {
|
|
html += `
|
|
<div style="display:flex;align-items:center;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius);
|
|
background:#f59e0b1a;border:1px solid #f59e0b55;
|
|
margin-bottom:var(--space-3)">
|
|
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
|
|
<div style="font-size:var(--text-sm)">
|
|
<strong>Gewitter erwartet:</strong>
|
|
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Pollenflug
|
|
const pollen = d.pollen;
|
|
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
|
|
const pollenEntries = Object.entries(pollen)
|
|
.filter(([, v]) => v != null && v.level > 0);
|
|
if (pollenEntries.length) {
|
|
html += `
|
|
<div style="margin-bottom:var(--space-3)">
|
|
<div style="font-size:var(--text-xs);font-weight:600;
|
|
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
|
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
|
|
Pollenflug
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
|
${pollenEntries.map(([key, lvlObj]) => {
|
|
const col = _pollenColor(lvlObj?.level ?? 0);
|
|
const name = _POLLEN_NAMES[key] || key;
|
|
const lbl = lvlObj?.label || '';
|
|
return `<span style="display:inline-flex;align-items:center;gap:4px;
|
|
font-size:var(--text-xs);border-radius:999px;
|
|
padding:3px 10px;background:${col}22;
|
|
border:1px solid ${col}55;color:${col};font-weight:600">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
|
|
${_esc(name)}: ${_esc(lbl)}
|
|
</span>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Zecken
|
|
if (d.zecken != null) {
|
|
const [tickLabel, tickColor] = _tickLevel(d.zecken);
|
|
html += `
|
|
<div style="display:flex;align-items:center;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius);
|
|
background:${tickColor}1a;border:1px solid ${tickColor}55;
|
|
margin-bottom:var(--space-3)">
|
|
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
|
|
<div style="flex:1">
|
|
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
|
|
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
|
|
${_esc(tickLabel)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Wenn keine Hunde-Daten vorhanden
|
|
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
|
|
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
|
|
html += `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
|
Keine besonderen Hinweise für heute.
|
|
</p>
|
|
`;
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// HILFSFUNKTIONEN — Wetter
|
|
// ----------------------------------------------------------
|
|
function _beaufort(kmh) {
|
|
if (kmh < 2) return 'Windstille';
|
|
if (kmh < 12) return 'leicht';
|
|
if (kmh < 29) return 'mäßig';
|
|
if (kmh < 50) return 'frisch';
|
|
if (kmh < 62) return 'stark';
|
|
if (kmh < 75) return 'stürmisch';
|
|
return 'Sturm';
|
|
}
|
|
|
|
function _uvLabel(uv) {
|
|
if (uv <= 2) return ['niedrig', '#4CAF50'];
|
|
if (uv <= 5) return ['mittel', '#FFC107'];
|
|
if (uv <= 7) return ['hoch', '#FF9800'];
|
|
if (uv <= 10) return ['sehr hoch', '#F44336'];
|
|
return ['extrem', '#9C27B0'];
|
|
}
|
|
|
|
function _compass(deg) {
|
|
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
|
|
return dirs[Math.round(deg / 45) % 8];
|
|
}
|
|
|
|
function _asphaltLevel(temp) {
|
|
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
|
|
if (temp < 50) return ['leicht erwärmt', '#FFC107',
|
|
'Kurze Kontaktzeiten sind unbedenklich.'];
|
|
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
|
|
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
|
|
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
|
|
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
|
|
}
|
|
|
|
function _pollenColor(level) {
|
|
if (level === 0) return '#9E9E9E';
|
|
if (level === 1) return '#4CAF50';
|
|
if (level === 2) return '#FFC107';
|
|
if (level === 3) return '#FF9800';
|
|
return '#F44336'; // level 4+
|
|
}
|
|
|
|
function _tickLevel(risk) {
|
|
const r = (risk || '').toLowerCase();
|
|
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
|
|
if (r === 'mittel') return ['mittel', '#FF9800'];
|
|
return ['hoch', '#F44336'];
|
|
}
|
|
|
|
function _esc(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// PUBLIC API
|
|
// ----------------------------------------------------------
|
|
return { init, refresh };
|
|
|
|
})();
|