rcnn/web/src/coaster/useCoasterPath.ts
munsel 42197bfbc9 add coaster naming, challenge detail panel, and fix GeoJSON id bug
- Coaster editor: name input in top bar, saved/loaded with coaster data
- CoasterListPanel: show coaster name prominently alongside creator username
- ChallengesListPanel: drill-in detail view with center map, plan coaster,
  and accept challenge buttons; coaster count shown in list and detail
- AllCoastersPanel: coaster count visible in challenge entries
- Backend: add coaster_count to ChallengeMapSerializer and ChallengeDetailSerializer
- Fix: ChallengeLayer and ChallengesListPanel were reading f.properties.id
  (always undefined) instead of f.id — GeoFeatureModelSerializer puts the pk
  at the GeoJSON Feature level, not in properties
- Types: remove id from ChallengeMapProperties to reflect actual data shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:43:10 +02:00

341 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from 'react'
import * as Cesium from 'cesium'
import type { AnchorPoint } from './bezierUtils'
import { effectivePosition, samplePath, computeRails } from './bezierUtils'
export type EditorMode = 'add' | 'select' | 'strip'
export interface CoasterPathHandle {
anchors: AnchorPoint[]
pathPts: Cesium.Cartesian3[]
selectedId: string | null
mode: EditorMode
setMode: (m: EditorMode) => void
updateAnchorHeight: (id: string, delta: number) => void
removeAnchor: (id: string) => void
loadAnchors: (anchors: AnchorPoint[]) => void
undoLast: () => void
clearAll: () => void
}
let _counter = 0
function genId(): string {
return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}`
}
export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle {
const [anchors, setAnchors] = useState<AnchorPoint[]>([])
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mode, setMode] = useState<EditorMode>('add')
// Keep refs in sync so event-handler closures always see current values
const anchorsRef = useRef(anchors); anchorsRef.current = anchors
const modeRef = useRef(mode); modeRef.current = mode
const selectedRef = useRef(selectedId); selectedRef.current = selectedId
// Cesium entity refs
const sphereMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
const pathEntities = useRef<Cesium.Entity[]>([])
const startLabelRef = useRef<Cesium.Entity | null>(null)
// Drag state (refs to avoid triggering re-renders)
const isDragging = useRef(false)
const dragAnchorId = useRef<string | null>(null)
const dragPos = useRef<Cesium.Cartesian3 | null>(null)
const didMoveDuringDrag = useRef(false)
// ── public callbacks ───────────────────────────────────────────────────────
const updateAnchorHeight = useCallback((id: string, delta: number) => {
setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a))
}, [])
const removeAnchor = useCallback((id: string) => {
setAnchors(prev => prev.filter(a => a.id !== id))
setSelectedId(prev => prev === id ? null : prev)
}, [])
const loadAnchors = useCallback((newAnchors: AnchorPoint[]) => {
setAnchors(newAnchors)
setSelectedId(null)
}, [])
const undoLast = useCallback(() => {
setAnchors(prev => {
if (prev.length === 0) return prev
const removed = prev[prev.length - 1]
setSelectedId(s => s === removed.id ? null : s)
return prev.slice(0, -1)
})
}, [])
const clearAll = useCallback(() => {
setAnchors([])
setSelectedId(null)
}, [])
// ── cursor style ───────────────────────────────────────────────────────────
useEffect(() => {
viewer.scene.canvas.style.cursor =
mode === 'add' ? 'crosshair' :
mode === 'strip' ? 'cell' : 'default'
}, [mode, viewer])
// ── entity sync ────────────────────────────────────────────────────────────
useEffect(() => {
const existingIds = new Set(anchors.map(a => a.id))
// Remove spheres for deleted anchors
sphereMapRef.current.forEach((entity, id) => {
if (!existingIds.has(id)) {
viewer.entities.remove(entity)
sphereMapRef.current.delete(id)
}
})
// Add or update anchor spheres
anchors.forEach((anchor) => {
const pos = effectivePosition(anchor)
const isSelected = anchor.id === selectedId
const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE
const size = isSelected ? 15 : 10
if (!sphereMapRef.current.has(anchor.id)) {
const entity = viewer.entities.add({
id: `coaster-anchor-${anchor.id}`,
position: new Cesium.ConstantPositionProperty(pos),
point: {
pixelSize: size,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: { anchorId: anchor.id },
})
sphereMapRef.current.set(anchor.id, entity)
} else {
const entity = sphereMapRef.current.get(anchor.id)!
entity.position = new Cesium.ConstantPositionProperty(pos)
entity.point!.color = new Cesium.ConstantProperty(color)
entity.point!.pixelSize = new Cesium.ConstantProperty(size)
}
})
// Rebuild path + rails
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (anchors.length >= 2) {
const pts = samplePath(anchors)
setPathPts(pts)
const { left, right } = computeRails(pts)
const centre = viewer.entities.add({
polyline: {
positions: pts,
width: 2,
material: Cesium.Color.YELLOW.withAlpha(0.55),
arcType: Cesium.ArcType.NONE,
},
})
const leftRail = viewer.entities.add({
polyline: {
positions: left,
width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE,
},
})
const rightRail = viewer.entities.add({
polyline: {
positions: right,
width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE,
},
})
pathEntities.current = [centre, leftRail, rightRail]
} else {
setPathPts([])
}
// Start label — always at first anchor
if (startLabelRef.current) {
viewer.entities.remove(startLabelRef.current)
startLabelRef.current = null
}
if (anchors.length > 0) {
startLabelRef.current = viewer.entities.add({
position: new Cesium.ConstantPositionProperty(effectivePosition(anchors[0])),
label: {
text: '\u25B6 Start',
font: '13px sans-serif',
fillColor: Cesium.Color.fromCssColorString('#4ade80'),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
showBackground: true,
backgroundColor: Cesium.Color.BLACK.withAlpha(0.45),
backgroundPadding: new Cesium.Cartesian2(6, 4),
},
})
}
}, [anchors, selectedId, viewer])
// ── path visibility toggle ─────────────────────────────────────────────────
useEffect(() => {
pathEntities.current.forEach(e => { e.show = showPath })
}, [showPath])
// ── anchor visibility toggle ───────────────────────────────────────────────
useEffect(() => {
sphereMapRef.current.forEach(e => { e.show = showAnchors })
if (startLabelRef.current) startLabelRef.current.show = showAnchors
}, [showAnchors])
// ── input handling ─────────────────────────────────────────────────────────
useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
const canvas = viewer.scene.canvas
// Prevent browser context-menu in the viewer
const suppressCtx = (e: Event) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressCtx)
const disableCam = () => {
const c = viewer.scene.screenSpaceCameraController
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = false
}
const enableCam = () => {
const c = viewer.scene.screenSpaceCameraController
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = true
}
function pickAnchorId(pos: Cesium.Cartesian2): string | null {
const picked = viewer.scene.pick(pos)
if (!Cesium.defined(picked)) return null
const entity = picked.id as Cesium.Entity | undefined
if (!(entity instanceof Cesium.Entity)) return null
return entity.properties?.anchorId?.getValue() ?? null
}
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(pos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
// LEFT_DOWN start drag in select mode
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (modeRef.current !== 'select') return
const anchorId = pickAnchorId(e.position)
if (!anchorId) return
isDragging.current = true
dragAnchorId.current = anchorId
dragPos.current = null
didMoveDuringDrag.current = false
disableCam()
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
// MOUSE_MOVE live-drag the sphere
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => {
if (!isDragging.current || !dragAnchorId.current) return
const newPos = pickTerrain(e.endPosition)
if (!newPos) return
didMoveDuringDrag.current = true
dragPos.current = newPos
// Move the sphere entity directly (no React re-render during drag)
const entity = sphereMapRef.current.get(dragAnchorId.current)
if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
// LEFT_UP commit drag
handler.setInputAction(() => {
if (isDragging.current) {
if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) {
const id = dragAnchorId.current
const pos = dragPos.current
setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a))
}
isDragging.current = false
dragAnchorId.current = null
dragPos.current = null
enableCam()
}
}, Cesium.ScreenSpaceEventType.LEFT_UP)
// LEFT_CLICK add point or select
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
// Suppress click that immediately follows a completed drag
if (didMoveDuringDrag.current) {
didMoveDuringDrag.current = false
return
}
const anchorId = pickAnchorId(e.position)
if (anchorId) {
setSelectedId(prev => prev === anchorId ? null : anchorId)
return
}
if (modeRef.current === 'add') {
const pos = pickTerrain(e.position)
if (!pos) return
const id = genId()
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }])
setSelectedId(null)
} else if (modeRef.current === 'select') {
setSelectedId(null)
}
// 'strip' mode clicks are handled by useAccelerationStrips
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// RIGHT_CLICK undo / remove selected
handler.setInputAction(() => {
if (selectedRef.current) {
const id = selectedRef.current
setAnchors(prev => prev.filter(a => a.id !== id))
setSelectedId(null)
} else {
setAnchors(prev => prev.slice(0, -1))
}
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
if (!handler.isDestroyed()) handler.destroy()
canvas.removeEventListener('contextmenu', suppressCtx)
if (!viewer.isDestroyed()) {
enableCam()
viewer.scene.canvas.style.cursor = 'default'
}
}
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
// ── cleanup on unmount ─────────────────────────────────────────────────────
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
sphereMapRef.current.forEach(e => viewer.entities.remove(e))
sphereMapRef.current.clear()
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (startLabelRef.current) {
viewer.entities.remove(startLabelRef.current)
startLabelRef.current = null
}
}
}, [viewer])
return { anchors, pathPts, selectedId, mode, setMode, updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
}