feat(offline): Offline-Karten – Einsatzgebiet wird automatisch vorgeladen (v1.5.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30m48s

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) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-19 18:19:19 +02:00
parent 2dbb2204f6
commit 796ce53db3
4 changed files with 194 additions and 1 deletions

View File

@@ -88,6 +88,15 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen, - Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen,
Modul-Daten in Rapport-PDF übernehmen Modul-Daten in Rapport-PDF übernehmen
## Zusatz Offline-Karten ✅ v1.5.1
- [x] Kacheln des Einsatzgebiets werden beim Öffnen eines Einsatzes automatisch
vorgeladen (Zoom 1318, ~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 ## Phase 5 Kür
- [x] **5.1 Linien-Typ-Abfrage** 🟡 ✅ - [x] **5.1 Linien-Typ-Abfrage** 🟡 ✅

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.5.0", "version": "1.5.1",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -26,6 +26,7 @@ import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
import { useMapExport } from '@/hooks/use-map-export' import { useMapExport } from '@/hooks/use-map-export'
import { useAutoSave } from '@/hooks/use-auto-save' import { useAutoSave } from '@/hooks/use-auto-save'
import { useOfflineSync } from '@/hooks/use-offline-sync' import { useOfflineSync } from '@/hooks/use-offline-sync'
import { preloadTilesForArea, shouldPreloadForProject, markPreloadedForProject } from '@/lib/offline-tiles'
import { useRealtimeSync } from '@/hooks/use-realtime-sync' import { useRealtimeSync } from '@/hooks/use-realtime-sync'
import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types' import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
import { useToolStore } from '@/stores/tool-store' import { useToolStore } from '@/stores/tool-store'
@@ -411,6 +412,33 @@ export default function AppPage() {
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0 featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
}, [currentProject?.id]) }, [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 // Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen
const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => { const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => {
setFeatures(serverFeatures) setFeatures(serverFeatures)

156
src/lib/offline-tiles.ts Normal file
View File

@@ -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 1318) */
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<Pick<PreloadOptions, 'radiusM' | 'minZoom' | 'maxZoom' | 'maxTiles'>>): 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<PreloadResult> {
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 */ }
}