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
2450 lines
100 KiB
TypeScript
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)} m²` : `${(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>
|
|
)
|
|
}
|