Files
Lageplan/src/components/map/map-view.tsx
Pepe Ziberi 0e65721a1c fix(sync): Editier-Lock-Versionierung + Zeichnen-Race + Mess-Race (v1.6.4)
- handleStopEditing speichert jetzt MIT baseVersion und aktualisiert
  featuresVersionRef aus der Antwort (409 → Server-Stand übernehmen). Verhindert
  Fehl-Konflikte/Konflikt-Schleifen beim erneuten Bearbeiten nach dem Freigeben.
- Lock-Ablehnung (zwei zeichnen bei freier Karte gleichzeitig): der abgelehnte
  Client lädt den echten Sperr-Halter nach → Karte wird lokal read-only, keine
  weiteren nicht-speicherbaren Änderungen mehr.
- Mess-Höhenabfrage: Generation-Zähler bricht veraltete asynchrone Abfragen ab
  (Neue Messung/Verlassen) → keine falschen Höhen-/Druckwerte der alten Messung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:36:00 +02:00

2536 lines
103 KiB
TypeScript

'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useDrop } from 'react-dnd'
import Moveable from 'react-moveable'
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
import type { Project, DrawFeature, DrawMode } from '@/types'
import { MeasurePanel } from './measure-panel'
import { MapCompass } from './map-compass'
import { FALLBACK_HOSE_TYPES, type HoseTypeData, type MeasurementSummary } from '@/lib/hose-calc'
// Haversine distance between two [lng, lat] points in meters
function haversineDistance(a: number[], b: number[]): number {
const R = 6371000
const toRad = Math.PI / 180
const dLat = (b[1] - a[1]) * toRad
const dLng = (b[0] - a[0]) * toRad
const x = Math.sin(dLat / 2) ** 2 + Math.cos(a[1] * toRad) * Math.cos(b[1] * toRad) * Math.sin(dLng / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))
}
function formatDistance(meters: number): string {
if (meters < 1000) return `${Math.round(meters)} m`
return `${(meters / 1000).toFixed(2)} km`
}
// Approximate polygon area in m² using the Shoelace formula on spherical coordinates
function polygonArea(ring: number[][]): number {
const toRad = Math.PI / 180
const R = 6371000
let area = 0
const n = ring.length - 1 // exclude closing duplicate
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += (ring[j][0] - ring[i][0]) * toRad * (2 + Math.sin(ring[i][1] * toRad) + Math.sin(ring[j][1] * toRad))
}
return Math.abs(area * R * R / 2)
}
// Point-to-segment distance in screen pixels (for click detection on lines)
function pointToSegmentDist(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1, dy = y2 - y1
const lenSq = dx * dx + dy * dy
if (lenSq === 0) return Math.hypot(px - x1, py - y1)
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq))
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
}
// Generate a geodesic circle (corrects for lat/lng distortion)
function generateCircleCoords(center: [number, number], edge: [number, number], steps: number = 64): number[][] {
const [cLng, cLat] = center
const [eLng, eLat] = edge
const toRad = Math.PI / 180
// Haversine distance in meters
const R = 6371000
const dLat = (eLat - cLat) * toRad
const dLng = (eLng - cLng) * toRad
const a = Math.sin(dLat / 2) ** 2 + Math.cos(cLat * toRad) * Math.cos(eLat * toRad) * Math.sin(dLng / 2) ** 2
const radiusM = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
// Generate circle points using destination formula
const coords: number[][] = []
for (let i = 0; i <= steps; i++) {
const bearing = (i / steps) * 2 * Math.PI
const latR = cLat * toRad
const lngR = cLng * toRad
const d = radiusM / R
const lat2 = Math.asin(Math.sin(latR) * Math.cos(d) + Math.cos(latR) * Math.sin(d) * Math.cos(bearing))
const lng2 = lngR + Math.atan2(Math.sin(bearing) * Math.sin(d) * Math.cos(latR), Math.cos(d) - Math.sin(latR) * Math.sin(lat2))
coords.push([lng2 / toRad, lat2 / toRad])
}
return coords
}
interface MapViewProps {
project: Project | null
features: DrawFeature[]
drawMode: DrawMode
selectedColor: string
selectedWidth: number
onFeaturesChange: (features: DrawFeature[]) => void
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
onTextPlace: (coordinates: [number, number]) => void
canEdit: boolean
mapRef?: React.MutableRefObject<maplibregl.Map | null>
onDrawModeChange?: (mode: DrawMode) => void
undoDrawPointRef?: React.MutableRefObject<(() => boolean) | null>
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
pendingSymbol?: { iconId: string; imageUrl?: string } | null
onSymbolPlaced?: () => void
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
isFullscreen?: boolean
onToggleFullscreen?: () => void
/** Windrichtung fürs Lagebild (Grad, „Wind aus") + Setter */
windDirection?: number | null
onWindChange?: (dir: number | null) => void
/** Live-Wind (Open-Meteo) für den aktuellen Standort holen */
onFetchLiveWind?: () => Promise<void> | void
}
export function MapView({
project,
features,
drawMode,
selectedColor,
selectedWidth,
onFeaturesChange,
onSymbolDrop,
onTextPlace,
canEdit,
mapRef: externalMapRef,
onDrawModeChange,
undoDrawPointRef,
pendingSymbol,
onSymbolPlaced,
isFullscreen,
onToggleFullscreen,
windDirection,
onWindChange,
onFetchLiveWind,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(null)
const markersRef = useRef<maplibregl.Marker[]>([])
const markerCleanupsRef = useRef<(() => void)[]>([])
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
const measureCoordsRef = useRef<number[][]>([])
// Generation-Zähler: bricht veraltete (asynchrone) Höhen-Abfragen ab, wenn
// zwischenzeitlich eine neue Messung gestartet/geleert wurde.
const measureGenRef = useRef(0)
const [isMapLoaded, setIsMapLoaded] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
const [mapBearing, setMapBearing] = useState(0)
const [measurePointCount, setMeasurePointCount] = useState(0)
const [measureFinished, setMeasureFinished] = useState(false)
// Messergebnis für das React-Panel (ersetzt das frühere innerHTML-DOM-Overlay)
const [measureResult, setMeasureResult] = useState<{ measurement: MeasurementSummary; hoseTypes: HoseTypeData[] } | null>(null)
const [drawingPointCount, setDrawingPointCount] = useState(0)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const tooltipPosRef = useRef({ x: 0, y: 0 })
// Vertex editing state (for lines/polygons in select mode)
const selectedLineIdRef = useRef<string | null>(null)
const vertexMarkersRef = useRef<maplibregl.Marker[]>([])
// Inline edit state (replaces native prompt() which breaks fullscreen)
const [inlineEdit, setInlineEdit] = useState<{
featureId: string
type: 'label' | 'text'
value: string
} | null>(null)
const inlineEditInputRef = useRef<HTMLInputElement>(null)
// Symbol selection state for Moveable (rotation/scale handles)
const [isSymbolSelected, setIsSymbolSelected] = useState(false)
const [symbolSelectKey, setSymbolSelectKey] = useState(0) // Force Moveable re-mount on symbol switch
const moveableRef = useRef<Moveable>(null)
const selectedSymbolRef = useRef<{
id: string
wrapperEl: HTMLElement
innerEl: HTMLElement
rotation: number
scale: number
featureType: 'symbol' | 'text'
baseFontSize?: number
resizeStartWidth?: number
resizeStartScale?: number
} | null>(null)
const drawingRef = useRef<{
isDrawing: boolean
currentCoords: number[][]
startPoint: [number, number] | null
}>({
isDrawing: false,
currentCoords: [],
startPoint: null,
})
// --- REFS for stable closures (prevents stale state in map event handlers) ---
const drawModeRef = useRef(drawMode)
const selectedColorRef = useRef(selectedColor)
const selectedWidthRef = useRef(selectedWidth)
const featuresRef = useRef(features)
const canEditRef = useRef(canEdit)
const onFeaturesChangeRef = useRef(onFeaturesChange)
const onSymbolDropRef = useRef(onSymbolDrop)
const onTextPlaceRef = useRef(onTextPlace)
const onDrawModeChangeRef = useRef(onDrawModeChange)
const pendingSymbolRef = useRef(pendingSymbol)
const onSymbolPlacedRef = useRef(onSymbolPlaced)
// Lock/unlock map panning based on draw mode
// Apple-style: touchZoomRotate (two-finger pinch/zoom) ALWAYS enabled
// dragPan only disabled for freehand (single finger = draw, two finger = zoom)
// For click-based tools (line, polygon, arrow, etc.) dragPan stays enabled — taps add points, drags pan
useEffect(() => {
drawModeRef.current = drawMode
if (map.current) {
// Only freehand needs dragPan disabled (single finger draws)
if (drawMode === 'freehand' && canEdit) {
map.current.dragPan.disable()
} else {
map.current.dragPan.enable()
}
// Two-finger zoom/rotate ALWAYS works
map.current.touchZoomRotate.enable()
}
}, [drawMode, canEdit])
useEffect(() => { selectedColorRef.current = selectedColor }, [selectedColor])
useEffect(() => { selectedWidthRef.current = selectedWidth }, [selectedWidth])
useEffect(() => { featuresRef.current = features }, [features])
useEffect(() => { canEditRef.current = canEdit }, [canEdit])
useEffect(() => { onFeaturesChangeRef.current = onFeaturesChange }, [onFeaturesChange])
useEffect(() => { onSymbolDropRef.current = onSymbolDrop }, [onSymbolDrop])
useEffect(() => { onTextPlaceRef.current = onTextPlace }, [onTextPlace])
useEffect(() => { onDrawModeChangeRef.current = onDrawModeChange }, [onDrawModeChange])
useEffect(() => { pendingSymbolRef.current = pendingSymbol }, [pendingSymbol])
useEffect(() => { onSymbolPlacedRef.current = onSymbolPlaced }, [onSymbolPlaced])
// Expose undo-draw-point function to parent via ref
useEffect(() => {
if (undoDrawPointRef) {
undoDrawPointRef.current = () => {
if (drawingRef.current.isDrawing && drawingRef.current.currentCoords.length > 1) {
drawingRef.current.currentCoords.pop()
return true // handled
}
if (drawingRef.current.isDrawing && drawingRef.current.currentCoords.length <= 1) {
// Cancel drawing entirely
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
return true
}
return false // not handled, let parent undo features
}
}
}, [undoDrawPointRef])
// --- Measurement display helper ---
const updateMeasureDisplayRef = useRef<() => void>(() => {})
const updateMeasureDisplay = useCallback(() => {
if (!map.current) return
const coords = measureCoordsRef.current
// Remove old labels (but keep point markers at indices 0,2,4,... and labels at odd indices)
// Actually, let's clear ALL measure markers and rebuild
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
if (coords.length === 0) {
// Clear the measure line source
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
return
}
// Update line
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) {
src.setData({
type: 'FeatureCollection',
features: coords.length >= 2 ? [{
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: {},
}] : [],
})
}
// Create draggable point markers + distance labels
let totalDist = 0
coords.forEach((pt, i) => {
// Draggable point marker
const dot = document.createElement('div')
dot.style.cssText = 'width:18px;height:18px;background:#fbbf24;border:3px solid #000;border-radius:50%;cursor:grab;'
const pointMarker = new maplibregl.Marker({ element: dot, draggable: true, anchor: 'center' })
.setLngLat(pt as [number, number])
.addTo(map.current!)
// On drag: update coords and redraw
const idx = i
pointMarker.on('dragend', () => {
const pos = pointMarker.getLngLat()
measureCoordsRef.current[idx] = [pos.lng, pos.lat]
updateMeasureDisplay()
})
measureMarkersRef.current.push(pointMarker)
// Segment distance label at midpoint
if (i > 0) {
const prev = coords[i - 1]
const segDist = haversineDistance(prev, pt)
totalDist += segDist
const midLng = (prev[0] + pt[0]) / 2
const midLat = (prev[1] + pt[1]) / 2
const segLabel = document.createElement('div')
segLabel.style.cssText = 'background:rgba(0,0,0,0.75);color:#fbbf24;padding:3px 7px;border-radius:5px;font-size:12px;font-weight:600;white-space:nowrap;pointer-events:none;'
segLabel.textContent = formatDistance(segDist)
const segMarker = new maplibregl.Marker({ element: segLabel, anchor: 'center' })
.setLngLat([midLng, midLat])
.addTo(map.current!)
measureMarkersRef.current.push(segMarker)
}
})
// Total distance label at last point
if (coords.length >= 2) {
const last = coords[coords.length - 1]
const totalLabel = document.createElement('div')
totalLabel.style.cssText = 'background:#fbbf24;color:#000;padding:5px 10px;border-radius:6px;font-size:14px;font-weight:700;white-space:nowrap;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,0.3);'
totalLabel.textContent = `Gesamt: ${formatDistance(totalDist)}`
const totalMarker = new maplibregl.Marker({ element: totalLabel, anchor: 'top' })
.setLngLat(last as [number, number])
.addTo(map.current!)
measureMarkersRef.current.push(totalMarker)
}
}, [])
updateMeasureDisplayRef.current = updateMeasureDisplay
// --- Vertex editing: show/clear draggable vertex markers for selected line/polygon ---
const showVertexMarkersRef = useRef<(featureId: string | null) => void>(() => {})
const showVertexMarkers = useCallback((featureId: string | null) => {
// Clear existing vertex markers
vertexMarkersRef.current.forEach(m => m.remove())
vertexMarkersRef.current = []
if (!featureId || !map.current) {
selectedLineIdRef.current = null
return
}
selectedLineIdRef.current = featureId
const feature = featuresRef.current.find(f => f.id === featureId)
if (!feature) return
let coords: number[][]
const isPolygon = feature.geometry.type === 'Polygon'
if (feature.geometry.type === 'LineString') {
coords = feature.geometry.coordinates as number[][]
} else if (isPolygon) {
const ring = (feature.geometry.coordinates as number[][][])[0]
coords = ring.slice(0, -1) // exclude closing point
} else return
const m = map.current
// Create draggable vertex markers
coords.forEach((pt, i) => {
const dot = document.createElement('div')
dot.style.cssText = 'width:14px;height:14px;background:#3b82f6;border:2px solid #fff;border-radius:50%;cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,0.4);'
const marker = new maplibregl.Marker({ element: dot, draggable: true, anchor: 'center' })
.setLngLat(pt as [number, number])
.addTo(m)
const idx = i
marker.on('dragend', () => {
const pos = marker.getLngLat()
const feat = featuresRef.current.find(f => f.id === featureId)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const newCoords = [...(feat.geometry.coordinates as number[][])]; newCoords[idx] = [pos.lng, pos.lat]
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: newCoords } } : f)
onFeaturesChangeRef.current(updated)
} else if (feat.geometry.type === 'Polygon') {
const ring = [...(feat.geometry.coordinates as number[][][])[0]]
ring[idx] = [pos.lng, pos.lat]
if (idx === 0) ring[ring.length - 1] = [pos.lng, pos.lat] // close ring
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: [ring] } } : f)
onFeaturesChangeRef.current(updated)
}
})
// Right-click to delete vertex (min 2 for lines, 3 for polygons)
const handleDelete = (e: Event) => {
e.preventDefault(); e.stopPropagation()
const feat = featuresRef.current.find(f => f.id === featureId)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const c = feat.geometry.coordinates as number[][]
if (c.length <= 2) return
const newCoords = c.filter((_, ci) => ci !== idx)
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: newCoords } } : f)
onFeaturesChangeRef.current(updated)
} else if (feat.geometry.type === 'Polygon') {
const ring = (feat.geometry.coordinates as number[][][])[0]
const unique = ring.slice(0, -1)
if (unique.length <= 3) return
const newRing = unique.filter((_, ci) => ci !== idx)
newRing.push(newRing[0]) // close ring
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: [newRing] } } : f)
onFeaturesChangeRef.current(updated)
}
}
dot.addEventListener('contextmenu', handleDelete)
// Long-press for touch devices (500ms = delete)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
dot.addEventListener('touchstart', (e) => {
longPressTimer = setTimeout(() => {
handleDelete(e)
longPressTimer = null
}, 500)
}, { passive: true })
dot.addEventListener('touchend', () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null } })
dot.addEventListener('touchmove', () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null } }, { passive: true })
vertexMarkersRef.current.push(marker)
})
// Create "+" markers between vertices to add new points
for (let i = 0; i < coords.length - (isPolygon ? 0 : 1); i++) {
const nextIdx = (i + 1) % coords.length
const midLng = (coords[i][0] + coords[nextIdx][0]) / 2
const midLat = (coords[i][1] + coords[nextIdx][1]) / 2
const plus = document.createElement('div')
plus.style.cssText = 'width:18px;height:18px;background:rgba(59,130,246,0.5);border:1.5px dashed #3b82f6;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff;line-height:1;'
plus.textContent = '+'
const insertAfter = i
const addVertex = (e: Event) => {
e.stopPropagation()
const feat = featuresRef.current.find(f => f.id === featureId)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const newCoords = [...(feat.geometry.coordinates as number[][])]
newCoords.splice(insertAfter + 1, 0, [midLng, midLat])
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: newCoords } } : f)
onFeaturesChangeRef.current(updated)
} else if (feat.geometry.type === 'Polygon') {
const ring = [...(feat.geometry.coordinates as number[][][])[0]]
ring.splice(insertAfter + 1, 0, [midLng, midLat])
const updated = featuresRef.current.map(f => f.id === featureId ? { ...f, geometry: { ...f.geometry, coordinates: [ring] } } : f)
onFeaturesChangeRef.current(updated)
}
}
plus.addEventListener('click', addVertex)
plus.addEventListener('touchend', (e) => { e.preventDefault(); addVertex(e) })
const marker = new maplibregl.Marker({ element: plus, anchor: 'center' })
.setLngLat([midLng, midLat])
.addTo(m)
vertexMarkersRef.current.push(marker)
}
}, [])
showVertexMarkersRef.current = showVertexMarkers
// --- Fetch elevation data and show pressure calculation ---
const fetchElevationsAndUpdate = useCallback(async () => {
const coords = measureCoordsRef.current
if (coords.length < 2 || !map.current) return
// Diese Abfrage gehört zur aktuellen Messung; wird sie überholt, verwerfen wir das Ergebnis.
const gen = measureGenRef.current
// Interpolate extra points along the line (every ~30m) for better elevation accuracy
const sampledCoords: number[][] = [coords[0]]
const SAMPLE_INTERVAL = 30 // meters
for (let i = 1; i < coords.length; i++) {
const segDist = haversineDistance(coords[i - 1], coords[i])
const numSamples = Math.max(1, Math.floor(segDist / SAMPLE_INTERVAL))
for (let s = 1; s <= numSamples; s++) {
const t = s / numSamples
sampledCoords.push([
coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0]),
coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1]),
])
}
}
// Fetch elevations for all sampled points
const lats = sampledCoords.map(c => c[1].toFixed(6)).join(',')
const lngs = sampledCoords.map(c => c[0].toFixed(6)).join(',')
let sampledElevations: number[] = []
let elevationSource: 'open-meteo' | 'open-elevation' | 'none' = 'none'
// Try Open-Meteo first
try {
const res = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lngs}`)
if (res.ok) {
const data = await res.json()
sampledElevations = data.elevation || []
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-meteo'
}
} catch (e) {
console.warn('[Elevation] Open-Meteo nicht erreichbar')
}
// Fallback: Open-Elevation API (better resolution in some areas)
if (sampledElevations.length !== sampledCoords.length) {
try {
const locations = sampledCoords.map(c => ({ latitude: c[1], longitude: c[0] }))
const res = await fetch('https://api.open-elevation.com/api/v1/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
})
if (res.ok) {
const data = await res.json()
sampledElevations = (data.results || []).map((r: any) => r.elevation)
if (sampledElevations.length === sampledCoords.length) elevationSource = 'open-elevation'
}
} catch (e) {
console.warn('[Elevation] Open-Elevation auch nicht erreichbar')
}
}
// Final fallback: flat terrain (ehrlich als 'none' ausgewiesen)
if (sampledElevations.length !== sampledCoords.length) {
sampledElevations = sampledCoords.map(() => 0)
elevationSource = 'none'
}
// Extract elevations for the original measurement points
// First point = index 0, then for each segment we took numSamples sub-points
const elevations: number[] = [sampledElevations[0]]
let sIdx = 0
for (let i = 1; i < coords.length; i++) {
const segDist = haversineDistance(coords[i - 1], coords[i])
const numSamples = Math.max(1, Math.floor(segDist / SAMPLE_INTERVAL))
sIdx += numSamples
elevations.push(sampledElevations[sIdx] ?? 0)
}
// Use sampled elevations for more accurate height profile
// Calculate max elevation gain along full sampled path
let maxSampledElev = sampledElevations[0]
let minSampledElev = sampledElevations[0]
let totalClimb = 0
let totalDescent = 0
for (let i = 1; i < sampledElevations.length; i++) {
const diff = sampledElevations[i] - sampledElevations[i - 1]
if (diff > 0) totalClimb += diff
else totalDescent += Math.abs(diff)
maxSampledElev = Math.max(maxSampledElev, sampledElevations[i])
minSampledElev = Math.min(minSampledElev, sampledElevations[i])
}
// === Leitungsrechner: Schlauchtypen laden (volle Stammdaten) ===
let hoseTypes: HoseTypeData[] = []
try {
const htRes = await fetch('/api/hose-types')
if (htRes.ok) {
const htData = await htRes.json()
if (htData.hoseTypes && htData.hoseTypes.length > 0) {
hoseTypes = htData.hoseTypes.map((ht: any) => ({
id: ht.id,
name: ht.name,
diameterMm: ht.diameterMm,
flowRateLpm: ht.flowRateLpm,
frictionCoeff: ht.frictionCoeff,
lengthPerPieceM: ht.lengthPerPieceM ?? 20,
isDefault: ht.isDefault === true,
}))
}
}
} catch { /* ignore */ }
if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES
// Überholt? (neue/geleerte Messung während der async Abfragen) → Ergebnis verwerfen,
// damit keine Höhen-Labels/Druckwerte der ALTEN Messung erscheinen.
if (gen !== measureGenRef.current) return
let totalDist = 0
for (let i = 1; i < coords.length; i++) {
totalDist += haversineDistance(coords[i - 1], coords[i])
}
const elStart = elevations[0]
const elEnd = elevations[elevations.length - 1]
// Ergebnis in React-State — das MeasurePanel übernimmt Darstellung + Interaktion
setMeasureResult({
measurement: {
totalDistanceM: totalDist,
elevationStartM: elStart,
elevationEndM: elEnd,
netDiffM: elEnd - elStart,
climbM: totalClimb,
descentM: totalDescent,
minElevationM: minSampledElev,
maxElevationM: maxSampledElev,
samplePointCount: sampledCoords.length,
elevationSource,
},
hoseTypes,
})
// Add elevation labels at each point
coords.forEach((pt, i) => {
if (elevations[i] === 0 && elevations.every(e => e === 0)) return // skip if no real data
const elLabel = document.createElement('div')
elLabel.style.cssText = 'background:rgba(59,130,246,0.85);color:white;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:600;white-space:nowrap;pointer-events:none;'
elLabel.textContent = `${Math.round(elevations[i])}m`
if (map.current) {
const m = new maplibregl.Marker({ element: elLabel, anchor: 'top-left' })
.setLngLat(pt as [number, number])
.addTo(map.current)
measureMarkersRef.current.push(m)
}
})
}, [])
const fetchElevationsAndUpdateRef = useRef(fetchElevationsAndUpdate)
fetchElevationsAndUpdateRef.current = fetchElevationsAndUpdate
// Initialize map
useEffect(() => {
if (!mapContainer.current || map.current) return
const initialCenter = project?.mapCenter || { lng: 8.2275, lat: 47.3497 }
const initialZoom = project?.mapZoom || 15
map.current = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: [
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 19,
attribution: '© OpenStreetMap contributors',
},
satellite: {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
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,
},
},
layers: [
{
id: 'osm',
type: 'raster',
source: 'osm',
},
{
id: 'satellite',
type: 'raster',
source: 'satellite',
layout: { visibility: 'none' },
},
{
id: 'swisstopo',
type: 'raster',
source: 'swisstopo',
layout: { visibility: 'none' },
},
],
},
center: [initialCenter.lng, initialCenter.lat],
zoom: initialZoom,
preserveDrawingBuffer: true,
})
// Expose map instance to parent for export
if (externalMapRef) externalMapRef.current = map.current
// --- WebGL context loss recovery ---
// When the browser reclaims GPU memory (background tab, memory pressure),
// the WebGL context is lost and tiles go black. This recovers automatically.
const canvas = map.current.getCanvas()
canvas.addEventListener('webglcontextlost', (e) => {
console.warn('[Map] WebGL context lost — will restore when possible')
e.preventDefault() // allows context to be restored
})
canvas.addEventListener('webglcontextrestored', () => {
console.info('[Map] WebGL context restored — reloading map style')
const m = map.current
if (m) {
// Force full tile reload by re-setting the style
const style = m.getStyle()
if (style) {
m.setStyle(style)
}
}
})
// --- Page visibility recovery ---
// When user switches back to this tab after a while, tiles may be stale/black.
// Force a resize + tile re-request on visibility change.
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && map.current) {
// Small delay to let browser finish tab switch
setTimeout(() => {
if (!map.current) return
map.current.resize()
// Nudge the map to force tile re-requests
const center = map.current.getCenter()
map.current.setCenter(center)
}, 100)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Store cleanup reference
const cleanupVisibility = () => document.removeEventListener('visibilitychange', handleVisibilityChange)
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
// Bearing verfolgen, damit Nordpfeil/Windrose mit der Kartendrehung mitlaufen
map.current.on('rotate', () => setMapBearing(map.current?.getBearing() || 0))
// Geolocation: eigene Position dauerhaft anzeigen (Punkt + Genauigkeit + Blickrichtung)
const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: true,
// showUserHeading zeigt die Blickrichtung — in den Typen dieser Version (noch)
// nicht deklariert, zur Laufzeit aber vorhanden.
showUserHeading: true,
} as any)
map.current.addControl(geolocate, 'bottom-right')
// Position beim Laden aktivieren (Punkt erscheint), aber bei gesetztem Einsatzort
// auf dem Einsatzort bleiben statt auf die eigene Position zu springen.
const einsatzortCenter = project?.mapCenter
const hasEinsatzort = !!einsatzortCenter &&
!(einsatzortCenter.lng === 8.5417 && einsatzortCenter.lat === 47.3769)
map.current.on('load', () => {
geolocate.trigger()
if (hasEinsatzort && einsatzortCenter) {
geolocate.once('geolocate', () => {
map.current?.easeTo({
center: [einsatzortCenter.lng, einsatzortCenter.lat],
zoom: project?.mapZoom || 17,
duration: 600,
})
})
}
})
map.current.on('load', () => {
const m = map.current
if (!m) return
setIsMapLoaded(true)
// Guard: skip if sources already exist (React strict mode double-mount)
if (m.getSource('draw-features')) return
// Drawing features source
m.addSource('draw-features', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Measurement line source + layer
m.addSource('measure-line', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
m.addLayer({
id: 'measure-line-layer',
type: 'line',
source: 'measure-line',
paint: {
'line-color': '#fbbf24',
'line-width': 3,
'line-dasharray': [4, 3],
},
})
// Preview source (for live drawing feedback)
m.addSource('draw-preview', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Create hatched pattern for danger zones
const hatchCanvas = document.createElement('canvas')
hatchCanvas.width = 16
hatchCanvas.height = 16
const hatchCtx = hatchCanvas.getContext('2d')!
hatchCtx.clearRect(0, 0, 16, 16)
hatchCtx.fillStyle = 'rgba(220, 38, 38, 0.15)'
hatchCtx.fillRect(0, 0, 16, 16)
hatchCtx.strokeStyle = 'rgba(220, 38, 38, 0.6)'
hatchCtx.lineWidth = 2
hatchCtx.beginPath()
hatchCtx.moveTo(0, 16)
hatchCtx.lineTo(16, 0)
hatchCtx.moveTo(-4, 4)
hatchCtx.lineTo(4, -4)
hatchCtx.moveTo(12, 20)
hatchCtx.lineTo(20, 12)
hatchCtx.stroke()
const hatchImg = new Image()
hatchImg.src = hatchCanvas.toDataURL()
hatchImg.onload = () => {
if (!m.hasImage('hatch-pattern')) {
m.addImage('hatch-pattern', hatchImg)
}
}
// Normal polygon fill layer (non-dangerzone)
m.addLayer({
id: 'draw-polygons-fill',
type: 'fill',
source: 'draw-features',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['!=', ['get', 'isDangerZone'], 1]],
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
},
})
// Dangerzone hatched fill layer
m.addLayer({
id: 'draw-dangerzone-fill',
type: 'fill',
source: 'draw-features',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'isDangerZone'], 1]],
paint: {
'fill-color': 'rgba(220, 38, 38, 0.2)',
'fill-opacity': 1,
},
})
// Polygon outline
m.addLayer({
id: 'draw-polygons-outline',
type: 'line',
source: 'draw-features',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: {
'line-color': ['get', 'color'],
'line-width': ['coalesce', ['get', 'width'], 3],
},
})
// Line layer — alle Linien AUSSER Rettungsachse (die bekommt ihren eigenen gestrichelten Layer)
m.addLayer({
id: 'draw-lines',
type: 'line',
source: 'draw-features',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['!=', ['get', 'lineType'], 'rettungsachse']],
layout: {
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['coalesce', ['get', 'width'], 3],
},
})
// Rettungsachse — gestrichelt, damit sie als freizuhaltende Achse klar erkennbar ist
m.addLayer({
id: 'draw-lines-rettungsachse',
type: 'line',
source: 'draw-features',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'lineType'], 'rettungsachse']],
layout: {
'line-cap': 'butt',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['coalesce', ['get', 'width'], 6],
'line-dasharray': [2, 1.5],
},
})
// Point layer
m.addLayer({
id: 'draw-points',
type: 'circle',
source: 'draw-features',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 6,
'circle-color': ['get', 'color'],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
},
})
// Preview line
m.addLayer({
id: 'draw-preview-line',
type: 'line',
source: 'draw-preview',
filter: ['==', ['geometry-type'], 'LineString'],
layout: {
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-dasharray': [3, 3],
},
})
// Preview polygon fill
m.addLayer({
id: 'draw-preview-fill',
type: 'fill',
source: 'draw-preview',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.15,
},
})
// Preview polygon outline
m.addLayer({
id: 'draw-preview-outline',
type: 'line',
source: 'draw-preview',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
'line-dasharray': [3, 3],
},
})
// --- Helper: update preview source ---
function setPreview(data: any) {
const mapRef = map.current
if (!mapRef) return
const src = mapRef.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData(data)
}
function clearPreviewData() {
setPreview({ type: 'FeatureCollection', features: [] })
}
// --- ALL drawing event handlers registered directly in map load ---
m.on('click', (e: maplibregl.MapMouseEvent) => {
if (!canEditRef.current) return
const mode = drawModeRef.current
const color = selectedColorRef.current
const width = selectedWidthRef.current
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat]
// Tap-to-place: ein zuvor angetipptes Symbol wird hier platziert (Mobil-freundlich,
// Alternative zum Drag&Drop). Hat Vorrang vor allen Zeichen-Modi.
if (pendingSymbolRef.current) {
const p = pendingSymbolRef.current
onSymbolDropRef.current(p.iconId, coords, p.imageUrl)
onSymbolPlacedRef.current?.()
return
}
if (mode === 'select') {
// Detect click near a line/polygon for vertex editing
const pixel = e.point
const tolerance = 15
const currentFeatures = featuresRef.current
let closestId: string | null = null
let closestDist = Infinity
for (const f of currentFeatures) {
if (f.geometry.type === 'LineString') {
const lineCoords = f.geometry.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; closestId = f.id }
}
} else if (f.geometry.type === 'Polygon') {
const ring = (f.geometry.coordinates as number[][][])[0]
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; closestId = f.id }
}
}
}
if (closestId && closestDist < tolerance) {
showVertexMarkersRef.current(closestId)
} else {
showVertexMarkersRef.current(null)
}
return
}
// Eraser mode: click on/near a feature to delete it
if (mode === 'eraser') {
const pixel = e.point
const tolerance = 20 // px
const currentFeatures = featuresRef.current
let closestIdx = -1
let closestDist = Infinity
for (let i = 0; i < currentFeatures.length; i++) {
const f = currentFeatures[i]
const geom = f.geometry
if (geom.type === 'Point') {
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 }
} 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) {
const deleted = currentFeatures[closestIdx]
const newFeatures = currentFeatures.filter((_, i) => i !== closestIdx)
onFeaturesChangeRef.current(newFeatures)
}
return
}
if (mode === 'text') {
onTextPlaceRef.current(coords)
return
}
if (mode === 'point') {
const newFeature: DrawFeature = {
id: `point-${Date.now()}`,
type: 'point',
geometry: { type: 'Point', coordinates: coords },
properties: { color },
}
onFeaturesChangeRef.current([...featuresRef.current, newFeature])
return
}
if (mode === 'measure') {
// Add point to measurement (only if not already finished)
if (drawingRef.current.isDrawing || measureCoordsRef.current.length === 0) {
measureCoordsRef.current.push(coords)
drawingRef.current.isDrawing = true
drawingRef.current.currentCoords = measureCoordsRef.current
setMeasurePointCount(measureCoordsRef.current.length)
updateMeasureDisplayRef.current()
}
return
}
if (mode === 'linestring' || mode === 'polygon' || mode === 'arrow' || mode === 'dangerzone') {
if (!drawingRef.current.isDrawing) {
drawingRef.current.isDrawing = true
drawingRef.current.currentCoords = [coords]
drawingRef.current.startPoint = coords
setDrawingPointCount(1)
} else {
drawingRef.current.currentCoords.push(coords)
setDrawingPointCount(drawingRef.current.currentCoords.length)
}
return
}
if (mode === 'rectangle' || mode === 'circle') {
if (!drawingRef.current.startPoint) {
drawingRef.current.startPoint = coords
drawingRef.current.isDrawing = true
} else {
const start = drawingRef.current.startPoint
if (mode === 'rectangle') {
const feat: DrawFeature = {
id: `rect-${Date.now()}`,
type: 'rectangle',
geometry: {
type: 'Polygon',
coordinates: [[
[start[0], start[1]], [coords[0], start[1]],
[coords[0], coords[1]], [start[0], coords[1]], [start[0], start[1]],
]],
},
properties: { color, width },
}
onFeaturesChangeRef.current([...featuresRef.current, feat])
} else {
const circleCoords = generateCircleCoords(start, coords)
const feat: DrawFeature = {
id: `circle-${Date.now()}`,
type: 'circle',
geometry: { type: 'Polygon', coordinates: [circleCoords] },
properties: { color, width },
}
onFeaturesChangeRef.current([...featuresRef.current, feat])
}
drawingRef.current.isDrawing = false
drawingRef.current.startPoint = null
drawingRef.current.currentCoords = []
clearPreviewData()
}
}
})
m.on('dblclick', (e: maplibregl.MapMouseEvent) => {
if (!drawingRef.current.isDrawing) return
e.preventDefault()
const mode = drawModeRef.current
// Measure mode: stop adding points, keep line + labels, fetch elevation
if (mode === 'measure') {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
clearPreviewData()
fetchElevationsAndUpdateRef.current()
return
}
if (!canEditRef.current) return
const color = selectedColorRef.current
const width = selectedWidthRef.current
const coords = drawingRef.current.currentCoords
if (coords.length < 2) {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
clearPreviewData()
return
}
if (mode === 'linestring') {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `line-${Date.now()}`, type: 'linestring',
geometry: { type: 'LineString', coordinates: coords },
properties: { color, width },
}])
} else if (mode === 'arrow') {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `arrow-${Date.now()}`, type: 'arrow',
geometry: { type: 'LineString', coordinates: coords },
properties: { color, width, isArrow: true },
}])
} else if (mode === 'polygon' && coords.length >= 3) {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `polygon-${Date.now()}`, type: 'polygon',
geometry: { type: 'Polygon', coordinates: [[...coords, coords[0]]] },
properties: { color, width },
}])
} else if (mode === 'dangerzone' && coords.length >= 3) {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `danger-${Date.now()}`, type: 'dangerzone',
geometry: { type: 'Polygon', coordinates: [[...coords, coords[0]]] },
properties: { color: '#dc2626', width: 2, isDangerZone: true },
}])
}
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
setDrawingPointCount(0)
clearPreviewData()
})
const handleDrawStart = (e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) => {
if (!canEditRef.current || drawModeRef.current !== 'freehand') return
// Multi-touch = zoom/pan, not drawing
const origEvent = (e as maplibregl.MapTouchEvent).originalEvent as TouchEvent | undefined
if (origEvent?.touches && origEvent.touches.length > 1) return
drawingRef.current.isDrawing = true
drawingRef.current.currentCoords = [[e.lngLat.lng, e.lngLat.lat]]
}
m.on('mousedown', handleDrawStart)
m.on('touchstart', handleDrawStart)
const handleDrawMove = (e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) => {
const mode = drawModeRef.current
const color = selectedColorRef.current
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat]
if (mode === 'measure' && drawingRef.current.isDrawing && drawingRef.current.currentCoords.length > 0) {
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'LineString', coordinates: [...drawingRef.current.currentCoords, coords] },
properties: { color: '#fbbf24' },
}],
})
return
}
if (mode === 'freehand' && drawingRef.current.isDrawing) {
// Cancel freehand if second finger added (user wants to zoom)
const origEvt = (e as maplibregl.MapTouchEvent).originalEvent as TouchEvent | undefined
if (origEvt?.touches && origEvt.touches.length > 1) {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
clearPreviewData()
return
}
drawingRef.current.currentCoords.push(coords)
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'LineString', coordinates: drawingRef.current.currentCoords },
properties: { color },
}],
})
return
}
if (!drawingRef.current.isDrawing && !drawingRef.current.startPoint) return
if (mode === 'linestring' || mode === 'arrow') {
if (drawingRef.current.currentCoords.length > 0) {
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'LineString', coordinates: [...drawingRef.current.currentCoords, coords] },
properties: { color },
}],
})
}
} else if (mode === 'polygon' || mode === 'dangerzone') {
if (drawingRef.current.currentCoords.length > 0) {
const pc = [...drawingRef.current.currentCoords, coords, drawingRef.current.currentCoords[0]]
const previewColor = mode === 'dangerzone' ? '#dc2626' : color
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [pc] },
properties: { color: previewColor },
}],
})
}
} else if (mode === 'rectangle' && drawingRef.current.startPoint) {
const s = drawingRef.current.startPoint
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [[ [s[0], s[1]], [coords[0], s[1]], [coords[0], coords[1]], [s[0], coords[1]], [s[0], s[1]] ]] },
properties: { color },
}],
})
} else if (mode === 'circle' && drawingRef.current.startPoint) {
const s = drawingRef.current.startPoint
const cc = generateCircleCoords(s, coords)
setPreview({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [cc] },
properties: { color },
}],
})
}
}
m.on('mousemove', handleDrawMove)
m.on('touchmove', handleDrawMove)
const handleDrawEnd = () => {
if (!canEditRef.current || drawModeRef.current !== 'freehand' || !drawingRef.current.isDrawing) return
const coords = drawingRef.current.currentCoords
if (coords.length >= 2) {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `freehand-${Date.now()}`, type: 'freehand',
geometry: { type: 'LineString', coordinates: coords },
properties: { color: selectedColorRef.current, width: selectedWidthRef.current },
}])
}
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
clearPreviewData()
// Apple-style: auto-return to select after freehand stroke
onDrawModeChangeRef.current?.('select')
}
m.on('mouseup', handleDrawEnd)
m.on('touchend', handleDrawEnd)
})
return () => {
cleanupVisibility()
map.current?.remove()
map.current = null
}
}, [])
// Update map center only when a DIFFERENT project is loaded (by ID change)
const prevProjectIdRef = useRef<string | null>(null)
useEffect(() => {
if (map.current && project && project.id !== prevProjectIdRef.current) {
prevProjectIdRef.current = project.id
map.current.flyTo({
center: [project.mapCenter.lng, project.mapCenter.lat],
zoom: project.mapZoom,
duration: 1500,
})
}
}, [project])
// Select a symbol for Moveable editing
const selectSymbol = useCallback((featureId: string, wrapperEl: HTMLElement, innerEl: HTMLElement) => {
const prev = selectedSymbolRef.current
// Save pending changes from previous symbol
if (prev && prev.id !== featureId) {
const updated = featuresRef.current.map(f =>
f.id === prev.id
? { ...f, properties: { ...f.properties, rotation: prev.rotation, scale: prev.scale } }
: f
)
onFeaturesChangeRef.current(updated)
// Remove visual indicator from previous
prev.wrapperEl.style.outline = ''
prev.wrapperEl.style.outlineOffset = ''
prev.wrapperEl.style.boxShadow = ''
}
const feat = featuresRef.current.find(f => f.id === featureId)
if (!feat) return
const rotation = (feat.properties.rotation as number) || 0
const scale = (feat.properties.scale as number) || 1
const featureType = feat.type === 'text' ? 'text' as const : 'symbol' as const
const baseFontSize = featureType === 'text' ? ((feat.properties.fontSize as number) || 14) : undefined
selectedSymbolRef.current = { id: featureId, wrapperEl, innerEl, rotation, scale, featureType, baseFontSize }
// Visual selection indicator
wrapperEl.style.outline = '2px solid #3b82f6'
wrapperEl.style.outlineOffset = '4px'
wrapperEl.style.boxShadow = '0 0 16px rgba(59,130,246,0.4)'
// Bump key to force Moveable re-mount with new target
setSymbolSelectKey(k => k + 1)
setIsSymbolSelected(true)
}, [])
// Deselect symbol and save pending changes
const deselectSymbol = useCallback(() => {
const sel = selectedSymbolRef.current
if (!sel) return
// Save rotation/scale to features
const updated = featuresRef.current.map(f =>
f.id === sel.id
? { ...f, properties: { ...f.properties, rotation: sel.rotation, scale: sel.scale } }
: f
)
onFeaturesChangeRef.current(updated)
// Remove visual selection
sel.wrapperEl.style.outline = ''
sel.wrapperEl.style.outlineOffset = ''
sel.wrapperEl.style.boxShadow = ''
selectedSymbolRef.current = null
setIsSymbolSelected(false)
}, [])
// Delete selected symbol
const deleteSelectedSymbol = useCallback(() => {
const sel = selectedSymbolRef.current
if (!sel) return
const updated = featuresRef.current.filter(f => f.id !== sel.id)
onFeaturesChangeRef.current(updated)
selectedSymbolRef.current = null
setIsSymbolSelected(false)
}, [])
// Update features on map
useEffect(() => {
if (!map.current || !isMapLoaded) return
// Remember selected symbol ID so we can re-attach after marker recreation
const reselectedId = selectedSymbolRef.current?.id || null
const reselectedRotation = selectedSymbolRef.current?.rotation || 0
const reselectedScale = selectedSymbolRef.current?.scale || 1
const source = map.current.getSource('draw-features') as maplibregl.GeoJSONSource
if (source) {
const geojsonFeatures = features
.filter(f => f.type !== 'symbol' && f.type !== 'text')
.map(f => ({
type: 'Feature' as const,
geometry: f.geometry as any,
properties: {
id: f.id,
color: (f.properties.color as string) || '#000000',
width: (f.properties.width as number) || 3,
isDangerZone: f.properties.isDangerZone ? 1 : 0,
lineType: (f.properties.lineType as string) || 'normal',
},
}))
source.setData({
type: 'FeatureCollection' as const,
features: geojsonFeatures,
})
}
// Clear old markers and their cleanup functions
markerCleanupsRef.current.forEach(fn => fn())
markerCleanupsRef.current = []
markersRef.current.forEach(m => m.remove())
markersRef.current = []
// Add arrowheads for arrow features
features
.filter(f => f.type === 'arrow')
.forEach(f => {
if (f.geometry.type === 'LineString' && map.current) {
const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) return
// Geographic bearing from p1 to p2 (works for short distances)
const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1]
const dLng = p2[0] - p1[0]
const dLat = p2[1] - p1[1]
// atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI)
const color = (f.properties.color as string) || '#000000'
const arrowEl = document.createElement('div')
arrowEl.style.cssText = `
width: 0; height: 0;
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-bottom: 24px solid ${color};
transform: rotate(${geoBearing}deg);
transform-origin: center center;
pointer-events: none;
`
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(p2 as [number, number])
.addTo(map.current)
markersRef.current.push(marker)
}
})
// Add line/polygon label markers at midpoints
features
.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))
.forEach(f => {
if (!map.current) return
const label = f.properties.label as string
let midpoint: [number, number]
if (f.geometry.type === 'LineString') {
const coords = f.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
midpoint = coords[midIdx] as [number, number]
}
} else {
// Polygon: use centroid of first ring
const ring = (f.geometry.coordinates as number[][][])[0]
const len = ring.length - 1 // exclude closing point
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
midpoint = [cx / len, cy / len]
}
// Apply stored label offset if present
const labelOffset = f.properties.labelOffset as [number, number] | undefined
if (labelOffset) {
midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
}
const el = document.createElement('div')
const isDanger = f.type === 'dangerzone'
el.style.cssText = `
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
color: #fff;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
pointer-events: ${canEdit ? 'auto' : 'none'};
letter-spacing: 0.3px;
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
cursor: ${canEdit ? 'grab' : 'default'};
transform: translate(0,0);
will-change: transform;
`
// Build label content: label text + line length & hose count for LineStrings
if (f.geometry.type === 'LineString') {
const lineCoords = f.geometry.coordinates as number[][]
let totalLen = 0
for (let i = 1; i < lineCoords.length; i++) {
totalLen += haversineDistance(lineCoords[i - 1], lineCoords[i])
}
const hoseLength = 20 // meters per hose piece
const hoseCount = Math.ceil(totalLen / hoseLength)
const lenText = totalLen < 1000 ? `${Math.round(totalLen)}m` : `${(totalLen / 1000).toFixed(2)}km`
const labelLine = document.createElement('div')
labelLine.textContent = label
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
} else {
// Polygon: show label + area
const ring = (f.geometry.coordinates as number[][][])[0]
const area = polygonArea(ring)
const areaText = area < 10000 ? `${Math.round(area)}` : `${(area / 10000).toFixed(2)} ha`
const labelLine = document.createElement('div')
labelLine.textContent = label
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = areaText
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
}
// Double-click to edit label — only in select mode
if (canEdit) {
el.addEventListener('dblclick', (e) => {
e.stopPropagation()
if (drawModeRef.current !== 'select') return
const currentLabel = (featuresRef.current.find(feat => feat.id === f.id)?.properties.label as string) || ''
setInlineEdit({ featureId: f.id, type: 'label', value: currentLabel })
})
}
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'map' })
.setLngLat(midpoint)
.addTo(map.current)
// Save label position offset on drag end
if (canEdit) {
marker.on('dragend', () => {
const newPos = marker.getLngLat()
// Calculate midpoint without offset to get the base midpoint
let baseMid: [number, number]
const feat = featuresRef.current.find(feat => feat.id === f.id)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const coords = feat.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
baseMid = coords[midIdx] as [number, number]
}
} else {
const ring = (feat.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
baseMid = [cx / len, cy / len]
}
const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
const updated = featuresRef.current.map(pf =>
pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
)
onFeaturesChangeRef.current(updated)
})
}
markersRef.current.push(marker)
})
// Add symbol markers
features
.filter(f => f.type === 'symbol')
.forEach(f => {
if (f.geometry.type === 'Point' && map.current) {
const coords = f.geometry.coordinates as [number, number]
const scale = (f.properties.scale as number) || 1
const rotation = (f.properties.rotation as number) || 0
const imageUrl = f.properties.imageUrl as string
const iconId = f.properties.iconId as string
// Resolve image URL: uploaded symbols have imageUrl, builtin use data URI
let imgSrc = ''
if (imageUrl) {
imgSrc = imageUrl
} else {
const symbolDef = getSymbolById(iconId)
if (symbolDef) imgSrc = getSymbolDataUri(symbolDef)
}
const baseSize = 32
const placementZoom = (f.properties.placementZoom as number) || 17
const currentZoom = map.current.getZoom()
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor))
// Wrapper: used as MapLibre marker element (MapLibre sets transform on this)
const wrapper = document.createElement('div')
wrapper.className = 'symbol-marker-wrapper'
wrapper.dataset.featureId = f.id
wrapper.dataset.baseScale = String(scale)
wrapper.dataset.placementZoom = String(placementZoom)
wrapper.style.width = `${size}px`
wrapper.style.height = `${size}px`
wrapper.style.cursor = canEdit ? 'move' : 'default'
// Inner element: rotation/scale applied here (won't interfere with MapLibre positioning)
const inner = document.createElement('div')
inner.className = 'symbol-marker'
inner.style.width = '100%'
inner.style.height = '100%'
inner.style.transformOrigin = 'center center'
inner.style.transform = `rotate(${rotation}deg)`
inner.style.transition = 'transform 0.1s'
// Try primary image source, with fallback chain for broken/deleted icons
const applyImage = (src: string) => {
inner.style.backgroundImage = `url("${src}")`
inner.style.backgroundSize = 'contain'
inner.style.backgroundRepeat = 'no-repeat'
inner.style.backgroundPosition = 'center'
}
if (imgSrc) {
applyImage(imgSrc)
// If image fails to load (404 from deleted icon), try fallback
const testImg = new Image()
testImg.onload = () => {} // OK
testImg.onerror = () => {
// Fallback 1: Try API endpoint with iconId
if (iconId) {
const fallbackUrl = `/api/icons/${iconId}/image`
if (fallbackUrl !== imgSrc) {
const test2 = new Image()
test2.onload = () => applyImage(fallbackUrl)
test2.onerror = () => {
// Fallback 2: Show broken symbol indicator
inner.style.backgroundImage = 'none'
inner.style.display = 'flex'
inner.style.alignItems = 'center'
inner.style.justifyContent = 'center'
inner.style.border = '2px dashed #ef4444'
inner.style.borderRadius = '4px'
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
test2.src = fallbackUrl
} else {
inner.style.backgroundImage = 'none'
inner.style.display = 'flex'
inner.style.alignItems = 'center'
inner.style.justifyContent = 'center'
inner.style.border = '2px dashed #ef4444'
inner.style.borderRadius = '4px'
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
}
}
testImg.src = imgSrc
} else if (iconId) {
// No imageUrl at all — try loading via API
applyImage(`/api/icons/${iconId}/image`)
}
wrapper.appendChild(inner)
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode
if (canEdit) {
let dragHappened = false
wrapper.addEventListener('mousedown', () => { dragHappened = false })
wrapper.addEventListener('mousemove', () => { dragHappened = true })
wrapper.addEventListener('click', (e) => {
if (dragHappened) return
if (drawModeRef.current !== 'select') return // Only select in pointer/select mode
e.stopPropagation()
selectSymbol(f.id, wrapper, inner)
})
let touchStartPos: { x: number; y: number } | null = null
wrapper.addEventListener('touchstart', (e) => {
touchStartPos = { x: e.touches[0].clientX, y: e.touches[0].clientY }
}, { passive: true })
wrapper.addEventListener('touchend', (e) => {
if (!touchStartPos) return
if (drawModeRef.current !== 'select') { touchStartPos = null; return }
const touch = e.changedTouches[0]
const dist = Math.hypot(touch.clientX - touchStartPos.x, touch.clientY - touchStartPos.y)
if (dist < 10) selectSymbol(f.id, wrapper, inner)
touchStartPos = null
})
wrapper.addEventListener('contextmenu', (e) => e.preventDefault())
}
try {
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords)
.addTo(map.current)
if (canEdit) {
marker.on('dragstart', () => {
wrapper.style.outline = '3px solid #3b82f6'
wrapper.style.outlineOffset = '2px'
wrapper.style.borderRadius = '6px'
wrapper.style.boxShadow = '0 0 12px rgba(59,130,246,0.5)'
wrapper.style.zIndex = '100'
})
marker.on('dragend', () => {
wrapper.style.outline = ''
wrapper.style.outlineOffset = ''
wrapper.style.boxShadow = ''
wrapper.style.zIndex = ''
const newPos = marker.getLngLat()
const updated = featuresRef.current.map(feat =>
feat.id === f.id
? { ...feat, geometry: { ...feat.geometry, coordinates: [newPos.lng, newPos.lat] } }
: feat
)
onFeaturesChangeRef.current(updated)
})
}
markersRef.current.push(marker)
} catch (err) {
console.error('[Markers] Symbol marker error:', f.id, err)
}
}
})
// Add text markers
features
.filter(f => f.type === 'text')
.forEach(f => {
if (f.geometry.type === 'Point' && map.current) {
const coords = f.geometry.coordinates as [number, number]
const fontSize = (f.properties.fontSize as number) || 14
const scale = (f.properties.scale as number) || 1
// Wrapper for Moveable targeting (like symbols)
const wrapper = document.createElement('div')
wrapper.className = 'symbol-marker-wrapper'
wrapper.dataset.featureId = f.id
wrapper.dataset.baseScale = String(scale)
wrapper.style.cursor = canEdit ? 'move' : 'default'
wrapper.style.display = 'inline-block'
const el = document.createElement('div')
el.className = 'text-marker symbol-marker'
el.style.cssText = `
font-size: ${fontSize * scale}px;
font-weight: bold;
color: ${(f.properties.color as string) || '#000000'};
white-space: nowrap;
text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;
user-select: none;
pointer-events: auto;
transform-origin: center center;
`
el.textContent = (f.properties.text as string) || ''
wrapper.appendChild(el)
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords)
.addTo(map.current)
if (canEdit) {
marker.on('dragend', () => {
const newPos = marker.getLngLat()
const updated = featuresRef.current.map(feat =>
feat.id === f.id
? { ...feat, geometry: { ...feat.geometry, coordinates: [newPos.lng, newPos.lat] } }
: feat
)
onFeaturesChangeRef.current(updated)
})
// Click to select for Moveable editing (resize) — ONLY in select mode
let dragHappened = false
wrapper.addEventListener('mousedown', () => { dragHappened = false })
wrapper.addEventListener('mousemove', () => { dragHappened = true })
wrapper.addEventListener('click', (e) => {
if (dragHappened) return
if (drawModeRef.current !== 'select') return
e.stopPropagation()
selectSymbol(f.id, wrapper, el)
})
let touchStartPos: { x: number; y: number } | null = null
wrapper.addEventListener('touchstart', (e) => {
touchStartPos = { x: e.touches[0].clientX, y: e.touches[0].clientY }
}, { passive: true })
wrapper.addEventListener('touchend', (e) => {
if (!touchStartPos) return
if (drawModeRef.current !== 'select') { touchStartPos = null; return }
const touch = e.changedTouches[0]
const dist = Math.hypot(touch.clientX - touchStartPos.x, touch.clientY - touchStartPos.y)
if (dist < 10) selectSymbol(f.id, wrapper, el)
touchStartPos = null
})
// Double-click to edit text — only in select mode
el.addEventListener('dblclick', (e) => {
e.stopPropagation()
if (drawModeRef.current !== 'select') return
const currentText = (featuresRef.current.find(feat => feat.id === f.id)?.properties.text as string) || ''
setInlineEdit({ featureId: f.id, type: 'text', value: currentText })
})
}
markersRef.current.push(marker)
}
})
// Re-attach Moveable selection to new DOM elements after marker recreation
if (reselectedId) {
const newWrapper = document.querySelector(`.symbol-marker-wrapper[data-feature-id="${reselectedId}"]`) as HTMLElement | null
if (newWrapper) {
const newInner = newWrapper.querySelector('.symbol-marker') as HTMLElement | null
if (newInner) {
const reselFeat = features.find(f => f.id === reselectedId)
selectedSymbolRef.current = {
id: reselectedId,
wrapperEl: newWrapper,
innerEl: newInner,
rotation: reselectedRotation,
scale: reselectedScale,
featureType: reselFeat?.type === 'text' ? 'text' : 'symbol',
baseFontSize: reselFeat?.type === 'text' ? ((reselFeat.properties.fontSize as number) || 14) : undefined,
}
// Re-apply visual selection indicator
newWrapper.style.outline = '2px solid #3b82f6'
newWrapper.style.outlineOffset = '4px'
newWrapper.style.boxShadow = '0 0 16px rgba(59,130,246,0.4)'
// Bump key to re-mount Moveable with new target
setSymbolSelectKey(k => k + 1)
} else {
selectedSymbolRef.current = null
setIsSymbolSelected(false)
}
} else {
// Symbol was deleted
selectedSymbolRef.current = null
setIsSymbolSelected(false)
}
}
// Re-show vertex markers if a line/polygon is still selected after feature update
if (selectedLineIdRef.current) {
const stillExists = features.find(f => f.id === selectedLineIdRef.current)
if (stillExists) {
showVertexMarkersRef.current(selectedLineIdRef.current)
} else {
showVertexMarkersRef.current(null)
}
}
}, [features, isMapLoaded, canEdit])
// Reset measurement state when leaving measure mode (but keep info panel — user closes it)
useEffect(() => {
if (drawMode !== 'measure') {
measureGenRef.current++ // pendende Höhen-Abfrage beim Verlassen entwerten
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
measureCoordsRef.current = []
setMeasurePointCount(0)
setMeasureFinished(false)
// Clear the measure line source
if (map.current) {
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
// Info panel stays — user closes it with X
}
}, [drawMode])
// Keep Moveable box in sync with symbol position during map pan/zoom
// AND update symbol sizes on zoom (geo-scaling: symbols scale with the map)
useEffect(() => {
if (!map.current) return
const handleMapMove = () => {
if (moveableRef.current && selectedSymbolRef.current) {
moveableRef.current.updateRect()
}
}
const handleZoom = () => {
handleMapMove()
// Update all symbol marker sizes based on current zoom
const currentZoom = map.current?.getZoom() || 17
const baseSize = 32
document.querySelectorAll<HTMLElement>('.symbol-marker-wrapper').forEach(wrapper => {
const bScale = parseFloat(wrapper.dataset.baseScale || '1')
const pZoom = parseFloat(wrapper.dataset.placementZoom || '17')
const zoomFactor = Math.pow(2, currentZoom - pZoom)
const size = Math.max(8, Math.min(400, baseSize * bScale * zoomFactor))
wrapper.style.width = `${size}px`
wrapper.style.height = `${size}px`
})
}
map.current.on('move', handleMapMove)
map.current.on('zoom', handleZoom)
return () => {
map.current?.off('move', handleMapMove)
map.current?.off('zoom', handleZoom)
}
}, [isSymbolSelected])
// When not in 'select' mode, make symbol/text markers click-through so drawing works through them
useEffect(() => {
const pointerEvents = drawMode === 'select' ? 'auto' : 'none'
markersRef.current.forEach(m => {
const el = m.getElement()
if (el && (el.classList.contains('symbol-marker-wrapper') || el.classList.contains('text-marker'))) {
el.style.pointerEvents = pointerEvents
}
})
// Also deselect any selected symbol when switching away from select mode
if (drawMode !== 'select' && selectedSymbolRef.current) {
deselectSymbol()
}
// Clear vertex editing when leaving select mode
if (drawMode !== 'select' && selectedLineIdRef.current) {
showVertexMarkersRef.current(null)
}
}, [drawMode, deselectSymbol])
// ESC to cancel drawing, DEL to delete selected symbol/line/polygon
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// DEL / Backspace → delete selected symbol or line/polygon
if (e.key === 'Delete' || e.key === 'Backspace') {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// Delete selected symbol/text
if (selectedSymbolRef.current) {
e.preventDefault()
deleteSelectedSymbol()
return
}
// Delete selected line/polygon/arrow (vertex-editing selection)
if (selectedLineIdRef.current) {
e.preventDefault()
const updated = featuresRef.current.filter(f => f.id !== selectedLineIdRef.current)
onFeaturesChangeRef.current(updated)
showVertexMarkersRef.current(null)
return
}
}
if (e.key === 'Escape') {
// In measure mode: finalize (keep line + labels), just stop adding
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
if (map.current) {
const src = map.current.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
// Fetch elevation for finalized measurement
fetchElevationsAndUpdateRef.current()
return
}
// Other modes: cancel
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
if (map.current) {
const src = map.current.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [deleteSelectedSymbol])
// Drop zone for symbols — use stable ref connection (no inline ref callback)
const [, drop] = useDrop(() => ({
accept: 'SYMBOL',
drop: (item: { iconId: string; imageUrl?: string }, monitor) => {
if (!map.current || !mapContainer.current) return
const offset = monitor.getClientOffset()
if (!offset) return
const rect = mapContainer.current.getBoundingClientRect()
const lngLat = map.current.unproject([offset.x - rect.left, offset.y - rect.top])
onSymbolDropRef.current(item.iconId, [lngLat.lng, lngLat.lat], item.imageUrl)
// Reset draw mode to select after placing a symbol
onDrawModeChangeRef.current?.('select')
return { dropped: true }
},
}), [])
// Connect drop target via useEffect (avoids inline ref re-registration during drag)
useEffect(() => {
if (mapContainer.current) {
drop(mapContainer.current)
}
}, [drop])
// Set cursor directly on the MapLibre canvas to override its default grab cursor
useEffect(() => {
if (!map.current) return
const canvas = map.current.getCanvas()
if (!canvas) return
let cursor = ''
if (canEdit) {
switch (drawMode) {
case 'point': case 'linestring': case 'polygon':
case 'rectangle': case 'circle': case 'arrow': case 'freehand':
case 'measure': case 'dangerzone':
cursor = 'crosshair'
break
case 'eraser':
cursor = 'pointer'
break
case 'text':
cursor = 'text'
break
default:
cursor = ''
}
}
if (cursor) {
canvas.style.cursor = cursor
map.current.getContainer().style.cursor = cursor
} else {
canvas.style.cursor = ''
map.current.getContainer().style.cursor = ''
}
}, [drawMode, canEdit, isMapLoaded])
// Cursor-following tooltip for drawing/measuring modes
useEffect(() => {
const container = mapContainer.current
if (!container) return
const onMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect()
const x = e.clientX - rect.left + 16
const y = e.clientY - rect.top - 10
tooltipPosRef.current = { x, y }
const tip = tooltipRef.current
if (tip) {
tip.style.left = `${x}px`
tip.style.top = `${y}px`
}
}
container.addEventListener('mousemove', onMove)
return () => container.removeEventListener('mousemove', onMove)
}, [isMapLoaded])
// Finish line/polygon/arrow drawing handler
const finishDrawing = useCallback(() => {
const mode = drawModeRef.current
const coords = drawingRef.current.currentCoords
const color = selectedColorRef.current
const width = selectedWidthRef.current
if (coords.length < 2) {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
setDrawingPointCount(0)
if (map.current) {
const src = map.current.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
return
}
if (mode === 'linestring') {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `line-${Date.now()}`, type: 'linestring',
geometry: { type: 'LineString', coordinates: coords },
properties: { color, width },
}])
} else if (mode === 'arrow') {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `arrow-${Date.now()}`, type: 'arrow',
geometry: { type: 'LineString', coordinates: coords },
properties: { color, width, isArrow: true },
}])
} else if (mode === 'polygon' && coords.length >= 3) {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `polygon-${Date.now()}`, type: 'polygon',
geometry: { type: 'Polygon', coordinates: [[...coords, coords[0]]] },
properties: { color, width },
}])
} else if (mode === 'dangerzone' && coords.length >= 3) {
onFeaturesChangeRef.current([...featuresRef.current, {
id: `danger-${Date.now()}`, type: 'dangerzone',
geometry: { type: 'Polygon', coordinates: [[...coords, coords[0]]] },
properties: { color: '#dc2626', width: 2, isDangerZone: true },
}])
}
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
setDrawingPointCount(0)
if (map.current) {
const src = map.current.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
// Apple-style: auto-return to select mode after finishing drawing
onDrawModeChangeRef.current?.('select')
}, [])
// Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand)
const clearMeasurement = useCallback(() => {
measureGenRef.current++ // laufende Höhen-Abfragen der alten Messung entwerten
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
measureCoordsRef.current = []
setMeasurePointCount(0)
setMeasureFinished(false)
setMeasureResult(null)
if (map.current) {
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
}, [])
// Finish measurement handler
const finishMeasure = useCallback(() => {
drawingRef.current.isDrawing = false
drawingRef.current.currentCoords = []
drawingRef.current.startPoint = null
setMeasureFinished(true)
if (map.current) {
const src = map.current.getSource('draw-preview') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
fetchElevationsAndUpdateRef.current()
// WICHTIG: im Mess-Modus bleiben, sonst löscht der Reset-Effekt (drawMode !== 'measure')
// die eben gezeichnete Linie sofort wieder. Der Nutzer beendet über „Neue Messung"
// oder durch Wahl eines anderen Werkzeugs.
}, [])
return (
<div className="relative w-full h-full" onClick={() => { if (isSymbolSelected) deselectSymbol() }}>
<div
ref={mapContainer}
className="w-full h-full"
/>
{/* Vollbild-Umschalter — mobil als schwebender Button (versteckt die Browser-Leiste) */}
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
className="md:hidden absolute top-3 left-3 z-10 w-11 h-11 flex items-center justify-center rounded-lg bg-white/92 text-gray-800 shadow-md border border-black/10 active:scale-95 transition-transform"
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></svg>
) : (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
)}
</button>
)}
{/* Nordpfeil + Windrose (Lagebild) */}
<MapCompass
bearing={mapBearing}
windDirection={windDirection}
canEdit={canEdit}
onWindChange={(dir) => onWindChange?.(dir)}
onFetchLiveWind={onFetchLiveWind}
/>
{/* Moveable controls for selected symbol */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
<>
<Moveable
key={symbolSelectKey}
ref={moveableRef}
target={selectedSymbolRef.current.wrapperEl}
rotatable={selectedSymbolRef.current.featureType === 'symbol'}
resizable={true}
keepRatio={selectedSymbolRef.current.featureType === 'symbol'}
throttleRotate={1}
renderDirections={['nw', 'ne', 'sw', 'se']}
rotationPosition={'top'}
origin={false}
padding={{ left: 6, top: 6, right: 6, bottom: 6 }}
onRotateStart={({ set }) => {
set(selectedSymbolRef.current?.rotation || 0)
}}
onRotate={({ rotation }) => {
if (selectedSymbolRef.current) {
selectedSymbolRef.current.innerEl.style.transform = `rotate(${rotation}deg)`
selectedSymbolRef.current.rotation = rotation
}
}}
onRotateEnd={() => {
const sel = selectedSymbolRef.current
if (sel) {
const updated = featuresRef.current.map(f =>
f.id === sel.id ? { ...f, properties: { ...f.properties, rotation: sel.rotation, scale: sel.scale } } : f
)
onFeaturesChangeRef.current(updated)
}
}}
onResizeStart={({ setOrigin }) => {
setOrigin(['%', '%'])
if (selectedSymbolRef.current) {
selectedSymbolRef.current.resizeStartWidth = selectedSymbolRef.current.wrapperEl.getBoundingClientRect().width
selectedSymbolRef.current.resizeStartScale = selectedSymbolRef.current.scale
}
}}
onResize={({ width, height }) => {
if (selectedSymbolRef.current) {
if (selectedSymbolRef.current.featureType === 'text') {
// For text: scale fontSize proportionally based on initial width
const baseFontSize = selectedSymbolRef.current.baseFontSize || 14
const startW = selectedSymbolRef.current.resizeStartWidth || 1
const startScale = selectedSymbolRef.current.resizeStartScale || 1
const ratio = width / startW
selectedSymbolRef.current.scale = Math.max(0.2, Math.min(10, startScale * ratio))
selectedSymbolRef.current.innerEl.style.fontSize = `${baseFontSize * selectedSymbolRef.current.scale}px`
} else {
// For symbols: resize wrapper, use ratio from start to preserve zoom-aware scale
selectedSymbolRef.current.wrapperEl.style.width = `${width}px`
selectedSymbolRef.current.wrapperEl.style.height = `${height}px`
const startW = selectedSymbolRef.current.resizeStartWidth || 1
const startScale = selectedSymbolRef.current.resizeStartScale || 1
selectedSymbolRef.current.scale = Math.max(0.1, Math.min(10, startScale * (width / startW)))
}
}
}}
onResizeEnd={() => {
const sel = selectedSymbolRef.current
if (sel) {
const updated = featuresRef.current.map(f =>
f.id === sel.id ? { ...f, properties: { ...f.properties, rotation: sel.rotation, scale: sel.scale } } : f
)
onFeaturesChangeRef.current(updated)
}
}}
/>
{/* Floating action bar for selected symbol */}
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[10000] flex items-center gap-2 bg-white/95 backdrop-blur-sm rounded-xl shadow-xl border border-gray-200 px-3 py-2"
onClick={(e) => e.stopPropagation()}
>
<span className="text-xs font-semibold text-gray-600 mr-1">
{Math.round(selectedSymbolRef.current.rotation)}°
</span>
<div className="w-px h-6 bg-gray-200" />
<span className="text-xs font-semibold text-gray-600 mr-1">
{selectedSymbolRef.current.scale.toFixed(1)}x
</span>
<div className="w-px h-6 bg-gray-200" />
<button
onClick={deleteSelectedSymbol}
className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-semibold rounded-lg transition-colors"
>
Löschen
</button>
<button
onClick={(e) => { e.stopPropagation(); deselectSymbol() }}
className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-semibold rounded-lg transition-colors"
>
Fertig
</button>
</div>
</>
)}
{/* Layer selector dropdown */}
<div className="absolute top-3 right-3 z-10">
<button
onClick={() => setLayerDropdownOpen(v => !v)}
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"
style={{
background: activeBaseLayer !== 'osm' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.92)',
color: activeBaseLayer !== 'osm' ? '#fff' : '#1f2937',
borderColor: activeBaseLayer !== 'osm' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)',
}}
>
<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>
{{ osm: 'OpenStreetMap', satellite: 'Satellit', swisstopo: 'Swisstopo' }[activeBaseLayer]}
<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>
{layerDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setLayerDropdownOpen(false)} />
<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)' }}>
{([
{ key: 'osm', label: 'OpenStreetMap' },
{ key: 'satellite', label: 'Satellit (Esri)' },
{ key: 'swisstopo', label: 'Swisstopo Karte' },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => {
if (!map.current) return
const allLayers: Array<'osm' | 'satellite' | 'swisstopo'> = ['osm', 'satellite', 'swisstopo']
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>
</>
)}
</div>
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */}
{(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && (
<button
onClick={finishDrawing}
style={{
position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
zIndex: 1000, padding: '12px 28px', fontSize: '15px', fontWeight: 700,
background: '#22c55e', color: '#fff', border: 'none', borderRadius: '10px',
cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
}}
>
Zeichnung abschliessen ({drawingPointCount} Punkte)
</button>
)}
{/* Messung abschliessen Button */}
{drawMode === 'measure' && measurePointCount >= 2 && !measureFinished && (
<button
onClick={finishMeasure}
style={{
position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
zIndex: 1000, padding: '12px 28px', fontSize: '15px', fontWeight: 700,
background: '#fbbf24', color: '#000', border: 'none', borderRadius: '10px',
cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
}}
>
Messung abschliessen ({measurePointCount} Punkte)
</button>
)}
{/* Neue Messung starten */}
{drawMode === 'measure' && measureFinished && (
<button
onClick={clearMeasurement}
style={{
position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
zIndex: 1000, padding: '12px 28px', fontSize: '15px', fontWeight: 700,
background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '10px',
cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
}}
>
Neue Messung
</button>
)}
{/* Messergebnis + Leitungsrechner (React-Panel, ersetzt das alte DOM-Overlay) */}
{measureResult && (
<MeasurePanel
measurement={measureResult.measurement}
hoseTypes={measureResult.hoseTypes}
onClose={clearMeasurement}
/>
)}
{/* Inline edit overlay — replaces native prompt() to prevent fullscreen exit */}
{inlineEdit && (
<div
className="absolute top-3 left-1/2 -translate-x-1/2 z-[10000] flex items-center gap-2 bg-white/95 backdrop-blur-sm rounded-xl shadow-xl border border-gray-200 px-3 py-2"
onClick={(e) => e.stopPropagation()}
>
<span className="text-xs font-semibold text-gray-500 whitespace-nowrap">
{inlineEdit.type === 'label' ? 'Bezeichnung:' : 'Text:'}
</span>
<input
ref={inlineEditInputRef}
type="text"
value={inlineEdit.value}
onChange={(e) => setInlineEdit({ ...inlineEdit, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = inlineEdit.value.trim()
const prop = inlineEdit.type === 'label' ? 'label' : 'text'
const updated = featuresRef.current.map(feat =>
feat.id === inlineEdit.featureId
? { ...feat, properties: { ...feat.properties, [prop]: val || undefined } }
: feat
)
onFeaturesChangeRef.current(updated)
setInlineEdit(null)
}
if (e.key === 'Escape') setInlineEdit(null)
}}
autoFocus
className="w-48 px-2 py-1 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder={inlineEdit.type === 'label' ? 'Bezeichnung...' : 'Text...'}
/>
<button
onClick={() => {
const val = inlineEdit.value.trim()
const prop = inlineEdit.type === 'label' ? 'label' : 'text'
const updated = featuresRef.current.map(feat =>
feat.id === inlineEdit.featureId
? { ...feat, properties: { ...feat.properties, [prop]: val || undefined } }
: feat
)
onFeaturesChangeRef.current(updated)
setInlineEdit(null)
}}
className="px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white text-xs font-semibold rounded-lg"
>
OK
</button>
<button
onClick={() => setInlineEdit(null)}
className="px-2 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 text-xs font-semibold rounded-lg"
>
</button>
</div>
)}
{/* Cursor-following tooltip — always mounted for stable ref */}
<div
ref={tooltipRef}
className="pointer-events-none absolute z-[9999] hidden md:block"
style={{
left: tooltipPosRef.current.x,
top: tooltipPosRef.current.y,
display: (() => {
if (!canEdit || drawMode === 'select') return 'none'
if (drawMode === 'measure' && measureFinished) return 'none'
return undefined // let className handle (hidden on mobile, block on md+)
})(),
}}
>
<div className="bg-gray-900/85 text-white text-xs font-medium px-2.5 py-1.5 rounded-md shadow-lg whitespace-nowrap backdrop-blur-sm">
{(() => {
const isDrawing = drawingPointCount > 0
const isMeasuring = measurePointCount > 0
switch (drawMode) {
case 'linestring':
return isDrawing ? 'Klicke für nächsten Punkt · Doppelklick beenden' : 'Klicke für den ersten Punkt'
case 'polygon':
case 'dangerzone':
return isDrawing ? 'Klicke für nächsten Punkt · Doppelklick beenden' : 'Klicke für den ersten Punkt'
case 'arrow':
return isDrawing ? 'Klicke für nächsten Punkt · Doppelklick beenden' : 'Klicke für den Startpunkt'
case 'measure':
return isMeasuring ? 'Klicke für nächsten Messpunkt · Doppelklick beenden' : 'Klicke, um die Messung zu starten'
case 'rectangle':
return isDrawing ? 'Klicke für die gegenüberliegende Ecke' : 'Klicke für die erste Ecke'
case 'circle':
return isDrawing ? 'Klicke, um den Radius zu setzen' : 'Klicke für den Mittelpunkt'
case 'freehand':
return 'Halten und zeichnen'
case 'point':
return 'Klicke, um einen Punkt zu setzen'
case 'text':
return 'Klicke, um Text zu platzieren'
case 'eraser':
return 'Klicke auf ein Element zum Löschen'
default:
return ''
}
})()}
</div>
</div>
</div>
)
}