DWD-Regenvorhersage: Pipeline + /radar-Route + Timeline-Integration + Settings-Toggle
PoC BESTANDEN (tools/dwd-radar/poc): Anker (9E,51N) = Pixel-Mitte (470/600),
Ecken decken sich mit der DWD-DE1200-Spec — Georeferenzierung bewiesen.
- tools/dwd-radar: RV-Komposit (25 Frames, 0-120min) -> kolorierte RGBA-
PMTiles z4-7 je Frame (MapLibre overzoomt darueber) + manifest.json,
atomarer Swap, KEEP_RUNS-Aufraeumen; 25 Frames in ~14s lokal
- docker-compose.dwd.yml (DSM-Cron alle 5 min, NIE --remove-orphans)
- main.py: /radar/manifest.json (no-store) + /radar/{run}/{file} (Range/206,
immutable — Run-Id im Pfad); sw.js: /radar/ pass-through
- map.js: Radar-Frames heterogen ({url,time,dwd}) — DWD ersetzt RainViewer-
Nowcast (0-120min, 5-min-Schritte) wenn Toggle an + GL + Karte in DE +
Manifest frisch (<30min); sonst RainViewer-Fallback; Label '+X Min - DWD'
- settings.js: Toggle 'DWD-Regenvorhersage' (by_dwd_radar, Default AN)
- pytest 39 passed
Bump v1240
This commit is contained in:
parent
6a06c9be7e
commit
5330681059
17 changed files with 4685 additions and 23 deletions
|
|
@ -387,6 +387,60 @@ _FONTS_DIR = os.getenv("FONTS_DIR", os.path.join(_TILES_DIR, "fonts"))
|
|||
if os.path.isdir(_FONTS_DIR):
|
||||
app.mount("/fonts", StaticFiles(directory=_FONTS_DIR), name="fonts")
|
||||
|
||||
# DWD-Regenvorhersage (tools/dwd-radar, Cron alle 5 Min): run-<id>/rv_*.pmtiles + manifest.json.
|
||||
_RADAR_DIR = os.getenv("RADAR_DIR", "/data/radar")
|
||||
|
||||
|
||||
@app.get("/radar/manifest.json")
|
||||
async def radar_manifest():
|
||||
path = os.path.join(_RADAR_DIR, "manifest.json")
|
||||
if not os.path.isfile(path):
|
||||
return Response(status_code=404)
|
||||
# Immer frisch: der Inhalt wechselt alle 5 Min bei GLEICHER URL
|
||||
return FileResponse(path, media_type="application/json",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.api_route("/radar/{run}/{filename}", methods=["GET", "HEAD"])
|
||||
async def serve_radar(run: str, filename: str, request: Request):
|
||||
# Kein Path-Traversal; Run-Verzeichnis ist content-stabil (Run-Id im Pfad) → lange cachebar.
|
||||
for part in (run, filename):
|
||||
if "/" in part or "\\" in part or ".." in part:
|
||||
return Response(status_code=404)
|
||||
path = os.path.join(_RADAR_DIR, run, filename)
|
||||
if not os.path.isfile(path):
|
||||
return Response(status_code=404)
|
||||
file_size = os.path.getsize(path)
|
||||
_etag = f'"{file_size:x}-{int(os.path.getmtime(path)):x}"'
|
||||
base_headers = {"Accept-Ranges": "bytes", "ETag": _etag,
|
||||
"Cache-Control": "public, max-age=3600, immutable"}
|
||||
if request.method == "HEAD":
|
||||
return Response(status_code=200, media_type="application/octet-stream",
|
||||
headers={**base_headers, "Content-Length": str(file_size)})
|
||||
range_header = request.headers.get("range")
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
rng = range_header[6:].split(",")[0]
|
||||
start_s, _, end_s = rng.partition("-")
|
||||
try:
|
||||
if start_s == "":
|
||||
length = int(end_s)
|
||||
start = max(0, file_size - length)
|
||||
end = file_size - 1
|
||||
else:
|
||||
start = int(start_s)
|
||||
end = int(end_s) if end_s else file_size - 1
|
||||
except ValueError:
|
||||
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
|
||||
end = min(end, file_size - 1)
|
||||
if start > end or start >= file_size:
|
||||
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
|
||||
with open(path, "rb") as f:
|
||||
f.seek(start)
|
||||
data = f.read(end - start + 1)
|
||||
return Response(data, status_code=206, media_type="application/octet-stream",
|
||||
headers={**base_headers, "Content-Range": f"bytes {start}-{end}/{file_size}"})
|
||||
return FileResponse(path, media_type="application/octet-stream", headers=base_headers)
|
||||
|
||||
@app.api_route("/tiles/{filename}", methods=["GET", "HEAD"])
|
||||
async def serve_tile(filename: str, request: Request):
|
||||
# Kein Path-Traversal
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1239"></script>
|
||||
<script src="/js/boot-early.js?v=1240"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1239">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1239">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1239">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1239">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1239">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1240">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1240">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1240">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1240">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1240">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -612,11 +612,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1239"></script>
|
||||
<script src="/js/ui.js?v=1239"></script>
|
||||
<script src="/js/app.js?v=1239"></script>
|
||||
<script src="/js/worlds.js?v=1239"></script>
|
||||
<script src="/js/offline-indicator.js?v=1239"></script>
|
||||
<script src="/js/api.js?v=1240"></script>
|
||||
<script src="/js/ui.js?v=1240"></script>
|
||||
<script src="/js/app.js?v=1240"></script>
|
||||
<script src="/js/worlds.js?v=1240"></script>
|
||||
<script src="/js/offline-indicator.js?v=1240"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1239"></script>
|
||||
<script src="/js/boot.js?v=1240"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1239'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1240'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -584,17 +584,57 @@ window.Page_map = (() => {
|
|||
document.getElementById('central-map')?.appendChild(el);
|
||||
}
|
||||
|
||||
// DWD-Regenvorhersage (Settings-Toggle, Default AN) — nur im GL-Modus sinnvoll
|
||||
// (PMTiles-Protokoll) und innerhalb der DE1200-Abdeckung.
|
||||
function _dwdEnabled() {
|
||||
try { return localStorage.getItem('by_dwd_radar') !== '0'; } catch (e) { return true; }
|
||||
}
|
||||
function _mapInDwdCoverage() {
|
||||
try {
|
||||
const c = _map.getCenter(); // beide Engines: {lat, lng}
|
||||
return c.lng >= 1.5 && c.lng <= 18.7 && c.lat >= 45.7 && c.lat <= 56.2;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function _loadRadar() {
|
||||
if (!_radarActive || !_map) return;
|
||||
try {
|
||||
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
|
||||
const frames = [...past, ...nowcast];
|
||||
if (!frames.length) return;
|
||||
_radarHost = data.host || _radarHost;
|
||||
_radarFrames = frames.map(f => ({ path: f.path, time: f.time }));
|
||||
_radarNowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
|
||||
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
|
||||
|
||||
// DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast,
|
||||
// Vergangenheit bleibt RainViewer (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 }));
|
||||
const dwd = man.frames.map(fr => ({
|
||||
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
|
||||
time: runT + fr.lead_min * 60,
|
||||
dwd: true,
|
||||
}));
|
||||
frames = [...pastRv, ...dwd];
|
||||
nowIdx = pastRv.length; // DWD lead 0 = "jetzt"
|
||||
}
|
||||
}
|
||||
} catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
|
||||
}
|
||||
|
||||
_radarFrames = frames;
|
||||
_radarNowIdx = nowIdx;
|
||||
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
|
||||
_showRadarFrame(_radarIdx);
|
||||
_buildRadarTimeline();
|
||||
|
|
@ -602,7 +642,7 @@ window.Page_map = (() => {
|
|||
}
|
||||
|
||||
function _radarUrl(idx) {
|
||||
return `${_radarHost}${_radarFrames[idx].path}/256/{z}/{x}/{y}/4/1_1.png`;
|
||||
return _radarFrames[idx].url;
|
||||
}
|
||||
|
||||
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
|
||||
|
|
@ -661,7 +701,7 @@ 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}`;
|
||||
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`;
|
||||
timeEl.classList.toggle('is-forecast', diffMin > 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -825,6 +825,30 @@ window.Page_settings = (() => {
|
|||
</div>`;
|
||||
})()}
|
||||
|
||||
<!-- DWD-Regenvorhersage (Deutschland) — speist die Karten-Radar-Timeline -->
|
||||
<div class="settings-toggle-row">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#cloud-rain"></use></svg>
|
||||
<div class="settings-toggle-label">
|
||||
<div style="font-weight:500">DWD-Regenvorhersage</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
2-Stunden-Vorhersage vom Deutschen Wetterdienst im Regenradar (nur Deutschland)
|
||||
</div>
|
||||
</div>
|
||||
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
|
||||
<input type="checkbox" id="toggle-dwd-radar"
|
||||
style="opacity:0;width:0;height:0;position:absolute"
|
||||
${localStorage.getItem('by_dwd_radar') !== '0' ? 'checked' : ''}>
|
||||
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
|
||||
background:${localStorage.getItem('by_dwd_radar') !== '0' ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
|
||||
id="toggle-dwd-radar-track"></span>
|
||||
<span id="toggle-dwd-radar-thumb"
|
||||
style="position:absolute;top:2px;left:${localStorage.getItem('by_dwd_radar') !== '0' ? '22px' : '2px'};
|
||||
width:20px;height:20px;border-radius:50%;
|
||||
background:#fff;transition:.2s;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Goldene Gassi-Stunde -->
|
||||
<div class="settings-toggle-row" style="border-bottom:none">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||||
|
|
@ -1583,6 +1607,19 @@ window.Page_settings = (() => {
|
|||
: 'Pocket-Modus deaktiviert.');
|
||||
});
|
||||
|
||||
// DWD-Regenvorhersage (Default AN) — ausgewertet in pages/map.js _dwdEnabled()
|
||||
document.getElementById('toggle-dwd-radar')?.addEventListener('change', e => {
|
||||
const on = e.target.checked;
|
||||
localStorage.setItem('by_dwd_radar', on ? '1' : '0');
|
||||
const track = document.getElementById('toggle-dwd-radar-track');
|
||||
const thumb = document.getElementById('toggle-dwd-radar-thumb');
|
||||
if (track) track.style.background = on ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
if (thumb) thumb.style.left = on ? '22px' : '2px';
|
||||
UI.toast.info(on
|
||||
? 'DWD-Regenvorhersage aktiviert — 2h-Vorhersage im Regenradar (Deutschland).'
|
||||
: 'DWD-Regenvorhersage deaktiviert — Regenradar nutzt RainViewer.');
|
||||
});
|
||||
|
||||
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
|
||||
const enabled = e.target.checked;
|
||||
const track = document.getElementById('toggle-notes-ki-track');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1239"></script>
|
||||
<script src="/js/landing-init.js?v=1240"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1239';
|
||||
const VER = '1240';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
@ -296,6 +296,10 @@ self.addEventListener('fetch', event => {
|
|||
return;
|
||||
}
|
||||
|
||||
// DWD-Regenradar: NICHT abfangen — manifest.json wechselt alle 5 Min bei gleicher URL
|
||||
// (no-store vom Server) und die PMTiles-Range-Requests (206) sind eh nicht cachebar.
|
||||
if (url.pathname.startsWith('/radar/')) return;
|
||||
|
||||
// API-Calls mit Timeout, Caching und Write-Queue
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const method = event.request.method;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue