Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0784553017 | ||
|
|
e4c3c92cab | ||
|
|
0abc1c6b02 | ||
|
|
5bf4106db2 | ||
|
|
2432e9a17f |
@@ -49,9 +49,9 @@ const nextConfig = {
|
|||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"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:",
|
"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'",
|
"frame-ancestors 'self'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.0.5",
|
"version": "1.0.7",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
BIN
public/logo-icon-maskable.png
Normal file
BIN
public/logo-icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -20,7 +20,13 @@
|
|||||||
"src": "/logo-icon.png",
|
"src": "/logo-icon.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/logo-icon-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"screenshots": [],
|
"screenshots": [],
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Skip non-GET requests
|
// Skip non-GET requests
|
||||||
if (event.request.method !== 'GET') return
|
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)
|
// Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary)
|
||||||
if (CACHEABLE_API.some(p => pathname.startsWith(p))) {
|
if (CACHEABLE_API.some(p => pathname.startsWith(p))) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
@@ -84,8 +87,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Other API requests: network only
|
// Other API requests: network only
|
||||||
if (pathname.startsWith('/api/')) return
|
if (pathname.startsWith('/api/')) return
|
||||||
|
|
||||||
// Cache map tiles from OpenStreetMap / MapTiler (Cache First — tiles don't change)
|
// 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')) {
|
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com') || url.includes('server.arcgisonline.com') || url.includes('geo.admin.ch')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.open(TILE_CACHE).then((cache) =>
|
caches.open(TILE_CACHE).then((cache) =>
|
||||||
cache.match(event.request).then((cached) => {
|
cache.match(event.request).then((cached) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { JournalView } from '@/components/journal/journal-view'
|
import { JournalView } from '@/components/journal/journal-view'
|
||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
|
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 { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
|
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 socketRef = useRef<any>(null)
|
||||||
const prevProjectIdRef = useRef<string | null>(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 lastEmitRef = useRef(0)
|
||||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const currentProjectRef = useRef(currentProject)
|
||||||
|
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
|
||||||
|
|
||||||
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
|
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 now = Date.now()
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
socketRef.current?.emit('features-updated', {
|
socketRef.current?.emit('features-updated', {
|
||||||
projectId: currentProject!.id,
|
projectId: proj!.id,
|
||||||
features: feats,
|
features: feats,
|
||||||
})
|
})
|
||||||
lastEmitRef.current = Date.now()
|
lastEmitRef.current = Date.now()
|
||||||
}
|
}
|
||||||
// Throttle: emit at most every 1.5 seconds
|
// Throttle: emit at most every 800ms for snappier sync
|
||||||
if (now - lastEmitRef.current > 1500) {
|
if (now - lastEmitRef.current > 800) {
|
||||||
emit()
|
emit()
|
||||||
} else {
|
} else {
|
||||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
|
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)
|
const isEditingByMeRef = useRef(false)
|
||||||
|
|
||||||
// Keep ref in sync with state
|
// Keep ref in sync with state
|
||||||
@@ -546,6 +550,7 @@ export default function AppPage() {
|
|||||||
socket.emit('leave-project', prevProjectIdRef.current)
|
socket.emit('leave-project', prevProjectIdRef.current)
|
||||||
}
|
}
|
||||||
socket.emit('join-project', currentProject.id)
|
socket.emit('join-project', currentProject.id)
|
||||||
|
setSocketRoom(currentProject.id)
|
||||||
prevProjectIdRef.current = currentProject.id
|
prevProjectIdRef.current = currentProject.id
|
||||||
|
|
||||||
// Listen for features changes from other clients (only apply if NOT the editor)
|
// Listen for features changes from other clients (only apply if NOT the editor)
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-start pb-3 border-b-[3px] border-gray-900 mb-1">
|
<div className="flex justify-between items-start pb-3 border-b-[3px] border-gray-900 mb-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{d.logoUrl && (
|
<img src="/logo.svg" alt="Lageplan" className="w-10 h-10 object-contain" />
|
||||||
<img src={d.logoUrl} alt="Logo" className="w-10 h-10 object-contain" />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Einsatzrapport</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Einsatzrapport</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5 font-medium">{d.organisation} · {d.abteilung}</p>
|
<p className="text-xs text-gray-500 mt-0.5 font-medium">{d.organisation} · {d.abteilung}</p>
|
||||||
@@ -168,8 +166,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
<Field label="Alarmzeit" value={d.alarmzeit} mono />
|
<Field label="Alarmzeit" value={d.alarmzeit} mono />
|
||||||
<Field label="Priorität" value={d.prioritaet} last />
|
<Field label="Priorität" value={d.prioritaet} last />
|
||||||
<Field label="Einsatzort / Adresse" value={d.einsatzort} span={2} />
|
<Field label="Einsatzort / Adresse" value={d.einsatzort} span={2} />
|
||||||
<Field label="Koordinaten" value={d.koordinaten} mono />
|
<Field label="Objekt / Gebäude" value={d.objekt} span={2} last />
|
||||||
<Field label="Objekt / Gebäude" value={d.objekt} last />
|
|
||||||
<Field label="Alarmierungsart" value={d.alarmierungsart} span={2} />
|
<Field label="Alarmierungsart" value={d.alarmierungsart} span={2} />
|
||||||
<Field label="Stichwort / Meldebild" value={d.stichwort} span={2} last />
|
<Field label="Stichwort / Meldebild" value={d.stichwort} span={2} last />
|
||||||
</div>
|
</div>
|
||||||
@@ -177,15 +174,9 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
|
|
||||||
{/* 2. Zeitverlauf */}
|
{/* 2. Zeitverlauf */}
|
||||||
<Section num="2" title="Zeitverlauf">
|
<Section num="2" title="Zeitverlauf">
|
||||||
<div className="grid grid-cols-4 border rounded">
|
<div className="grid grid-cols-2 border rounded">
|
||||||
<Field label="Alarmierung" value={d.zeitAlarm} mono highlight />
|
<Field label="Alarmierung" value={d.zeitAlarm} mono highlight />
|
||||||
<Field label="Ausrücken" value={d.zeitAusruecken} mono highlight />
|
<Field label="Eintreffen" value={d.zeitEintreffen} mono highlight last />
|
||||||
<Field label="Eintreffen" value={d.zeitEintreffen} mono highlight />
|
|
||||||
<Field label="Einsatzbereit" value={d.zeitBereit} mono highlight last />
|
|
||||||
<Field label="Feuer unter Kontrolle" value={d.zeitKontrolle} mono highlight />
|
|
||||||
<Field label="Feuer aus" value={d.zeitAus} mono highlight />
|
|
||||||
<Field label="Einrücken" value={d.zeitEinruecken} mono highlight />
|
|
||||||
<Field label="Einsatzende" value={d.zeitEnde} mono highlight last />
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -948,10 +948,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Koordinaten</label>
|
|
||||||
<Input value={rapportForm.koordinaten || ''} onChange={e => setRapportForm(f => ({ ...f, koordinaten: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
|
||||||
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
||||||
@@ -964,22 +960,15 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
{/* Zeitverlauf */}
|
{/* Zeitverlauf */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{[
|
<div>
|
||||||
['zeitAlarm', 'Alarm'],
|
<label className="text-[10px] text-gray-400">Alarm</label>
|
||||||
['zeitAusruecken', 'Ausrücken'],
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
|
||||||
['zeitEintreffen', 'Eintreffen'],
|
</div>
|
||||||
['zeitBereit', 'Bereit'],
|
<div>
|
||||||
['zeitKontrolle', 'F. u. Kontrolle'],
|
<label className="text-[10px] text-gray-400">Eintreffen</label>
|
||||||
['zeitAus', 'F. aus'],
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
|
||||||
['zeitEinruecken', 'Einrücken'],
|
</div>
|
||||||
['zeitEnde', 'Ende'],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<div key={key}>
|
|
||||||
<label className="text-[10px] text-gray-400">{label}</label>
|
|
||||||
<Input className="text-sm h-8" value={rapportForm[key] || ''} onChange={e => setRapportForm(f => ({ ...f, [key]: e.target.value }))} placeholder="HH:MM" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Lagebild */}
|
{/* Lagebild */}
|
||||||
@@ -1064,22 +1053,20 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
} else {
|
} else {
|
||||||
mapScreenshot = rawScreenshot
|
mapScreenshot = rawScreenshot
|
||||||
}
|
}
|
||||||
// Convert logo URL to base64 for PDF rendering
|
// Convert Lageplan app logo to base64 for PDF rendering
|
||||||
let logoDataUri = ''
|
let logoDataUri = ''
|
||||||
if (rapportForm.logoUrl) {
|
try {
|
||||||
try {
|
const logoRes = await fetch('/logo-icon.png')
|
||||||
const logoRes = await fetch(rapportForm.logoUrl)
|
if (logoRes.ok) {
|
||||||
if (logoRes.ok) {
|
const blob = await logoRes.blob()
|
||||||
const blob = await logoRes.blob()
|
logoDataUri = await new Promise<string>((resolve) => {
|
||||||
logoDataUri = await new Promise<string>((resolve) => {
|
const reader = new FileReader()
|
||||||
const reader = new FileReader()
|
reader.onloadend = () => resolve(reader.result as string)
|
||||||
reader.onloadend = () => resolve(reader.result as string)
|
reader.readAsDataURL(blob)
|
||||||
reader.readAsDataURL(blob)
|
})
|
||||||
})
|
}
|
||||||
}
|
} catch (e) { console.warn('Logo fetch failed:', e) }
|
||||||
} catch (e) { console.warn('Logo fetch failed:', e) }
|
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || '/logo-icon.png' }
|
||||||
}
|
|
||||||
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
|
||||||
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
||||||
const res = await fetch('/api/rapports', {
|
const res = await fetch('/api/rapports', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export function MapView({
|
|||||||
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
|
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
|
||||||
const measureCoordsRef = useRef<number[][]>([])
|
const measureCoordsRef = useRef<number[][]>([])
|
||||||
const [isMapLoaded, setIsMapLoaded] = useState(false)
|
const [isMapLoaded, setIsMapLoaded] = useState(false)
|
||||||
const [isSatellite, setIsSatellite] = useState(false)
|
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo' | 'swissimage'>('osm')
|
||||||
|
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
||||||
const [measurePointCount, setMeasurePointCount] = useState(0)
|
const [measurePointCount, setMeasurePointCount] = useState(0)
|
||||||
const [measureFinished, setMeasureFinished] = useState(false)
|
const [measureFinished, setMeasureFinished] = useState(false)
|
||||||
const [drawingPointCount, setDrawingPointCount] = useState(0)
|
const [drawingPointCount, setDrawingPointCount] = useState(0)
|
||||||
@@ -689,6 +690,24 @@ export function MapView({
|
|||||||
attribution: '© Esri, Maxar, Earthstar Geographics',
|
attribution: '© Esri, Maxar, Earthstar Geographics',
|
||||||
maxzoom: 19,
|
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: [
|
layers: [
|
||||||
{
|
{
|
||||||
@@ -702,6 +721,18 @@ export function MapView({
|
|||||||
source: 'satellite',
|
source: 'satellite',
|
||||||
layout: { visibility: 'none' },
|
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],
|
center: [initialCenter.lng, initialCenter.lat],
|
||||||
@@ -953,7 +984,7 @@ export function MapView({
|
|||||||
// Eraser mode: click on/near a feature to delete it
|
// Eraser mode: click on/near a feature to delete it
|
||||||
if (mode === 'eraser') {
|
if (mode === 'eraser') {
|
||||||
const pixel = e.point
|
const pixel = e.point
|
||||||
const tolerance = 10 // px
|
const tolerance = 20 // px
|
||||||
const currentFeatures = featuresRef.current
|
const currentFeatures = featuresRef.current
|
||||||
let closestIdx = -1
|
let closestIdx = -1
|
||||||
let closestDist = Infinity
|
let closestDist = Infinity
|
||||||
@@ -962,29 +993,44 @@ export function MapView({
|
|||||||
const f = currentFeatures[i]
|
const f = currentFeatures[i]
|
||||||
const geom = f.geometry
|
const geom = f.geometry
|
||||||
|
|
||||||
// Get all coordinates to check proximity
|
|
||||||
let allCoords: number[][] = []
|
|
||||||
if (geom.type === 'Point') {
|
if (geom.type === 'Point') {
|
||||||
allCoords = [geom.coordinates as number[]]
|
const projected = m.project(geom.coordinates as [number, 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 dx = projected.x - pixel.x
|
const dx = projected.x - pixel.x
|
||||||
const dy = projected.y - pixel.y
|
const dy = projected.y - pixel.y
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
if (dist < closestDist) {
|
if (dist < closestDist) { closestDist = dist; closestIdx = i }
|
||||||
closestDist = dist
|
} else if (geom.type === 'LineString') {
|
||||||
closestIdx = i
|
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 deleted = currentFeatures[closestIdx]
|
||||||
const newFeatures = currentFeatures.filter((_, i) => i !== closestIdx)
|
const newFeatures = currentFeatures.filter((_, i) => i !== closestIdx)
|
||||||
onFeaturesChangeRef.current(newFeatures)
|
onFeaturesChangeRef.current(newFeatures)
|
||||||
@@ -2061,11 +2107,12 @@ export function MapView({
|
|||||||
selectedSymbolRef.current.scale = Math.max(0.2, Math.min(10, startScale * ratio))
|
selectedSymbolRef.current.scale = Math.max(0.2, Math.min(10, startScale * ratio))
|
||||||
selectedSymbolRef.current.innerEl.style.fontSize = `${baseFontSize * selectedSymbolRef.current.scale}px`
|
selectedSymbolRef.current.innerEl.style.fontSize = `${baseFontSize * selectedSymbolRef.current.scale}px`
|
||||||
} else {
|
} else {
|
||||||
// For symbols: resize wrapper
|
// For symbols: resize wrapper, use ratio from start to preserve zoom-aware scale
|
||||||
selectedSymbolRef.current.wrapperEl.style.width = `${width}px`
|
selectedSymbolRef.current.wrapperEl.style.width = `${width}px`
|
||||||
selectedSymbolRef.current.wrapperEl.style.height = `${height}px`
|
selectedSymbolRef.current.wrapperEl.style.height = `${height}px`
|
||||||
const baseSize = 32
|
const startW = selectedSymbolRef.current.resizeStartWidth || 1
|
||||||
selectedSymbolRef.current.scale = Math.max(0.1, Math.min(10, width / baseSize))
|
const startScale = selectedSymbolRef.current.resizeStartScale || 1
|
||||||
|
selectedSymbolRef.current.scale = Math.max(0.1, Math.min(10, startScale * (width / startW)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2109,29 +2156,53 @@ export function MapView({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Layer toggle: OSM / Satellite */}
|
{/* Layer selector dropdown */}
|
||||||
<button
|
<div className="absolute top-3 right-3 z-10">
|
||||||
onClick={() => {
|
<button
|
||||||
if (!map.current) return
|
onClick={() => setLayerDropdownOpen(v => !v)}
|
||||||
const newSat = !isSatellite
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-medium shadow-md border backdrop-blur-sm transition-all"
|
||||||
setIsSatellite(newSat)
|
style={{
|
||||||
map.current.setLayoutProperty('osm', 'visibility', newSat ? 'none' : 'visible')
|
background: activeBaseLayer !== 'osm' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.92)',
|
||||||
map.current.setLayoutProperty('satellite', 'visibility', newSat ? 'visible' : 'none')
|
color: activeBaseLayer !== 'osm' ? '#fff' : '#1f2937',
|
||||||
}}
|
borderColor: activeBaseLayer !== 'osm' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)',
|
||||||
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"
|
}}
|
||||||
style={{
|
>
|
||||||
background: isSatellite ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.95)',
|
<svg className="w-3.5 h-3.5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||||
color: isSatellite ? '#fff' : '#333',
|
{{ osm: 'OpenStreetMap', satellite: 'Satellit', swisstopo: 'Swisstopo', swissimage: 'Luftbild CH' }[activeBaseLayer]}
|
||||||
borderColor: isSatellite ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)',
|
<svg className={`w-3 h-3 opacity-50 transition-transform ${layerDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
}}
|
</button>
|
||||||
title={isSatellite ? 'Zur Kartenansicht wechseln' : 'Zur Satellitenansicht wechseln'}
|
{layerDropdownOpen && (
|
||||||
>
|
<>
|
||||||
{isSatellite ? (
|
<div className="fixed inset-0 z-10" onClick={() => setLayerDropdownOpen(false)} />
|
||||||
<><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</>
|
<div className="absolute right-0 mt-1 z-20 min-w-[160px] rounded-lg shadow-xl border overflow-hidden backdrop-blur-md"
|
||||||
) : (
|
style={{ background: 'rgba(255,255,255,0.95)', borderColor: 'rgba(0,0,0,0.08)' }}>
|
||||||
<><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</>
|
{([
|
||||||
|
{ key: 'osm', label: 'OpenStreetMap' },
|
||||||
|
{ key: 'satellite', label: 'Satellit (Esri)' },
|
||||||
|
{ key: 'swisstopo', label: 'Swisstopo Karte' },
|
||||||
|
{ key: 'swissimage', label: 'Luftbild CH' },
|
||||||
|
] as const).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
if (!map.current) return
|
||||||
|
const allLayers: Array<'osm' | 'satellite' | 'swisstopo' | 'swissimage'> = ['osm', 'satellite', 'swisstopo', 'swissimage']
|
||||||
|
for (const l of allLayers) {
|
||||||
|
map.current.setLayoutProperty(l, 'visibility', l === key ? 'visible' : 'none')
|
||||||
|
}
|
||||||
|
setActiveBaseLayer(key)
|
||||||
|
setLayerDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-[11px] font-medium transition-colors ${activeBaseLayer === key ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{activeBaseLayer === key && <span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-2 align-middle" />}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */}
|
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */}
|
||||||
{(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && (
|
{(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && (
|
||||||
|
|||||||
@@ -166,8 +166,7 @@ export function RapportDocument({ data }: { data: RapportData }) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<FieldCell label="Einsatzort / Adresse" value={data.einsatzort} width="50%" />
|
<FieldCell label="Einsatzort / Adresse" value={data.einsatzort} width="50%" />
|
||||||
<FieldCell label="Koordinaten" value={data.koordinaten} mono width="25%" />
|
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="50%" />
|
||||||
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="25%" />
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<FieldCell label="Alarmierungsart" value={data.alarmierungsart} width="50%" />
|
<FieldCell label="Alarmierungsart" value={data.alarmierungsart} width="50%" />
|
||||||
@@ -185,16 +184,8 @@ export function RapportDocument({ data }: { data: RapportData }) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.fieldGrid}>
|
<View style={styles.fieldGrid}>
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="25%" />
|
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="50%" />
|
||||||
<FieldCell label="Ausrücken" value={data.zeitAusruecken} mono highlight width="25%" />
|
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="50%" />
|
||||||
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="25%" />
|
|
||||||
<FieldCell label="Einsatzbereit" value={data.zeitBereit} mono highlight width="25%" />
|
|
||||||
</View>
|
|
||||||
<View style={styles.fieldRow}>
|
|
||||||
<FieldCell label="Feuer unter Kontrolle" value={data.zeitKontrolle} mono highlight width="25%" />
|
|
||||||
<FieldCell label="Feuer aus" value={data.zeitAus} mono highlight width="25%" />
|
|
||||||
<FieldCell label="Einrücken" value={data.zeitEinruecken} mono highlight width="25%" />
|
|
||||||
<FieldCell label="Einsatzende" value={data.zeitEnde} mono highlight width="25%" />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,23 +3,50 @@
|
|||||||
import { io, Socket } from 'socket.io-client'
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
|
||||||
let socket: Socket | null = null
|
let socket: Socket | null = null
|
||||||
|
let currentRoom: string | null = null
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io({
|
socket = io({
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
transports: ['polling', 'websocket'],
|
transports: ['websocket', 'polling'],
|
||||||
upgrade: true,
|
upgrade: true,
|
||||||
reconnectionAttempts: 10,
|
reconnection: true,
|
||||||
reconnectionDelay: 2000,
|
reconnectionAttempts: Infinity,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
forceNew: false,
|
||||||
})
|
})
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
console.log('[Socket.io] Connected:', socket?.id)
|
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) => {
|
socket.on('connect_error', (err) => {
|
||||||
console.warn('[Socket.io] Connection error:', err.message)
|
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
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track which room the socket should be in (for auto-rejoin on reconnect) */
|
||||||
|
export function setSocketRoom(projectId: string | null): void {
|
||||||
|
currentRoom = projectId
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user