From 796ce53db3cc6987c365998b14e397f529a39c2c Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sun, 19 Jul 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(offline):=20Offline-Karten=20=E2=80=93=20E?= =?UTF-8?q?insatzgebiet=20wird=20automatisch=20vorgeladen=20(v1.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der härteste Einsatztauglich-Punkt: bisher war die Karte ohne Empfang nur dort verfügbar, wo man schon einmal hingezoomt hatte. Jetzt: - Beim Öffnen eines Einsatzes werden die OSM-Kacheln des Einsatzgebiets im Hintergrund vorgeladen (Zoom 13-18, ~800m Radius, max. 400 Kacheln) - Exakt das MapLibre-Subdomain-Schema (x+y)%3 → Cache-Keys stimmen überein - Bereits gecachte Kacheln werden übersprungen (schont OSM-Server + Akku) - Parallelität 6, Start 4s nach Projektladen (Karte rendert zuerst) - Marker pro Einsatz in localStorage, Auffrischung nach 14 Tagen - Toast "Karte offline verfügbar" nach Abschluss - Neue Bibliothek src/lib/offline-tiles.ts (Web-Mercator-Kachelmathematik) Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 9 +++ package.json | 2 +- src/app/app/page.tsx | 28 +++++++ src/lib/offline-tiles.ts | 156 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/lib/offline-tiles.ts diff --git a/ROADMAP.md b/ROADMAP.md index 69de48a..1916643 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,6 +88,15 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen, Modul-Daten in Rapport-PDF übernehmen +## Zusatz – Offline-Karten ✅ v1.5.1 + +- [x] Kacheln des Einsatzgebiets werden beim Öffnen eines Einsatzes automatisch + vorgeladen (Zoom 13–18, ~800 m Radius, max. 400 Kacheln, Parallelität 6) +- [x] Nutzt exakt das MapLibre-Subdomain-Schema `(x+y) % 3` → Cache-Keys passen +- [x] Bereits gecachte Kacheln werden übersprungen; Marker pro Einsatz (14 Tage) +- [x] SW cachte Kacheln schon Cache-First — jetzt auch nie besuchte Bereiche da +- Offen (später): Satellit/Swisstopo vorladen, grösserer Radius wählbar + ## Phase 5 – Kür - [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅ diff --git a/package.json b/package.json index c627871..832ca57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.5.0", + "version": "1.5.1", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index b203da8..55d61ee 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -26,6 +26,7 @@ import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' import { useMapExport } from '@/hooks/use-map-export' import { useAutoSave } from '@/hooks/use-auto-save' import { useOfflineSync } from '@/hooks/use-offline-sync' +import { preloadTilesForArea, shouldPreloadForProject, markPreloadedForProject } from '@/lib/offline-tiles' import { useRealtimeSync } from '@/hooks/use-realtime-sync' import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types' import { useToolStore } from '@/stores/tool-store' @@ -411,6 +412,33 @@ export default function AppPage() { featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0 }, [currentProject?.id]) + // Offline-Karten: Kacheln des Einsatzgebiets im Hintergrund vorladen, + // damit die Karte auch ohne Empfang (Keller, Funkloch) vollständig da ist. + useEffect(() => { + const proj = currentProject + if (!proj?.id || typeof navigator === 'undefined' || !navigator.onLine) return + const center = (proj as any).mapCenter + if (!center?.lng || !center?.lat) return + if (!shouldPreloadForProject(proj.id)) return + + let cancelled = false + // Karte zuerst rendern lassen, dann im Hintergrund vorladen + const timer = setTimeout(async () => { + try { + const result = await preloadTilesForArea(center.lng, center.lat) + if (cancelled || result.skipped) return + markPreloadedForProject(proj.id) + if (result.cached > 0) { + toast({ + title: 'Karte offline verfügbar', + description: `${result.cached} Kartenkacheln für das Einsatzgebiet gespeichert.`, + }) + } + } catch { /* Vorladen ist Komfort — Fehler still ignorieren */ } + }, 4000) + return () => { cancelled = true; clearTimeout(timer) } + }, [currentProject?.id, toast]) + // Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => { setFeatures(serverFeatures) diff --git a/src/lib/offline-tiles.ts b/src/lib/offline-tiles.ts new file mode 100644 index 0000000..26a877b --- /dev/null +++ b/src/lib/offline-tiles.ts @@ -0,0 +1,156 @@ +/** + * Offline-Karten: Kacheln des Einsatzgebiets vorladen. + * + * Der Service Worker (public/sw.js) cacht Karten-Kacheln bereits Cache-First — + * aber nur solche, die man schon einmal angeschaut hat. Diese Bibliothek lädt + * beim Öffnen eines Einsatzes das Gebiet rund um den Einsatzort proaktiv, + * damit die Karte auch ohne Empfang (Keller, Funkloch) vollständig da ist. + * + * Wichtig: MapLibre wählt die OSM-Subdomain deterministisch über + * (x + y) % anzahl — das Vorladen nutzt exakt dieselbe Formel, damit die + * Cache-Keys übereinstimmen. + */ + +// Muss mit TILE_CACHE in public/sw.js übereinstimmen (Fallback, falls der +// Service Worker die Seite (noch) nicht kontrolliert) +const TILE_CACHE = 'lageplan-tiles-v3' + +const OSM_SUBDOMAINS = ['a', 'b', 'c'] + +/** Web-Mercator: Länge/Breite → Kachel-Koordinaten */ +function lonToTileX(lon: number, z: number): number { + return Math.floor(((lon + 180) / 360) * Math.pow(2, z)) +} +function latToTileY(lat: number, z: number): number { + const rad = (lat * Math.PI) / 180 + return Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * Math.pow(2, z)) +} + +/** Kachelbreite in Metern am gegebenen Breitengrad */ +function tileSizeMeters(lat: number, z: number): number { + return (40075016.686 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, z) +} + +export interface PreloadResult { + total: number + cached: number + failed: number + skipped: boolean +} + +export interface PreloadOptions { + /** Radius um den Einsatzort in Metern (Standard 800) */ + radiusM?: number + /** Zoom-Bereich (Standard 13–18) */ + minZoom?: number + maxZoom?: number + /** Obergrenze Kachelanzahl als Schutz (Standard 400) */ + maxTiles?: number + /** Fortschritts-Callback (0..1) */ + onProgress?: (done: number, total: number) => void +} + +/** Alle Kachel-URLs für das Gebiet berechnen (OSM, MapLibre-Subdomain-Schema) */ +function computeTileUrls(lng: number, lat: number, opts: Required>): string[] { + const urls: string[] = [] + for (let z = opts.minZoom; z <= opts.maxZoom; z++) { + const sizeM = tileSizeMeters(lat, z) + const tileRadius = Math.max(1, Math.ceil(opts.radiusM / sizeM)) + const cx = lonToTileX(lng, z) + const cy = latToTileY(lat, z) + const max = Math.pow(2, z) - 1 + for (let x = cx - tileRadius; x <= cx + tileRadius; x++) { + for (let y = cy - tileRadius; y <= cy + tileRadius; y++) { + if (x < 0 || y < 0 || x > max || y > max) continue + const sub = OSM_SUBDOMAINS[Math.abs(x + y) % OSM_SUBDOMAINS.length] + urls.push(`https://${sub}.tile.openstreetmap.org/${z}/${x}/${y}.png`) + if (urls.length >= opts.maxTiles) return urls + } + } + } + return urls +} + +/** + * Kacheln rund um den Einsatzort vorladen. + * Läuft im Hintergrund mit begrenzter Parallelität; bereits gecachte Kacheln + * werden übersprungen (kein unnötiger Traffic für den OSM-Server). + */ +export async function preloadTilesForArea(lng: number, lat: number, options: PreloadOptions = {}): Promise { + const opts = { + radiusM: options.radiusM ?? 800, + minZoom: options.minZoom ?? 13, + maxZoom: options.maxZoom ?? 18, + maxTiles: options.maxTiles ?? 400, + } + + if (typeof window === 'undefined' || !('caches' in window) || !navigator.onLine) { + return { total: 0, cached: 0, failed: 0, skipped: true } + } + + const urls = computeTileUrls(lng, lat, opts) + const cache = await caches.open(TILE_CACHE) + // Der SW cacht selbst, wenn er die Seite kontrolliert — sonst übernehmen wir das + const swControls = !!navigator.serviceWorker?.controller + + let cached = 0 + let failed = 0 + let done = 0 + + const worker = async (url: string) => { + try { + const existing = await cache.match(url) + if (existing) { + cached++ + return + } + const res = await fetch(url, { mode: 'cors' }) + if (res.ok) { + if (!swControls) { + await cache.put(url, res.clone()) + } + cached++ + } else { + failed++ + } + } catch { + failed++ + } finally { + done++ + options.onProgress?.(done, urls.length) + } + } + + // Begrenzte Parallelität (6), um Gerät und Tile-Server zu schonen + const CONCURRENCY = 6 + const queue = [...urls] + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (queue.length > 0) { + const url = queue.shift() + if (!url) break + await worker(url) + } + }) + ) + + return { total: urls.length, cached, failed, skipped: false } +} + +/** Marker, damit pro Einsatz nur einmal (bzw. alle 14 Tage) vorgeladen wird */ +export function shouldPreloadForProject(projectId: string): boolean { + try { + const raw = localStorage.getItem(`lageplan-tiles-${projectId}`) + if (!raw) return true + const ts = parseInt(raw, 10) + return !Number.isFinite(ts) || Date.now() - ts > 14 * 24 * 60 * 60 * 1000 + } catch { + return true + } +} + +export function markPreloadedForProject(projectId: string) { + try { + localStorage.setItem(`lageplan-tiles-${projectId}`, String(Date.now())) + } catch { /* ignore */ } +}