mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
fix: measure tool — multi-point with segment distances and running total
This commit is contained in:
parent
30bfd72642
commit
a1f929e10a
1 changed files with 1970 additions and 1791 deletions
|
|
@ -7,7 +7,7 @@ import { useStore } from '../store'
|
|||
import { decodePolyline } from '../utils/decode'
|
||||
import { fetchReverse } from '../api'
|
||||
import { getConfig, hasFeature } from '../config'
|
||||
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler } from 'lucide-react'
|
||||
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react'
|
||||
import RadialMenu from './RadialMenu'
|
||||
import useContextMenu from '../hooks/useContextMenu'
|
||||
import toast from 'react-hot-toast'
|
||||
|
|
@ -653,6 +653,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
// Flag to suppress map-click when a stop pin was clicked
|
||||
const pinClickedRef = useRef(false)
|
||||
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
||||
// Refs for measurement state (accessible in click handlers)
|
||||
const measuringRef = useRef({ active: false, points: [] })
|
||||
const measureLabelsRef = useRef([]) // HTML label elements
|
||||
|
||||
const stops = useStore((s) => s.stops)
|
||||
const route = useStore((s) => s.route)
|
||||
|
|
@ -681,9 +684,14 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
lon: 0,
|
||||
centerLabel: null,
|
||||
})
|
||||
// Measurement mode state
|
||||
const [measuring, setMeasuring] = useState({ active: false, points: [] })
|
||||
// Measurement mode state (for UI rendering)
|
||||
const [measuring, setMeasuring] = useState({ active: false, points: [], totalMeters: 0 })
|
||||
|
||||
// Sync state to ref for click handler access
|
||||
const updateMeasuringState = (newState) => {
|
||||
measuringRef.current = newState
|
||||
setMeasuring(newState)
|
||||
}
|
||||
|
||||
// Update measurement layer with current points
|
||||
const updateMeasureLayer = (points) => {
|
||||
|
|
@ -716,18 +724,169 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
})
|
||||
}
|
||||
|
||||
// Clear measurement mode
|
||||
// Update segment labels (HTML overlays)
|
||||
const updateMeasureLabels = (points) => {
|
||||
const map = mapInstance.current
|
||||
if (!map) return
|
||||
|
||||
// Remove old labels
|
||||
measureLabelsRef.current.forEach(el => el.remove())
|
||||
measureLabelsRef.current = []
|
||||
|
||||
if (points.length < 2) return
|
||||
|
||||
const container = mapRef.current
|
||||
if (!container) return
|
||||
|
||||
// Create label for each segment
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p1 = points[i - 1]
|
||||
const p2 = points[i]
|
||||
const midLat = (p1.lat + p2.lat) / 2
|
||||
const midLon = (p1.lon + p2.lon) / 2
|
||||
const dist = haversineDistance(p1.lat, p1.lon, p2.lat, p2.lon)
|
||||
|
||||
const label = document.createElement('div')
|
||||
label.className = 'measure-label'
|
||||
label.textContent = formatDistance(dist)
|
||||
label.style.cssText = `
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
transform: translate(-50%, -50%);
|
||||
`
|
||||
|
||||
const pos = map.project([midLon, midLat])
|
||||
label.style.left = pos.x + 'px'
|
||||
label.style.top = pos.y + 'px'
|
||||
|
||||
container.appendChild(label)
|
||||
measureLabelsRef.current.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
// Reposition labels on map move/zoom
|
||||
const repositionLabels = () => {
|
||||
const map = mapInstance.current
|
||||
const points = measuringRef.current.points
|
||||
if (!map || points.length < 2) return
|
||||
|
||||
measureLabelsRef.current.forEach((label, i) => {
|
||||
if (i >= points.length - 1) return
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const midLat = (p1.lat + p2.lat) / 2
|
||||
const midLon = (p1.lon + p2.lon) / 2
|
||||
const pos = map.project([midLon, midLat])
|
||||
label.style.left = pos.x + 'px'
|
||||
label.style.top = pos.y + 'px'
|
||||
})
|
||||
}
|
||||
|
||||
// Clear measurement mode completely
|
||||
const clearMeasuring = () => {
|
||||
const map = mapInstance.current
|
||||
setMeasuring({ active: false, points: [] })
|
||||
updateMeasuringState({ active: false, points: [], totalMeters: 0 })
|
||||
|
||||
// Remove labels
|
||||
measureLabelsRef.current.forEach(el => el.remove())
|
||||
measureLabelsRef.current = []
|
||||
|
||||
if (map) {
|
||||
map.getCanvas().style.cursor = ""
|
||||
map.doubleClickZoom.enable()
|
||||
if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER)
|
||||
if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER)
|
||||
if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE)
|
||||
}
|
||||
}
|
||||
|
||||
// End measurement (keep line visible, exit active mode)
|
||||
const endMeasuring = () => {
|
||||
const map = mapInstance.current
|
||||
if (map) {
|
||||
map.getCanvas().style.cursor = ""
|
||||
map.doubleClickZoom.enable()
|
||||
}
|
||||
updateMeasuringState({ ...measuringRef.current, active: false })
|
||||
}
|
||||
|
||||
// Start new measurement
|
||||
const startMeasuring = (lat, lon) => {
|
||||
const map = mapInstance.current
|
||||
if (!map) return
|
||||
|
||||
// Clear any existing measurement first
|
||||
measureLabelsRef.current.forEach(el => el.remove())
|
||||
measureLabelsRef.current = []
|
||||
if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER)
|
||||
if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER)
|
||||
if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE)
|
||||
|
||||
// Set up new measurement
|
||||
updateMeasuringState({ active: true, points: [{ lat, lon }], totalMeters: 0 })
|
||||
map.getCanvas().style.cursor = "crosshair"
|
||||
map.doubleClickZoom.disable()
|
||||
|
||||
// Add source and layers
|
||||
map.addSource(MEASURE_SOURCE, {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
})
|
||||
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
|
||||
map.addLayer({
|
||||
id: MEASURE_LINE_LAYER,
|
||||
type: "line",
|
||||
source: MEASURE_SOURCE,
|
||||
paint: {
|
||||
"line-color": accentColor,
|
||||
"line-width": 2,
|
||||
"line-dasharray": [8, 4],
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: MEASURE_POINT_LAYER,
|
||||
type: "circle",
|
||||
source: MEASURE_SOURCE,
|
||||
filter: ["==", "$type", "Point"],
|
||||
paint: {
|
||||
"circle-radius": 5,
|
||||
"circle-color": accentColor,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#1a1a1a",
|
||||
},
|
||||
})
|
||||
updateMeasureLayer([{ lat, lon }])
|
||||
}
|
||||
|
||||
// Add a point to the measurement
|
||||
const addMeasurePoint = (lat, lon) => {
|
||||
const current = measuringRef.current
|
||||
if (!current.active) return
|
||||
|
||||
const newPoints = [...current.points, { lat, lon }]
|
||||
|
||||
// Calculate total distance
|
||||
let totalMeters = 0
|
||||
for (let i = 1; i < newPoints.length; i++) {
|
||||
totalMeters += haversineDistance(
|
||||
newPoints[i - 1].lat, newPoints[i - 1].lon,
|
||||
newPoints[i].lat, newPoints[i].lon
|
||||
)
|
||||
}
|
||||
|
||||
updateMeasuringState({ active: true, points: newPoints, totalMeters })
|
||||
updateMeasureLayer(newPoints)
|
||||
updateMeasureLabels(newPoints)
|
||||
}
|
||||
|
||||
const radialWedges = [
|
||||
{
|
||||
id: "directions-to",
|
||||
|
|
@ -814,40 +973,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
icon: Ruler,
|
||||
onSelect: () => {
|
||||
setRadialMenu((m) => ({ ...m, open: false }))
|
||||
const map = mapInstance.current
|
||||
if (!map) return
|
||||
setMeasuring({ active: true, points: [{ lat: radialMenu.lat, lon: radialMenu.lon }] })
|
||||
map.getCanvas().style.cursor = "crosshair"
|
||||
if (!map.getSource(MEASURE_SOURCE)) {
|
||||
map.addSource(MEASURE_SOURCE, {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
})
|
||||
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
|
||||
map.addLayer({
|
||||
id: MEASURE_LINE_LAYER,
|
||||
type: "line",
|
||||
source: MEASURE_SOURCE,
|
||||
paint: {
|
||||
"line-color": accentColor,
|
||||
"line-width": 2,
|
||||
"line-dasharray": [8, 4],
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: MEASURE_POINT_LAYER,
|
||||
type: "circle",
|
||||
source: MEASURE_SOURCE,
|
||||
filter: ["==", "$type", "Point"],
|
||||
paint: {
|
||||
"circle-radius": 4,
|
||||
"circle-color": accentColor,
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
})
|
||||
}
|
||||
updateMeasureLayer([{ lat: radialMenu.lat, lon: radialMenu.lon }])
|
||||
startMeasuring(radialMenu.lat, radialMenu.lon)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -856,6 +982,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
const map = mapInstance.current
|
||||
if (!map || !mapRef.current) return
|
||||
|
||||
// Suppress context menu during measurement mode
|
||||
if (measuringRef.current.active) return
|
||||
|
||||
// Convert screen coords to lat/lon
|
||||
const rect = mapRef.current.getBoundingClientRect()
|
||||
const lngLat = map.unproject([x - rect.left, y - rect.top])
|
||||
|
|
@ -1003,24 +1132,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
return
|
||||
}
|
||||
|
||||
// Handle measuring mode
|
||||
const measureState = measuring
|
||||
if (measureState.active) {
|
||||
// CRITICAL: Check measuring mode FIRST using ref (not stale closure)
|
||||
if (measuringRef.current.active) {
|
||||
const { lng, lat } = e.lngLat
|
||||
const newPoints = [...measureState.points, { lat, lon: lng }]
|
||||
setMeasuring({ ...measureState, points: newPoints })
|
||||
updateMeasureLayer(newPoints)
|
||||
// Calculate and show total distance
|
||||
if (newPoints.length > 1) {
|
||||
let totalMeters = 0
|
||||
for (let i = 1; i < newPoints.length; i++) {
|
||||
totalMeters += haversineDistance(
|
||||
newPoints[i - 1].lat, newPoints[i - 1].lon,
|
||||
newPoints[i].lat, newPoints[i].lon
|
||||
)
|
||||
}
|
||||
toast(formatDistance(totalMeters), { icon: "📏", duration: 2000 })
|
||||
}
|
||||
addMeasurePoint(lat, lng)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1203,16 +1318,20 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
}
|
||||
})
|
||||
|
||||
// Double-click ends measurement mode
|
||||
// Double-click ends measurement mode (and prevents zoom)
|
||||
map.on('dblclick', (e) => {
|
||||
if (measuring.active) {
|
||||
if (measuringRef.current.active) {
|
||||
e.preventDefault()
|
||||
// Keep the measurement visible but exit measuring mode
|
||||
setMeasuring((m) => ({ ...m, active: false }))
|
||||
map.getCanvas().style.cursor = ''
|
||||
// Add final point and end
|
||||
const { lng, lat } = e.lngLat
|
||||
addMeasurePoint(lat, lng)
|
||||
endMeasuring()
|
||||
}
|
||||
})
|
||||
|
||||
// Reposition measure labels on map move
|
||||
map.on('move', repositionLabels)
|
||||
|
||||
// Initialize mapCenter immediately when map loads (Fix 1: search viewport)
|
||||
map.once('load', () => {
|
||||
const center = map.getCenter()
|
||||
|
|
@ -1262,11 +1381,15 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
|
||||
interactiveLayers.forEach(layerId => {
|
||||
map.on('mouseenter', layerId, () => {
|
||||
if (!measuringRef.current.active) {
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
}
|
||||
})
|
||||
|
||||
map.on('mouseleave', layerId, () => {
|
||||
if (!measuringRef.current.active) {
|
||||
map.getCanvas().style.cursor = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1283,6 +1406,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
ro.disconnect()
|
||||
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
||||
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
||||
// Clean up measure labels
|
||||
measureLabelsRef.current.forEach(el => el.remove())
|
||||
measureLabelsRef.current = []
|
||||
maplibregl.removeProtocol('pmtiles')
|
||||
map.remove()
|
||||
}
|
||||
|
|
@ -1670,13 +1796,13 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
// ESC key handler for measurement mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Escape" && measuring.active) {
|
||||
clearMeasuring()
|
||||
if (e.key === "Escape" && measuringRef.current.active) {
|
||||
endMeasuring()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [measuring.active])
|
||||
}, [])
|
||||
|
||||
// Handle location pick mode for contacts
|
||||
useEffect(() => {
|
||||
|
|
@ -1686,11 +1812,11 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
map.getCanvas().style.cursor = 'crosshair'
|
||||
}
|
||||
return () => {
|
||||
if (map && !measuring.active) {
|
||||
if (map && !measuringRef.current.active) {
|
||||
map.getCanvas().style.cursor = ''
|
||||
}
|
||||
}
|
||||
}, [pickingLocationFor, measuring.active])
|
||||
}, [pickingLocationFor])
|
||||
|
||||
// ESC key handler for location pick mode
|
||||
useEffect(() => {
|
||||
|
|
@ -1773,6 +1899,59 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
>
|
||||
Z {zoomLevel.toFixed(1)}
|
||||
</div>
|
||||
|
||||
{/* Measurement info bar */}
|
||||
{(measuring.active || measuring.points.length > 1) && (
|
||||
<div
|
||||
className="absolute top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-2 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
color: "white",
|
||||
fontSize: "13px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<Ruler size={16} style={{ opacity: 0.8 }} />
|
||||
<span>
|
||||
<strong>{formatDistance(measuring.totalMeters)}</strong>
|
||||
<span style={{ opacity: 0.7, marginLeft: "6px" }}>
|
||||
({measuring.points.length} {measuring.points.length === 1 ? "point" : "points"})
|
||||
</span>
|
||||
</span>
|
||||
{measuring.active && (
|
||||
<span style={{ opacity: 0.6, fontSize: "11px" }}>
|
||||
Click to add points
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={endMeasuring}
|
||||
className="px-2 py-1 rounded text-xs font-medium"
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={clearMeasuring}
|
||||
className="p-1 rounded"
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title="Clear measurement"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radial context menu */}
|
||||
<RadialMenu
|
||||
open={radialMenu.open}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue