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
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:
@@ -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** 🟡 ✅
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
156
src/lib/offline-tiles.ts
Normal file
156
src/lib/offline-tiles.ts
Normal 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 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<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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user