Files
Lageplan/src/components/map/map-view.tsx
Pepe Ziberi 1f508bca74 v1.3.2: SEO fixes + map bugfixes
SEO:
- Landing page converted to Server Component (SSR)
- Extracted NavAuthButtons + ContactForm as client islands
- Removed fake aggregateRating from JSON-LD
- Added FAQPage JSON-LD schema (7 questions)
- Extended sitemap: /datenschutz, /spenden, /demo

Map fixes:
- WebGL context lost recovery (black tiles after inactivity)
- Page visibility handler for tile reload on tab switch
- Arrow direction: geographic bearing instead of screen angle
- All markers rotationAlignment viewport->map (geographic orientation)
- DEL key now deletes selected lines/polygons/arrows (not just symbols)
- Default drawing color: black
2026-03-03 23:33:04 +01:00

2450 lines
100 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'
// 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>
}
export function MapView({
project,
features,
drawMode,
selectedColor,
selectedWidth,
onFeaturesChange,
onSymbolDrop,
onTextPlace,
canEdit,
mapRef: externalMapRef,
onDrawModeChange,
undoDrawPointRef,
}: 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[][]>([])
const [isMapLoaded, setIsMapLoaded] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
const [measurePointCount, setMeasurePointCount] = useState(0)
const [measureFinished, setMeasureFinished] = useState(false)
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)
// 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])
// 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
// 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[] = []
// 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 || []
}
} 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)
}
} catch (e) {
console.warn('[Elevation] Open-Elevation auch nicht erreichbar')
}
}
// Final fallback: flat terrain
if (sampledElevations.length !== sampledCoords.length) {
sampledElevations = sampledCoords.map(() => 0)
}
// 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])
}
// === Feuerwehr Druckverlust-Berechnung (Schweizer Standard) ===
const AUSGANGSDRUCK_STRAHLROHR = 5.0 // bar am Strahlrohr/Monitor
const PUMPE_MAX_DRUCK = 10.0 // bar Förderdruck TS/FP
const HOEHENDRUCK_FAKTOR = 0.1 // bar pro Meter Höhe
// Load hose types from API (fall back to hardcoded defaults)
let hoseTypes: { name: string; diameter: number; flow: number; c: number }[] = []
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) => ({
name: ht.name,
diameter: ht.diameterMm,
flow: ht.flowRateLpm,
c: ht.frictionCoeff,
}))
}
}
} catch { /* ignore */ }
if (hoseTypes.length === 0) {
hoseTypes = [
{ name: '55mm Transportleitung', diameter: 55, flow: 500, c: 0.034 },
{ name: '75mm Zubringerleitung', diameter: 75, flow: 1500, c: 0.012 },
]
}
let totalDist = 0
let totalHoehenDruck = 0
const segments: { dist: number; elDiff: number }[] = []
for (let i = 1; i < coords.length; i++) {
const segDist = haversineDistance(coords[i - 1], coords[i])
const elDiff = elevations[i] - elevations[i - 1]
totalDist += segDist
totalHoehenDruck += elDiff * HOEHENDRUCK_FAKTOR
segments.push({ dist: segDist, elDiff })
}
// Calculate for each hose type
const hoseCalcs = hoseTypes.map(h => {
const reibung = h.c * Math.pow(h.flow / 100, 2) * (totalDist / 100)
const gesamt = reibung + totalHoehenDruck + AUSGANGSDRUCK_STRAHLROHR
const pumpen = Math.ceil(Math.max(gesamt, 0) / PUMPE_MAX_DRUCK)
return { ...h, reibung, gesamt, pumpen }
})
const elStart = elevations[0]
const elEnd = elevations[elevations.length - 1]
const elDiff = elEnd - elStart
const elMax = Math.max(...elevations)
const elMin = Math.min(...elevations)
// Remove existing info panel if any
const existingPanel = document.getElementById('measure-info-panel')
if (existingPanel) existingPanel.remove()
// Create info panel overlay
const panel = document.createElement('div')
panel.id = 'measure-info-panel'
const isDark = document.documentElement.classList.contains('dark')
panel.style.cssText = `
position: absolute; bottom: 48px; left: 16px; z-index: 1000;
background: ${isDark ? '#1a1a1a' : 'white'}; color: ${isDark ? '#c8a060' : '#1e293b'};
border: 2px solid ${isDark ? '#c89040' : '#fbbf24'}; border-radius: 12px;
padding: 14px 18px; font-size: 13px; line-height: 1.6;
box-shadow: 0 4px 20px rgba(0,0,0,0.3); max-width: 380px;
font-family: system-ui, sans-serif;
`
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid ${isDark ? '#333' : '#e5e7eb'};padding-bottom:6px;">
<span style="font-weight:700;font-size:15px;">📏 Messergebnis</span>
<button id="measure-panel-close" style="background:none;border:none;cursor:pointer;font-size:20px;color:${isDark ? '#886633' : '#94a3b8'};padding:0 4px;line-height:1;">✕</button>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Distanz:</span>
<span style="font-weight:600">${formatDistance(totalDist)}</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhe Start:</span>
<span style="font-weight:600">${Math.round(elStart)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhe Ende:</span>
<span style="font-weight:600">${Math.round(elEnd)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Min / Max:</span>
<span style="font-weight:600">${Math.round(minSampledElev)} / ${Math.round(maxSampledElev)} m ü.M.</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Aufstieg:</span>
<span style="font-weight:600;color:#ef4444">+${Math.round(totalClimb)} m ↑</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Abstieg:</span>
<span style="font-weight:600;color:#22c55e">-${Math.round(totalDescent)} m ↓</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Netto Diff.:</span>
<span style="font-weight:600;color:${elDiff > 0 ? '#ef4444' : '#22c55e'}">${elDiff > 0 ? '+' : ''}${Math.round(elDiff)} m ${elDiff > 0 ? '↑' : elDiff < 0 ? '↓' : '→'}</span>
</div>
<div style="font-size:11px;color:${isDark ? '#665533' : '#94a3b8'};margin-top:4px;">
${sampledCoords.length} Messpunkte (alle ${SAMPLE_INTERVAL}m), Quelle: Open-Meteo DEM (~90m Raster)
</div>
<div style="font-weight:700;font-size:15px;margin:10px 0 6px;border-bottom:1px solid ${isDark ? '#333' : '#e5e7eb'};padding-bottom:6px;">
🚒 Schlauchleitung (3er Verteiler, ${AUSGANGSDRUCK_STRAHLROHR} bar Strahlrohr)
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Höhendruck:</span>
<span style="font-weight:600;color:${totalHoehenDruck > 0 ? '#ef4444' : '#22c55e'}">${totalHoehenDruck > 0 ? '+' : ''}${totalHoehenDruck.toFixed(1)} bar</span>
</div>
${hoseCalcs.map(h => {
const w = h.gesamt > PUMPE_MAX_DRUCK
return `
<div style="margin-top:8px;padding:8px 10px;background:${isDark ? '#222' : '#f8fafc'};border-radius:8px;border:1px solid ${isDark ? '#333' : '#e5e7eb'};">
<div style="font-weight:700;font-size:13px;margin-bottom:4px;">⬤ ${h.name} (${h.diameter}mm, ${h.flow} l/min)</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:1px 10px;font-size:12px;">
<span style="color:${isDark ? '#886633' : '#64748b'}">Reibung:</span>
<span style="font-weight:600">${h.reibung.toFixed(1)} bar (${(h.c * Math.pow(h.flow / 100, 2)).toFixed(2)} bar/100m)</span>
<span style="color:${isDark ? '#886633' : '#64748b'}">Gesamt:</span>
<span style="font-weight:700;color:${w ? '#ef4444' : '#22c55e'}">${h.gesamt.toFixed(1)} bar</span>
</div>
<div style="margin-top:4px;padding:4px 8px;background:${w ? (isDark ? '#3a1a1a' : '#fef2f2') : (isDark ? '#1a2a1a' : '#f0fdf4')};border-radius:6px;font-size:12px;font-weight:600;color:${w ? '#ef4444' : '#22c55e'};">
${h.pumpen <= 1
? '✅ 1 Pumpe reicht'
: '⚠️ ' + h.pumpen + ' Pumpen! Verstärker alle ~' + Math.round(totalDist / (h.pumpen - 1)) + 'm'
}
</div>
</div>`
}).join('')}
`
// Attach to map container
const container = map.current?.getContainer()
if (container) {
container.style.position = 'relative'
container.appendChild(panel)
// X button closes panel
const closeBtn = document.getElementById('measure-panel-close')
if (closeBtn) {
closeBtn.addEventListener('click', () => panel.remove())
}
}
// 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')
// Geolocation: center map on user's position
const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: false,
})
map.current.addControl(geolocate, 'bottom-right')
// Auto-trigger geolocation when map loads to center on user's real position
map.current.on('load', () => { geolocate.trigger() })
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
m.addLayer({
id: 'draw-lines',
type: 'line',
source: 'draw-features',
filter: ['==', ['geometry-type'], 'LineString'],
layout: {
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['coalesce', ['get', 'width'], 3],
},
})
// 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]
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,
},
}))
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'
if (imgSrc) {
inner.style.backgroundImage = `url("${imgSrc}")`
inner.style.backgroundSize = 'contain'
inner.style.backgroundRepeat = 'no-repeat'
inner.style.backgroundPosition = 'center'
}
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') {
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')
}, [])
// 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()
// Apple-style: auto-return to select mode after finishing measurement
onDrawModeChangeRef.current?.('select')
}, [])
return (
<div className="relative w-full h-full" onClick={() => { if (isSymbolSelected) deselectSymbol() }}>
<div
ref={mapContainer}
className="w-full h-full"
/>
{/* 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={() => {
measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = []
measureCoordsRef.current = []
setMeasurePointCount(0)
setMeasureFinished(false)
if (map.current) {
const src = map.current.getSource('measure-line') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: [] })
}
}}
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>
)}
{/* 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>
)
}