4 Commits

8 changed files with 143 additions and 52 deletions

View File

@@ -49,9 +49,9 @@ const nextConfig = {
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com http://localhost:9000 http://minio:9000",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000",
"font-src 'self' data:",
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com",
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -20,7 +20,13 @@
"src": "/logo-icon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/logo-icon-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [],

View File

@@ -30,6 +30,9 @@ self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return
// Never intercept Socket.IO — let it pass through directly
if (pathname.startsWith('/socket.io')) return
// Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary)
if (CACHEABLE_API.some(p => pathname.startsWith(p))) {
event.respondWith(
@@ -84,8 +87,8 @@ self.addEventListener('fetch', (event) => {
// Other API requests: network only
if (pathname.startsWith('/api/')) return
// Cache map tiles from OpenStreetMap / MapTiler (Cache First — tiles don't change)
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com')) {
// Cache map tiles from OSM / MapTiler / ArcGIS / Swisstopo (Cache First — tiles don't change)
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com') || url.includes('server.arcgisonline.com') || url.includes('geo.admin.ch')) {
event.respondWith(
caches.open(TILE_CACHE).then((cache) =>
cache.match(event.request).then((cached) => {

View File

@@ -20,7 +20,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { JournalView } from '@/components/journal/journal-view'
import { jsPDF } from 'jspdf'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
import { getSocket } from '@/lib/socket'
import { getSocket, setSocketRoom } from '@/lib/socket'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
@@ -507,27 +507,31 @@ export default function AppPage() {
const socketRef = useRef<any>(null)
const prevProjectIdRef = useRef<string | null>(null)
// Throttled socket broadcast for near-real-time sync (1.5s instead of 10s auto-save)
// Throttled socket broadcast for near-real-time sync
const lastEmitRef = useRef(0)
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const currentProjectRef = useRef(currentProject)
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
if (!socketRef.current || !currentProject?.id || !isEditingByMe) return
const proj = currentProjectRef.current
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
const now = Date.now()
const emit = () => {
socketRef.current?.emit('features-updated', {
projectId: currentProject!.id,
projectId: proj!.id,
features: feats,
})
lastEmitRef.current = Date.now()
}
// Throttle: emit at most every 1.5 seconds
if (now - lastEmitRef.current > 1500) {
// Throttle: emit at most every 800ms for snappier sync
if (now - lastEmitRef.current > 800) {
emit()
} else {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
emitTimerRef.current = setTimeout(emit, 1500 - (now - lastEmitRef.current))
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
}
}, [currentProject?.id, isEditingByMe])
}, [])
const isEditingByMeRef = useRef(false)
// Keep ref in sync with state
@@ -546,6 +550,7 @@ export default function AppPage() {
socket.emit('leave-project', prevProjectIdRef.current)
}
socket.emit('join-project', currentProject.id)
setSocketRoom(currentProject.id)
prevProjectIdRef.current = currentProject.id
// Listen for features changes from other clients (only apply if NOT the editor)

View File

@@ -106,7 +106,7 @@ export function MapView({
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
const measureCoordsRef = useRef<number[][]>([])
const [isMapLoaded, setIsMapLoaded] = useState(false)
const [isSatellite, setIsSatellite] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo' | 'swissimage'>('osm')
const [measurePointCount, setMeasurePointCount] = useState(0)
const [measureFinished, setMeasureFinished] = useState(false)
const [drawingPointCount, setDrawingPointCount] = useState(0)
@@ -689,6 +689,24 @@ export function MapView({
attribution: '© Esri, Maxar, Earthstar Geographics',
maxzoom: 19,
},
'swisstopo': {
type: 'raster',
tiles: [
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg',
],
tileSize: 256,
attribution: '© swisstopo',
maxzoom: 17,
},
'swissimage': {
type: 'raster',
tiles: [
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg',
],
tileSize: 256,
attribution: '© swisstopo SWISSIMAGE',
maxzoom: 18,
},
},
layers: [
{
@@ -702,6 +720,18 @@ export function MapView({
source: 'satellite',
layout: { visibility: 'none' },
},
{
id: 'swisstopo',
type: 'raster',
source: 'swisstopo',
layout: { visibility: 'none' },
},
{
id: 'swissimage',
type: 'raster',
source: 'swissimage',
layout: { visibility: 'none' },
},
],
},
center: [initialCenter.lng, initialCenter.lat],
@@ -953,7 +983,7 @@ export function MapView({
// Eraser mode: click on/near a feature to delete it
if (mode === 'eraser') {
const pixel = e.point
const tolerance = 10 // px
const tolerance = 20 // px
const currentFeatures = featuresRef.current
let closestIdx = -1
let closestDist = Infinity
@@ -962,29 +992,44 @@ export function MapView({
const f = currentFeatures[i]
const geom = f.geometry
// Get all coordinates to check proximity
let allCoords: number[][] = []
if (geom.type === 'Point') {
allCoords = [geom.coordinates as number[]]
} else if (geom.type === 'LineString') {
allCoords = geom.coordinates as number[][]
} else if (geom.type === 'Polygon') {
allCoords = (geom.coordinates as number[][][])[0] || []
}
for (const c of allCoords) {
const projected = m.project([c[0], c[1]])
const projected = m.project(geom.coordinates as [number, number])
const dx = projected.x - pixel.x
const dy = projected.y - pixel.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < closestDist) {
closestDist = dist
closestIdx = i
if (dist < closestDist) { closestDist = dist; closestIdx = i }
} else if (geom.type === 'LineString') {
const lineCoords = geom.coordinates as number[][]
for (let j = 0; j < lineCoords.length - 1; j++) {
const p1 = m.project(lineCoords[j] as [number, number])
const p2 = m.project(lineCoords[j + 1] as [number, number])
const dist = pointToSegmentDist(pixel.x, pixel.y, p1.x, p1.y, p2.x, p2.y)
if (dist < closestDist) { closestDist = dist; closestIdx = i }
}
} else if (geom.type === 'Polygon') {
const ring = (geom.coordinates as number[][][])[0] || []
// Check edges
for (let j = 0; j < ring.length - 1; j++) {
const p1 = m.project(ring[j] as [number, number])
const p2 = m.project(ring[j + 1] as [number, number])
const dist = pointToSegmentDist(pixel.x, pixel.y, p1.x, p1.y, p2.x, p2.y)
if (dist < closestDist) { closestDist = dist; closestIdx = i }
}
// Point-in-polygon test (screen space)
const projected = ring.map(c => m.project(c as [number, number]))
let inside = false
for (let j = 0, k = projected.length - 1; j < projected.length; k = j++) {
const xi = projected[j].x, yi = projected[j].y
const xk = projected[k].x, yk = projected[k].y
if (((yi > pixel.y) !== (yk > pixel.y)) && (pixel.x < (xk - xi) * (pixel.y - yi) / (yk - yi) + xi)) {
inside = !inside
}
}
if (inside) { closestDist = 0; closestIdx = i }
}
}
if (closestIdx >= 0 && closestDist < tolerance * 3) {
if (closestIdx >= 0 && closestDist < tolerance) {
const deleted = currentFeatures[closestIdx]
const newFeatures = currentFeatures.filter((_, i) => i !== closestIdx)
onFeaturesChangeRef.current(newFeatures)
@@ -2109,29 +2154,34 @@ export function MapView({
</>
)}
{/* Layer toggle: OSM / Satellite */}
<button
onClick={() => {
{/* Layer selector dropdown */}
<select
value={activeBaseLayer}
onChange={(e) => {
if (!map.current) return
const newSat = !isSatellite
setIsSatellite(newSat)
map.current.setLayoutProperty('osm', 'visibility', newSat ? 'none' : 'visible')
map.current.setLayoutProperty('satellite', 'visibility', newSat ? 'visible' : 'none')
const newLayer = e.target.value as 'osm' | 'satellite' | 'swisstopo' | 'swissimage'
const allLayers: Array<'osm' | 'satellite' | 'swisstopo' | 'swissimage'> = ['osm', 'satellite', 'swisstopo', 'swissimage']
for (const l of allLayers) {
map.current.setLayoutProperty(l, 'visibility', l === newLayer ? 'visible' : 'none')
}
setActiveBaseLayer(newLayer)
}}
className="absolute top-3 right-3 z-10 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold shadow-lg border transition-colors"
className="absolute top-3 right-3 z-10 px-2.5 py-1.5 rounded-lg text-xs font-semibold shadow-lg border transition-colors cursor-pointer appearance-none pr-7"
style={{
background: isSatellite ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.95)',
color: isSatellite ? '#fff' : '#333',
borderColor: isSatellite ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)',
background: activeBaseLayer !== 'osm' ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.95)',
color: activeBaseLayer !== 'osm' ? '#fff' : '#333',
borderColor: activeBaseLayer !== 'osm' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)',
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='${activeBaseLayer !== 'osm' ? 'white' : '%23333'}' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 6px center',
}}
title={isSatellite ? 'Zur Kartenansicht wechseln' : 'Zur Satellitenansicht wechseln'}
title="Kartenstil wählen"
>
{isSatellite ? (
<><svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3z"/><path d="M9 4v13"/><path d="M15 7v13"/></svg>Karte</>
) : (
<><svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>Satellit</>
)}
</button>
<option value="osm">🗺 OpenStreetMap</option>
<option value="satellite">🛰 Satellit (Esri)</option>
<option value="swisstopo">🇨🇭 Swisstopo Karte</option>
<option value="swissimage">🇨🇭 Swisstopo Luftbild</option>
</select>
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */}
{(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && (

View File

@@ -3,23 +3,50 @@
import { io, Socket } from 'socket.io-client'
let socket: Socket | null = null
let currentRoom: string | null = null
export function getSocket(): Socket {
if (!socket) {
socket = io({
path: '/socket.io',
transports: ['polling', 'websocket'],
transports: ['websocket', 'polling'],
upgrade: true,
reconnectionAttempts: 10,
reconnectionDelay: 2000,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
forceNew: false,
})
socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id)
// Re-join project room after reconnect
if (currentRoom) {
console.log('[Socket.io] Re-joining room:', currentRoom)
socket?.emit('join-project', currentRoom)
}
})
socket.on('disconnect', (reason) => {
console.warn('[Socket.io] Disconnected:', reason)
if (reason === 'io server disconnect') {
// Server disconnected us, need to manually reconnect
socket?.connect()
}
})
socket.on('connect_error', (err) => {
console.warn('[Socket.io] Connection error:', err.message)
})
socket.io.on('reconnect', (attempt) => {
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
})
socket.io.on('reconnect_attempt', (attempt) => {
console.log('[Socket.io] Reconnect attempt', attempt)
})
}
return socket
}
/** Track which room the socket should be in (for auto-rejoin on reconnect) */
export function setSocketRoom(projectId: string | null): void {
currentRoom = projectId
}