'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 '@/app/app/page' // 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 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(null) const map = useRef(null) const markersRef = useRef([]) const markerCleanupsRef = useRef<(() => void)[]>([]) const measureMarkersRef = useRef([]) const measureCoordsRef = useRef([]) 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(null) const tooltipPosRef = useRef({ x: 0, y: 0 }) // Vertex editing state (for lines/polygons in select mode) const selectedLineIdRef = useRef(null) const vertexMarkersRef = useRef([]) // 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(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(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 | 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 = `
📏 Messergebnis
Distanz: ${formatDistance(totalDist)} Höhe Start: ${Math.round(elStart)} m ü.M. Höhe Ende: ${Math.round(elEnd)} m ü.M. Min / Max: ${Math.round(minSampledElev)} / ${Math.round(maxSampledElev)} m ü.M. Aufstieg: +${Math.round(totalClimb)} m ↑ Abstieg: -${Math.round(totalDescent)} m ↓ Netto Diff.: ${elDiff > 0 ? '+' : ''}${Math.round(elDiff)} m ${elDiff > 0 ? '↑' : elDiff < 0 ? '↓' : '→'}
${sampledCoords.length} Messpunkte (alle ${SAMPLE_INTERVAL}m), Quelle: Open-Meteo DEM (~90m Raster)
🚒 Schlauchleitung (3er Verteiler, ${AUSGANGSDRUCK_STRAHLROHR} bar Strahlrohr)
Höhendruck: ${totalHoehenDruck > 0 ? '+' : ''}${totalHoehenDruck.toFixed(1)} bar
${hoseCalcs.map(h => { const w = h.gesamt > PUMPE_MAX_DRUCK return `
⬤ ${h.name} (${h.diameter}mm, ${h.flow} l/min)
Reibung: ${h.reibung.toFixed(1)} bar (${(h.c * Math.pow(h.flow / 100, 2)).toFixed(2)} bar/100m) Gesamt: ${h.gesamt.toFixed(1)} bar
${h.pumpen <= 1 ? '✅ 1 Pumpe reicht' : '⚠️ ' + h.pumpen + ' Pumpen! Verstärker alle ~' + Math.round(totalDist / (h.pumpen - 1)) + 'm' }
` }).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 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 () => { map.current?.remove() map.current = null } }, []) // Update map center only when a DIFFERENT project is loaded (by ID change) const prevProjectIdRef = useRef(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 // Get last two points to calculate arrow direction using screen-projected coords const p1 = lineCoords[lineCoords.length - 2] const p2 = lineCoords[lineCoords.length - 1] const px1 = map.current.project(p1 as [number, number]) const px2 = map.current.project(p2 as [number, number]) const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90 const color = (f.properties.color as string) || '#000000' const arrowEl = document.createElement('div') arrowEl.style.cssText = ` width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-bottom: 20px solid ${color}; transform: rotate(${screenAngle}deg); transform-origin: center center; pointer-events: none; ` const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'viewport' }) .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] } 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.75)'}; color: #fff; padding: 1px 5px; border-radius: 3px; font-size: 11px; 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 3px rgba(0,0,0,0.25); cursor: ${canEdit ? 'pointer' : '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:11px;font-weight:600;line-height:1.2;' const infoLine = document.createElement('div') infoLine.textContent = `${lenText} / ${hoseCount} Schl.` infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;' 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)} m²` : `${(area / 10000).toFixed(2)} ha` const labelLine = document.createElement('div') labelLine.textContent = label labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;' const infoLine = document.createElement('div') infoLine.textContent = areaText infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;' 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', rotationAlignment: 'viewport' }) .setLngLat(midpoint) .addTo(map.current) 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: 'viewport' }) .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: 'viewport' }) .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('.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 / finalize measurement useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { 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) }, []) // 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 (
{ if (isSymbolSelected) deselectSymbol() }}>
{/* Moveable controls for selected symbol */} {isSymbolSelected && selectedSymbolRef.current && canEdit && ( <> { 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 */}
e.stopPropagation()} > {Math.round(selectedSymbolRef.current.rotation)}°
{selectedSymbolRef.current.scale.toFixed(1)}x
)} {/* Layer selector dropdown */}
{layerDropdownOpen && ( <>
setLayerDropdownOpen(false)} />
{([ { key: 'osm', label: 'OpenStreetMap' }, { key: 'satellite', label: 'Satellit (Esri)' }, { key: 'swisstopo', label: 'Swisstopo Karte' }, ] as const).map(({ key, label }) => ( ))}
)}
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */} {(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && ( )} {/* Messung abschliessen Button */} {drawMode === 'measure' && measurePointCount >= 2 && !measureFinished && ( )} {/* Neue Messung starten */} {drawMode === 'measure' && measureFinished && ( )} {/* Inline edit overlay — replaces native prompt() to prevent fullscreen exit */} {inlineEdit && (
e.stopPropagation()} > {inlineEdit.type === 'label' ? 'Bezeichnung:' : 'Text:'} 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...'} />
)} {/* Cursor-following tooltip — always mounted for stable ref */}
{ if (!canEdit || drawMode === 'select') return 'none' if (drawMode === 'measure' && measureFinished) return 'none' return undefined // let className handle (hidden on mobile, block on md+) })(), }} >
{(() => { 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 '' } })()}
) }