269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl'
|
|
import maplibregl from 'maplibre-gl'
|
|
import type { AccelerationStrip } from '../types/api'
|
|
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
|
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
|
|
import type { Layer3DHandle } from '../maplibre/custom3DLayer'
|
|
|
|
// ── Arc-length utilities (geographic [lon, lat, alt] inputs) ──────────────────
|
|
|
|
function geoDist(a: [number, number, number], b: [number, number, number]): number {
|
|
const midLat = (a[1] + b[1]) / 2
|
|
const dlat = (b[1] - a[1]) * 111320
|
|
const dlon = (b[0] - a[0]) * 111320 * Math.cos(midLat * Math.PI / 180)
|
|
return Math.sqrt(dlat * dlat + dlon * dlon)
|
|
}
|
|
|
|
export function computeArcLengths(pts: [number, number, number][]): number[] {
|
|
const s = [0]
|
|
for (let i = 1; i < pts.length; i++) {
|
|
s.push(s[i - 1] + geoDist(pts[i - 1], pts[i]))
|
|
}
|
|
return s
|
|
}
|
|
|
|
export function snapToPath(
|
|
lngLat: [number, number],
|
|
pts: [number, number, number][],
|
|
arcs: number[],
|
|
): { frac: number; pt: [number, number, number] } {
|
|
let minDist = Infinity
|
|
let best = 0
|
|
const refLat = lngLat[1]
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const dlat = (pts[i][1] - lngLat[1]) * 111320
|
|
const dlon = (pts[i][0] - lngLat[0]) * 111320 * Math.cos(refLat * Math.PI / 180)
|
|
const d = Math.sqrt(dlat * dlat + dlon * dlon)
|
|
if (d < minDist) { minDist = d; best = i }
|
|
}
|
|
const total = arcs[arcs.length - 1]
|
|
return { frac: total > 0 ? arcs[best] / total : 0, pt: pts[best] }
|
|
}
|
|
|
|
function fracToIndex(frac: number, arcs: number[]): number {
|
|
const target = frac * arcs[arcs.length - 1]
|
|
let best = 0, bestDiff = Infinity
|
|
for (let i = 0; i < arcs.length; i++) {
|
|
const diff = Math.abs(arcs[i] - target)
|
|
if (diff < bestDiff) { bestDiff = diff; best = i }
|
|
}
|
|
return best
|
|
}
|
|
|
|
// ── Source / layer IDs ────────────────────────────────────────────────────────
|
|
|
|
const SRC_STRIPS = 'accel-strips-src'
|
|
const LYR_STRIPS = 'accel-strips-lyr' // invisible draped line for right-click hit test
|
|
const SRC_PENDING = 'accel-pending-src'
|
|
const LYR_PENDING = 'accel-pending-lyr' // draped circle for pending start point
|
|
const LYR_STRIPS_3D = 'accel-strips-3d' // custom 3D layer for visual rendering
|
|
|
|
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
|
|
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
|
|
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
|
|
}
|
|
|
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
|
|
export interface AccelerationStripsHandle {
|
|
strips: AccelerationStrip[]
|
|
removeStrip: (id: string) => void
|
|
updateStrip: (id: string, accel_ms2: number) => void
|
|
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
|
clearStrips: () => void
|
|
loadStrips: (strips: AccelerationStrip[]) => void
|
|
}
|
|
|
|
interface PendingStrip { startFrac: number; pt: [number, number, number] }
|
|
|
|
export function useAccelerationStrips(
|
|
map: Map,
|
|
pathPts: [number, number, number][],
|
|
isActive: boolean,
|
|
showStrips = true,
|
|
): AccelerationStripsHandle {
|
|
const [strips, setStrips] = useState<AccelerationStrip[]>([])
|
|
|
|
const pendingRef = useRef<PendingStrip | null>(null)
|
|
const pathPtsRef = useRef(pathPts)
|
|
pathPtsRef.current = pathPts
|
|
const isActiveRef = useRef(isActive)
|
|
isActiveRef.current = isActive
|
|
const layer3DRef = useRef<Layer3DHandle | null>(null)
|
|
|
|
// ── Public callbacks ───────────────────────────────────────────────────────
|
|
|
|
const removeStrip = useCallback((id: string) => {
|
|
setStrips(prev => prev.filter(s => s.id !== id))
|
|
}, [])
|
|
|
|
const updateStrip = useCallback((id: string, accel_ms2: number) => {
|
|
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
|
}, [])
|
|
|
|
const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
|
|
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
|
|
}, [])
|
|
|
|
const clearStrips = useCallback(() => setStrips([]), [])
|
|
|
|
const loadStrips = useCallback((newStrips: AccelerationStrip[]) => {
|
|
setStrips(newStrips)
|
|
}, [])
|
|
|
|
// ── Set up MapLibre sources + layers ───────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
map.addSource(SRC_STRIPS, { type: 'geojson', data: emptyFC() })
|
|
map.addSource(SRC_PENDING, { type: 'geojson', data: emptyFC() })
|
|
|
|
// Invisible line kept only for right-click hit-testing (queryRenderedFeatures).
|
|
map.addLayer({ id: LYR_STRIPS, type: 'line', source: SRC_STRIPS,
|
|
paint: { 'line-color': '#f59e0b', 'line-width': 8, 'line-opacity': 0.01 } })
|
|
|
|
// Draped circle showing the pending strip start point.
|
|
map.addLayer({ id: LYR_PENDING, type: 'circle', source: SRC_PENDING,
|
|
paint: {
|
|
'circle-radius': 8,
|
|
'circle-color': '#f59e0b',
|
|
'circle-stroke-color': 'rgba(0,0,0,0.6)',
|
|
'circle-stroke-width': 2,
|
|
} })
|
|
|
|
return () => {
|
|
safeRemoveLayers(map,
|
|
[LYR_STRIPS, LYR_PENDING],
|
|
[SRC_STRIPS, SRC_PENDING],
|
|
)
|
|
}
|
|
}, [map])
|
|
|
|
// ── Custom 3D layer for strip visual rendering ─────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const handle = createCustom3DLayer(LYR_STRIPS_3D, map)
|
|
layer3DRef.current = handle
|
|
return () => {
|
|
handle.destroy()
|
|
layer3DRef.current = null
|
|
}
|
|
}, [map])
|
|
|
|
// ── Click handler for strip placement ─────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const onClick = (e: MapMouseEvent) => {
|
|
if (!isActiveRef.current) return
|
|
const pts = pathPtsRef.current
|
|
if (pts.length < 2) return
|
|
|
|
const arcs = computeArcLengths(pts)
|
|
const { frac, pt } = snapToPath([e.lngLat.lng, e.lngLat.lat], pts, arcs)
|
|
|
|
if (!pendingRef.current) {
|
|
pendingRef.current = { startFrac: frac, pt }
|
|
setData(map, SRC_PENDING, {
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: pt },
|
|
properties: {},
|
|
})
|
|
} else {
|
|
const { startFrac } = pendingRef.current
|
|
pendingRef.current = null
|
|
setData(map, SRC_PENDING, emptyFC())
|
|
|
|
let sf = startFrac, ef = frac
|
|
if (sf > ef) [sf, ef] = [ef, sf]
|
|
if (sf === ef) return
|
|
|
|
const id = crypto.randomUUID()
|
|
setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }])
|
|
}
|
|
}
|
|
|
|
const onContextMenu = () => {
|
|
if (!isActiveRef.current || !pendingRef.current) return
|
|
pendingRef.current = null
|
|
setData(map, SRC_PENDING, emptyFC())
|
|
}
|
|
|
|
map.on('click', onClick)
|
|
map.getCanvas().addEventListener('contextmenu', onContextMenu)
|
|
|
|
return () => {
|
|
map.off('click', onClick)
|
|
map.getCanvas().removeEventListener('contextmenu', onContextMenu)
|
|
}
|
|
}, [map])
|
|
|
|
// ── Right-click on strip to delete ────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const onContextMenuStrip = (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
|
if (isActiveRef.current) return
|
|
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_STRIPS] })
|
|
if (!features.length) return
|
|
const stripId = features[0].properties?.stripId as string | undefined
|
|
if (stripId) removeStrip(stripId)
|
|
}
|
|
|
|
map.on('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
|
|
return () => {
|
|
map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
|
|
}
|
|
}, [map, removeStrip])
|
|
|
|
// ── Sync strip GeoJSON + 3D tubes ─────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
if (!showStrips) {
|
|
layer3DRef.current?.update([])
|
|
return
|
|
}
|
|
const pts = pathPts
|
|
if (pts.length < 2) {
|
|
setData(map, SRC_STRIPS, emptyFC())
|
|
layer3DRef.current?.update([])
|
|
return
|
|
}
|
|
const arcs = computeArcLengths(pts)
|
|
|
|
const segments: { coords: [number, number, number][]; id: string }[] = []
|
|
|
|
const features: GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>[] = strips.map(strip => {
|
|
const si = fracToIndex(strip.startFrac, arcs)
|
|
const ei = fracToIndex(strip.endFrac, arcs)
|
|
const lo = Math.min(si, ei)
|
|
const hi = Math.max(si, ei)
|
|
const sliced = pts.slice(lo, hi + 1) as [number, number, number][]
|
|
if (sliced.length >= 2) segments.push({ coords: sliced, id: strip.id })
|
|
return {
|
|
type: 'Feature' as const,
|
|
geometry: { type: 'LineString' as const, coordinates: sliced },
|
|
properties: { stripId: strip.id },
|
|
}
|
|
}).filter(f => (f.geometry as GeoJSON.LineString).coordinates.length >= 2)
|
|
|
|
// Invisible draped geometry for right-click hit-testing
|
|
setData(map, SRC_STRIPS, { type: 'FeatureCollection', features })
|
|
|
|
// 3D tube rendering on the path
|
|
layer3DRef.current?.update(
|
|
segments.map(s => ({ pts: s.coords, color: 0xf59e0b, radiusMeters: 0.4 }))
|
|
)
|
|
}, [strips, pathPts, showStrips, map])
|
|
|
|
// ── Visibility toggle ─────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const vis = showStrips ? 'visible' : 'none'
|
|
if (map.getLayer(LYR_STRIPS)) map.setLayoutProperty(LYR_STRIPS, 'visibility', vis)
|
|
if (map.getLayer(LYR_PENDING)) map.setLayoutProperty(LYR_PENDING, 'visibility', vis)
|
|
// 3D layer: update with empty when hidden, restore on show
|
|
// (handled by the strip sync effect which runs on showStrips via the deps below)
|
|
}, [showStrips, map])
|
|
|
|
return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
|
|
}
|