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([]) const pendingRef = useRef(null) const pathPtsRef = useRef(pathPts) pathPtsRef.current = pathPts const isActiveRef = useRef(isActive) isActiveRef.current = isActive const layer3DRef = useRef(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[1]) return () => { map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters[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[] = 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 } }