KI-Vision-Model, Breed-Scraper, Karte/Routen + Release v1292
Parallele Arbeit (auf Staging mitgetestet): KI-Vision-Model (VISION_MODEL in ki.py/routes, im KI-Status sichtbar), Breed-Scraper-Anpassungen (breed_enricher/breed_evaluator, evaluate_enrichment mit user_id), Karten-/Routen-Änderungen (map.js, routes.js), kleinere UI-Anpassungen (admin.js, components.css), docker-compose, MARKETING, nav-loop-Test. Version-Bump auf 1292 (VERSION, sw.js, app.js, index.html, landing.html).
This commit is contained in:
parent
51aad6cf1b
commit
f7370028da
17 changed files with 322 additions and 100 deletions
|
|
@ -451,6 +451,9 @@ window.Page_map = (() => {
|
|||
let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit)
|
||||
let _radarPlaying = false;
|
||||
let _radarPlayTimer = null;
|
||||
let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — für sauberen Layer-Wechsel
|
||||
let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewünschter Frame
|
||||
let _rdrRaf = null; // requestAnimationFrame-Handle für die Koaleszenz
|
||||
|
||||
async function _toggleRadar() {
|
||||
if (!App.hasPro(_appState?.user)) {
|
||||
|
|
@ -461,7 +464,9 @@ window.Page_map = (() => {
|
|||
if (_radarActive) {
|
||||
_radarActive = false;
|
||||
_radarPause();
|
||||
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; }
|
||||
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; }
|
||||
if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; }
|
||||
_rdrPendingIdx = null;
|
||||
clearInterval(_radarTimer);
|
||||
document.getElementById('map-radar-timeline')?.remove();
|
||||
btn?.classList.remove('active');
|
||||
|
|
@ -602,67 +607,107 @@ window.Page_map = (() => {
|
|||
async function _loadRadar() {
|
||||
if (!_radarActive || !_map) return;
|
||||
try {
|
||||
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
|
||||
// Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand
|
||||
// (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, Gerätetest 2026-06-09).
|
||||
const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
|
||||
if (!past.length && !nowcast.length) return;
|
||||
_radarHost = data.host || _radarHost;
|
||||
const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
|
||||
|
||||
// Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast)
|
||||
let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
|
||||
// Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast)
|
||||
const WINDOW = 2 * 60 * 60; // 2 h je Seite
|
||||
const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig)
|
||||
|
||||
// DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast,
|
||||
// Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md).
|
||||
// Vergangenheit: RainViewer der letzten 2 h
|
||||
let pastFrames = past
|
||||
.filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec)
|
||||
.map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
|
||||
// "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min)
|
||||
let nowFrame = null;
|
||||
let futureFrames = nowcast
|
||||
.filter(f => f.time > nowSec && f.time <= nowSec + WINDOW)
|
||||
.map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
|
||||
// DWD-Vorhersage (0–120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md)
|
||||
if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) {
|
||||
try {
|
||||
const r = await fetch('/radar/manifest.json', { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
const man = await r.json();
|
||||
const runT = Math.floor(Date.parse(man.run_time_utc) / 1000);
|
||||
// Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback
|
||||
if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) {
|
||||
const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
// Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den
|
||||
// jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus).
|
||||
if (man.frames?.length && Math.abs(nowSec - runT) < 1800) {
|
||||
const dwd = man.frames.map(fr => ({
|
||||
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
|
||||
time: runT + fr.lead_min * 60,
|
||||
lead: fr.lead_min,
|
||||
dwd: true,
|
||||
}));
|
||||
frames = [...pastRv, ...dwd];
|
||||
nowIdx = pastRv.length; // DWD lead 0 = "jetzt"
|
||||
nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt"
|
||||
futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW);
|
||||
pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden
|
||||
}
|
||||
}
|
||||
} catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
|
||||
}
|
||||
|
||||
_radarFrames = frames;
|
||||
_radarNowIdx = nowIdx;
|
||||
// Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt"
|
||||
if (!nowFrame) {
|
||||
if (pastFrames.length) nowFrame = pastFrames.pop();
|
||||
else if (futureFrames.length) nowFrame = futureFrames.shift();
|
||||
}
|
||||
if (!nowFrame) return;
|
||||
|
||||
_radarFrames = [...pastFrames, nowFrame, ...futureFrames];
|
||||
_radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit
|
||||
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
|
||||
_showRadarFrame(_radarIdx);
|
||||
_buildRadarTimeline();
|
||||
} catch { /* still */ }
|
||||
}
|
||||
|
||||
function _radarUrl(idx) {
|
||||
return _radarFrames[idx].url;
|
||||
}
|
||||
|
||||
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
|
||||
function _showRadarFrame(idx) {
|
||||
if (!_radarActive || !_radarFrames[idx]) return;
|
||||
_radarIdx = idx;
|
||||
const url = _radarUrl(idx);
|
||||
const f = _radarFrames[idx];
|
||||
const url = f.url;
|
||||
const kind = f.dwd ? 'dwd' : 'rv';
|
||||
const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar');
|
||||
if (src && src.setTiles) {
|
||||
// setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles).
|
||||
// Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die
|
||||
// alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht).
|
||||
if (src && src.setTiles && kind === _radarLayerKind) {
|
||||
src.setTiles([url]);
|
||||
} else {
|
||||
if (_radarLayer) _wxRemoveRaster(_radarLayer);
|
||||
_radarLayer = _wxAddRaster('radar', url, 0.7, 7);
|
||||
_radarLayerKind = kind;
|
||||
}
|
||||
_updateRadarTimelineUI();
|
||||
}
|
||||
|
||||
// Slider-Position (0–1000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte):
|
||||
// Vergangenheit nutzt die linke, Vorhersage die rechte Hälfte — unabhängig von der
|
||||
// Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt).
|
||||
const RDR_MID = 500, RDR_SNAP = 28;
|
||||
function _radarPosToIdx(pos) {
|
||||
const now = _radarNowIdx, last = _radarFrames.length - 1;
|
||||
if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0;
|
||||
const fut = last - now;
|
||||
return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now;
|
||||
}
|
||||
function _radarIdxToPos(idx) {
|
||||
const now = _radarNowIdx, last = _radarFrames.length - 1;
|
||||
if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID;
|
||||
const fut = last - now;
|
||||
return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID;
|
||||
}
|
||||
|
||||
function _buildRadarTimeline() {
|
||||
if (!_radarFrames.length) return;
|
||||
let el = document.getElementById('map-radar-timeline');
|
||||
|
|
@ -674,17 +719,27 @@ window.Page_map = (() => {
|
|||
<button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg>
|
||||
</button>
|
||||
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="${_radarFrames.length - 1}" value="${_radarIdx}" step="1" aria-label="Radar-Zeit">
|
||||
<div class="rdr-track-wrap">
|
||||
<span class="rdr-now-tick" aria-hidden="true"></span>
|
||||
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="1000" value="${_radarIdxToPos(_radarIdx)}" step="1" aria-label="Radar-Zeit">
|
||||
</div>
|
||||
<span id="rdr-time" class="rdr-time"></span>`;
|
||||
document.getElementById('central-map')?.appendChild(el);
|
||||
el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay);
|
||||
el.querySelector('#rdr-slider').addEventListener('input', e => {
|
||||
const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
|
||||
let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
|
||||
if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt"
|
||||
_radarPause();
|
||||
_showRadarFrame(idx);
|
||||
// Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird
|
||||
// (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam).
|
||||
_rdrPendingIdx = _radarPosToIdx(pos);
|
||||
if (_rdrRaf == null) {
|
||||
_rdrRaf = requestAnimationFrame(() => {
|
||||
_rdrRaf = null;
|
||||
if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; }
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.querySelector('#rdr-slider').max = _radarFrames.length - 1;
|
||||
}
|
||||
// Breite an die Status-Pill angleichen → gleiche linke + rechte Kante.
|
||||
const pill = document.querySelector('.map-statusbar');
|
||||
|
|
@ -696,7 +751,7 @@ window.Page_map = (() => {
|
|||
const slider = document.getElementById('rdr-slider');
|
||||
const timeEl = document.getElementById('rdr-time');
|
||||
const playBtn = document.getElementById('rdr-play');
|
||||
if (slider) slider.value = _radarIdx;
|
||||
if (slider) slider.value = _radarIdxToPos(_radarIdx);
|
||||
if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`);
|
||||
const f = _radarFrames[_radarIdx];
|
||||
if (timeEl && f) {
|
||||
|
|
@ -704,8 +759,8 @@ window.Page_map = (() => {
|
|||
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
|
||||
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`);
|
||||
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`;
|
||||
timeEl.classList.toggle('is-forecast', diffMin > 0);
|
||||
timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht
|
||||
timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1008,6 +1063,14 @@ window.Page_map = (() => {
|
|||
center, zoom, attributionControl: false,
|
||||
maxZoom: 19, dragRotate: false, pitchWithRotate: false,
|
||||
});
|
||||
// setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser
|
||||
// AbortError. Eigener error-Handler verschluckt ihn, lässt echte Fehler aber durch.
|
||||
_map.on('error', (e) => {
|
||||
const err = e && e.error;
|
||||
const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || '');
|
||||
if (/abort/i.test(msg)) return;
|
||||
console.warn('MapLibre:', err || e);
|
||||
});
|
||||
// Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste).
|
||||
_map.touchZoomRotate.disableRotation();
|
||||
_map.touchPitch.disable();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue