# Offline-Karten (GL/Vektor) — Feature-Plan **Status:** Runde 2 (adaptives Modell) umgesetzt — **Staging-Default AN**; Production AUS bis Freigabe. **Stand:** 2026-06-07. Autor: René + Claude (Design). ## Umsetzungsstand (2026-06-06, v1222 auf Staging) **✅ Fertig + headless bewiesen (2026-06-05, v1213):** - `map-offline.js` (`window.MapOffline`): Region-Download (`downloadAround(lat,lon,radiusKm)`) → Vektorkacheln z0–14 via `pmtiles.getZxy` (liefert bereits dekomprimierte MVT) + Glyphs in **IndexedDB** (`by-offline-tiles`). `byt://`-MapLibre-Protokoll (IndexedDB-first, remote-Fallback). ~15 MB / 5 km (dekomprimiert). - `map-gl-style.js` `build({offline})`: `byt`-Source statt `pmtiles://`. - ui.js/map.js laden map-offline + registrieren `byt`. `UI.loadMapLibreUI` exportiert. - Welten-FAB Segment 5: prüft im GL-Modus gespeicherte Region (nicht mehr OSM-Raster); „Fehlende nachladen" stößt `MapOffline.downloadAround(GPS, 5km)` an. - **Beweis:** Download 97 Tiles (5 km München) → Netz AUS → **1903 Features gerendert**, nicht geladene Gegend (Paris) leer; Glyphs nötig (sonst lässt MapLibre offline die ganze Kachel fallen). **✅ Follow-ups Runde 1 (2026-06-06, v1222):** - **Flag-Default Staging-AN:** `by_offline_tiles` Default AN auf `staging.banyaro.app`, AUS sonst; localStorage `1`/`0` bzw. `?tilesoffline=1/0` (boot.js) übersteuert. Default-Logik 3× synchron: `map-gl-style.js _offlineEnabled()`, `offline-indicator.js _offlineTilesMode()`, `pages/map.js _offlineTilesEnabled()`. - **Karten-Download-Button:** Speed-Dial „Karte offline speichern" (`map-offline-btn`, war seit FAB-Redesign verwaist) — GL-Modus → `downloadAround(Kartenmitte, 5 km)` mit Fortschritt in der Statusbar (Kartenmitte statt GPS: Urlaubsort vorab speicherbar); Leaflet-Modus → alter Raster-Prefetch (`_cacheTiles`). Sichtbarkeit gated: GL ohne Offline-Flag (= Production) zeigt den Button nicht. - **Glyph-Persistenz:** Glyphs in IndexedDB (Key-Präfix `f/` im Tiles-Store, kein Schema-Bump) + Protokoll `byt://f/{fontstack}/{range}` (IndexedDB-first, remote-Fallback); Style nutzt offline die byt-Glyph-URL → überlebt App-Updates (SW-Cache wird gepurged, IndexedDB nicht). - **Raster-Prefetch gegated:** `offline-indicator.js init()` überspringt `_prefetchTiles()` im Offline-Tiles-Modus (GL nutzt das OSM-Raster nicht). **✅ Gerätetest-Befunde behoben (2026-06-06, v1223) — Gerätetest iOS BESTANDEN (Basemap+Labels offline ok):** - **POI-Marker offline:** `downloadAround` speichert zusätzlich `/api/osm/pois` (fast=true, liest lokale osm_pois-DB) je Typ für die Region-Bbox in IndexedDB (Key-Präfix `p/`, Merge per id — zweite Region löscht die erste nicht). `MapOffline.pois(type, bbox)` filtert für den Ausschnitt; map.js Phase-1-Catch fällt offline darauf zurück. POI-Typen-Liste in map-offline.js synchron mit `OSM_LAYER_MAP` (pages/map.js) halten! Marker erscheinen erst nach ERNEUTEM Region-Download. - **Offline-Banner** klappt 5 s nach Offline-Gang auf schmale Icon-Leiste ein (volles Banner verdeckte die Karten-Legende); Banner-Styles von index.html-Inline nach components.css konsolidiert. **✅ Runde 2 — adaptives Modell (2026-06-07, Design René 2026-06-06):** - **Budget-Download statt fester Radius:** `downloadAround(lat, lon, {budgetMB:5})` expandiert z14-Ringe (+ Eltern z10–13, Basis z0–9 immer dabei) um den Standort, bis **5 MB GESPEICHERTE Bytes** (dekomprimiert, IndexedDB) erreicht sind → Stadt ~1,5–3 km, Land ~6–10 km Radius — passend zur Funknetzdichte. **CLIENT-seitig — der geplante Server-Region-Extract-Endpoint ist NICHT nötig.** - **Funkloch-Gedächtnis:** Tile-Remote-Miss bei aktivem GPS (map.js Recording speist `MapOffline.setGps`) → `markDeadZone` (Dedupe 2 km, Cap 50, **komplett lokal, nie hochgeladen**). `autoFillDeadZones()` lädt offene Zonen budget-getrieben nach, sobald online (Trigger: offline-indicator init +30 s, `online`-Event +8 s; Vorab-Check ohne GL-Stack-Load). - **Routen-Korridor:** `downloadCorridor(track, {bufferKm:1, capMB:50})` + Button „Offline" im Routen-Detail (`rd-offline`, flag-gated) — Kacheln ±1 km um den Track + Marker der Korridor-Bbox. - **Coverage-Layer:** `MapOffline.coverage()` (GeoJSON der gespeicherten z14-Kacheln) als blauer GL-Fill-Layer; Offline-Button öffnet jetzt ein **Verwaltungs-Modal** (Gebiete/MB/Marker-Stats, Gebiet speichern, Bereiche ein-/ausblenden, Alles löschen per Zweiklick). - Flag-Logik zentralisiert: `boot.js window.BY.offlineTiles()` (vorher 3× dupliziert). - Meta neu: `regions`-Liste (Cap 30) + `deadzones`; `region` (letztes Gebiet) bleibt für Back-Compat. **🔲 Offen (Runde 3):** - **Gerätetest Runde 2** (Budget-Download, Funkloch-Lernen auf echter Gassi-Runde, Korridor, Coverage-Layer) → dann Prod-Freigabe-Entscheidung (BY.offlineTiles-Default erweitern analog `by_map_gl`). - **Rollendes Vorausladen beim Aufzeichnen** (fortlaufend um die aktuelle Position cachen, solange Empfang da — deckt den Weg schon beim ersten Mal ab; Akku-/Datensparsamkeit beachten). - **Bereichsauswahl** (Karten-Ausschnitt/Rechteck als Download-Gebiet) — Korridor deckt den Hauptfall ab, Rest nach Bedarf. - **Speicher-Cap + LRU** über alles (alte Gebiete fliegen automatisch raus) + optional `navigator.storage.persist()`. - Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles` + `map.js _cacheTiles`) komplett entfernen, wenn Flag dauerhaft AN (auch Prod). ## Ziel GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**. Selbst-zielend (cacht wo nötig, nicht überall), speichersparsam, ohne Nutzeraufwand. ## Problem (warum GL aktuell NICHT offline geht) - PMTiles lädt per **HTTP-Range (206 Partial Content)**. Die **Cache-API kann 206 nicht speichern** (`cache.put()` wirft) → Basemap-Kacheln landen nie im Offline-Cache. - SW hat **keine Regel für `/tiles`** (nur für `tile.openstreetmap.org` = altes Raster). - `offline-indicator.js` prefetcht weiterhin **OSM-Raster** (a.tile.openstreetmap.org), das die GL-Karte gar nicht nutzt → doppelter Regress: Raster gecacht das niemand zeigt, GL-Karte offline trotzdem leer. - Folge offline heute: App + Daten da, aber GL-Karte = Routenlinie/Marker auf **leerem Hintergrund**. ## Gemessene Speicher-Fakten (an echter dach.pmtiles, maxzoom=14 + Overzoom bis ~16) **Referenz-Radius = 5 km** (René, 2026-06-05: „5 km genügen"). Messungen: | Gebiet | Fläche | Tiles | Größe | |---|---|---|---| | **München (48,1/11,5), dicht — 5 km** | 10×10 km | 82 | **6,4 MB** | | **Bayerischer Wald, ländlich — 5 km** | 10×10 km | 99 | **2,6 MB** | | (Kontext) München 10 km | 20×20 km | 252 | 15 MB | | (Kontext) Bayerischer Wald 10 km | 20×20 km | 285 | 4,4 MB | | (Kontext) Bayerischer Wald ~25 km | 50×50 km | 1.595 | 20 MB | → Vektor ist ~10× sparsamer als Raster (Raster 5 km ≈ 40–120 MB). Stadt-Tiles ~2,5× dicker als Land. → 5 km ist NICHT 1/4 von 10 km (6,4 vs 15 MB) — die unteren Zoomstufen (Übersicht) sind immer dabei, unabhängig von der bbox-Größe. → **Budget ≈ 7 MB** (5 km dichte Stadt + Glyphs). Budget-getrieben deckt das in der Stadt ~5 km, auf dem **Land ~8–10 km** Radius ab (mehr Reichweite genau dort wo die Funklöcher sind). Glyphs (~1–2 MB) + Style (winzig) → ~8 MB pro Gegend. Sehr sparsam → viele Gegenden problemlos (10 ≈ 80 MB). → Messmethode (reproduzierbar): `docker run --rm -v /tmp/pmt:/out protomaps/go-pmtiles:latest extract https://staging.banyaro.app/tiles/dach.pmtiles /out/x.pmtiles --bbox=W,S,E,N` → Dateigröße ablesen. ## Architektur ### Region-Extract (budget-getrieben, NICHT fester Radius) - PMTiles-**Directory enthält pro Tile die Byte-Länge** → Server kann die Größe einer Region **aufsummieren OHNE die Tiles zu laden**. - Endpoint `GET /tiles/region?lat=&lon=&budget=7` (MB): wächst die bbox um die Position, bis die summierte Tile-Länge ≈ Budget erreicht (Stadt → kleiner Radius, Land → großer Radius), extrahiert dann genau diese Region als `region.pmtiles` (**ein 200er**, ~7 MB). `pmtiles extract` (go-pmtiles) oder python-pmtiles im Container. - Client lädt die Datei einmal → **IndexedDB** (Blob; 200er, anders als die 206-Ranges cachebar). - MapLibre liest **offline** aus dem lokalen Blob via `pmtiles://` (pmtiles.js kann aus ArrayBuffer lesen); **online** weiter remote `dach.pmtiles` (immer aktuell, ganz DACH+Anrainer). Source je nach Verbindung wählen. - Glyphs (`/fonts/*.pbf`, Open Sans Regular+Semibold) mit cachen (200er, cachebar). ### Adaptive Strategie (der eigentliche Clou — lernt von selbst) 1. **Rollendes Vorausladen beim Aufzeichnen:** Solange GPS aktiv UND Empfang da, fortlaufend Tiles um die **aktuelle Position** cachen. Deckt den echten Weg + die Anfahrt automatisch ab — auch beim ersten Mal, bevor man ins Funkloch läuft. 2. **Funkloch-Gedächtnis:** Wo echte Requests **scheitern** (Timeout/Fehler während aktivem GPS — NICHT `navigator.onLine`, das lügt bei Captive-Portal/Schwachempfang), den Bereich als „Offline nötig" markieren → priorisiert behalten, beim nächsten Online-Durchgang großzügiger nachladen. Caveat: im Funkloch selbst kann nicht geladen werden → greift ab dem 2. Besuch (Gassi = repetitiv → ok). 3. **Manuelles Vorab-Laden** („Offline-Inhalte laden"-Button) — zwei Modi: - **Aktuelle Gegend** (Default, Gassi): budget-getrieben um die Position (~7 MB), ein Tipp. - **Bereich auswählen** (mehrtägige Wanderung — Auto-5km reicht da nicht): Nutzer wählt ein größeres Gebiet, das ganz heruntergeladen wird. Auswahl-Optionen: - **Karten-Ausschnitt:** aktuellen Karten-Viewport (durch Zoomen/Verschieben gewählter bbox) als Download-Gebiet nehmen — simpel, kein Zeichnen nötig. - **Rechteck ziehen** auf der Karte (präziser). - **Routen-Korridor:** entlang einer Route ± Puffer (ideal für Touren, die einer Strecke folgen — viel sparsamer als eine große bbox). **Verbindung zum Routen-Feature (WICHTIG, sonst hängt der Modus in der Luft):** Einstieg primär AUS der Route — im Routen-Detail (`routes.js` `_openDetail`, Aktionsleiste neben GPX/Teilen/Navi) und in der Navigations-Ansicht ein Button **„Route offline speichern"**. Nutzt den bereits geladenen `route.gps_track` → Korridor = alle Tiles im Puffer (z.B. 1–2 km) um den Track (nicht die ganze bbox). Zusätzlich im „Offline-Inhalte laden"-Dialog ein Modus **„Aus meinen Routen wählen"** (Liste der gespeicherten Routen → Korridor laden). Größen-Vorschau wie unten, vor dem Download. - **Größen-Vorschau VOR dem Download:** die Tile-Byte-Längen aus der PMTiles-Directory aufsummieren (kein Tile-Download nötig) → „~45 MB" anzeigen, Nutzer bestätigt. Schützt vor versehentlichem Riesen-Download. - Fortschritt + „X MB gespeichert" + Liste gespeicherter Gebiete (umbenennen/löschen/aktualisieren). - Im Bereichs-Modus gilt das ~7-MB-Budget NICHT (Nutzer entscheidet bewusst), aber eine sinnvolle Obergrenze + der globale Speicher-Cap greifen. ### Drumherum - **Budget-Cap + LRU:** Gesamtspeicher gedeckelt; selten besuchte Funkloch-Caches fallen raus. - **Privatsphäre:** „Wo verliere ich Netz" = Aufenthaltsorte → **komplett lokal (IndexedDB), nie hochgeladen.** - **Aktualität:** Offline-Region beim nächsten Online-Sein neu ziehbar (Basemap-Updates / pmtiles-Refresh). - **Aufräumen:** Den alten OSM-Raster-Prefetch in `offline-indicator.js` ablösen/abschalten (cacht ungenutztes Raster). - **Pfoten-Offline-Indikator (Welten-FAB) anpassen:** `offline-indicator.js` füllt 5 Pfoten-Segmente; **Segment 5 „Karten-Kacheln"** prüft aktuell `CACHE_TILES` auf ≥ N **OSM-Raster**-Tiles (TILE_PREFETCH z14/z13). Die GL-Karte nutzt dieses Raster NICHT → grünes Segment = falsches „Karte offline bereit". → Segment 5 umdefinieren: prüfen ob für die aktuelle Gegend eine **`region.pmtiles` + Glyphs** lokal vorliegen (statt Raster-Tile-Count). `offline-fill-btn` („Offline-Inhalte laden") soll dann den Region-Download anstoßen statt den Raster-Prefetch. (Bei Variante 1 „Raster-Fallback" bliebe Segment 5 wie es ist — Entscheidung hängt an der Offline-Strategie oben.) ## Offene Entscheidungen / Defaults - Budget-Default **~7 MB** (Referenz 5 km Stadt; René 2026-06-05). Stadt ~5 km / Land ~8–10 km. Optional Stufe „Groß ~16 MB" (Stadt ~10 km / Land ~18–22 km) für Wandertage. - Zoom z0–14 (Overzoom liefert Straßenebene gratis). - Detektionssignal = echte Fetch-Timeouts bei aktivem GPS (nicht `navigator.onLine`). - Speicher = IndexedDB (Blobs); MapLibre-Source-Umschaltung online/offline. ## Abhängigkeiten - GL-Tiles in **Produktion** (dach.pmtiles + fonts auf Prod-Volume) — Voraussetzung. - pmtiles-Directory-Byte-Summierung (Server) + pmtiles.js Blob-Source (Client). - WebGL-Kontext-Disziplin beachten (siehe Skill/Memory: jede GL-Karte beim Schließen `remove()`). Siehe `docs/TILE_SERVER_HANDOVER.md` (Tile-Pipeline) + Memory `project_tile_server_maintenance`. --- # Weitere Karten-To-Dos (nicht offline-spezifisch) ## Wetter-Chip: Niederschlag „nächste 3 Std" statt ganzer Tag — ✅ ERLEDIGT (2026-06-05, v1214) Umgesetzt: `weather.py get_weather_for_location` → `precip = max(h_precip[now_h:now_h+3])` (Fallback Tages-Max); Pill `map.js` zeigt `💧 X% (3h)`. Gilt auch fürs Welten-Banner (geteiltes Feld). Hinweis: forecast_days=1 → Slice am Tagesende kürzer (späte Nacht weniger Vorausschau); für volle Mitternachts-Abdeckung forecast_days=2. (Original-Notiz:) **Ist:** Der Karten-Chip unten zeigt die Regenwahrscheinlichkeit als **Tages-Maximum** (`backend/weather.py:98` → `precip = daily['precipitation_probability_max'][0]`; angezeigt in `pages/map.js:~2598` als `💧 {w.precip_prob}%`). Über den ganzen Tag gemittelt/maximiert = wenig aussagekräftig für „soll ich JETZT raus". **Soll:** Den **höchsten Wert der nächsten 3 Stunden** (ab aktueller Stunde) zeigen. - Die stündlichen Daten werden in `weather.py` bereits geladen (`&hourly=precipitation_probability`, Array `h_precip` ab Zeile ~116) — kein neuer API-Call nötig. - Ändern: `precip_prob = max(h_precip[now_idx : now_idx+3])` (aktuellen Stundenindex bestimmen wie bei der bestehenden `next_rain_time`-Logik). `next_rain_time`/Warnungen können bleiben. - Optional Chip-Text klarstellen, dass sich der Wert auf die nächsten 3 h bezieht (z.B. `💧 {x}% (3h)`).