import { useEffect, useRef, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import type { Map, GeoJSONSource } from 'maplibre-gl' import { MapLibreViewer } from '../maplibre/MapLibreViewer' import { useMapLibreMap } from '../maplibre/maplibreContext' import { safeRemoveLayers } from '../maplibre/geoUtils' import { createCustom3DLayer } from '../maplibre/custom3DLayer' import type { Layer3DHandle } from '../maplibre/custom3DLayer' import { fetchChallengeDetail } from '../api/challenges' import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster' import { useCoasterPath } from './useCoasterPath' import { useAccelerationStrips, computeArcLengths, snapToPath } from './useAccelerationStrips' import { useTerrainCapture } from './useTerrainCapture' import { RideRenderer } from './RideRenderer' import { SimulationPlots } from './SimulationPlots' import { AccelerationStripsPanel } from './AccelerationStripsPanel' import { CoasterListPanel } from './CoasterListPanel' import { effectiveLngLatAlt } from './bezierUtils' import { useAuthStore } from '../store/authStore' import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api' import type { AnchorPoint } from './bezierUtils' import styles from './CoasterEditorPage.module.css' // ── Route pages ─────────────────────────────────────────────────────────────── export function CoasterEditorPage() { const { id } = useParams<{ id: string }>() const [challenge, setChallenge] = useState(null) useEffect(() => { if (!id) return fetchChallengeDetail(id).then(setChallenge).catch(console.error) }, [id]) return ( ) } export function CoasterViewerPage() { const { challengeId, coasterId } = useParams<{ challengeId: string; coasterId: string }>() const [challenge, setChallenge] = useState(null) const [preloadCoaster, setPreloadCoaster] = useState(null) useEffect(() => { if (!challengeId) return fetchChallengeDetail(challengeId).then(setChallenge).catch(console.error) listCoasters(challengeId) .then((list) => { const found = list.find((c) => c.id === coasterId) if (found) setPreloadCoaster(found) }) .catch(console.error) }, [challengeId, coasterId]) return ( ) } // ── Source / layer IDs for editor-specific overlays ─────────────────────────── const SRC_REGION = 'editor-region' const LYR_REGION_F = 'editor-region-fill' const LYR_REGION_L = 'editor-region-line' const LYR_SIM_RAILS = 'sim-rails-3d' 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) } // ── Inner scene (needs map context) ────────────────────────────────────────── interface SceneProps { challengeId: string | undefined challenge: ChallengeDetail | null readonly?: boolean preloadCoaster?: SavedCoaster } function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) { const map = useMapLibreMap() const navigate = useNavigate() const simRailLayerRef = useRef(null) const authUser = useAuthStore(s => s.user) const currentUsername = authUser?.profile?.preferred_username as string | undefined const [coasterName, setCoasterName] = useState('') const [simResult, setSimResult] = useState(null) const [simulating, setSimulating] = useState(false) const [simError, setSimError] = useState(null) const [initialVelocity, setInitialVelocity] = useState(1.0) const [showPath, setShowPath] = useState(true) const [showAnchors, setShowAnchors] = useState(true) const [showStrips, setShowStrips] = useState(true) const [coasterListKey, setCoasterListKey] = useState(0) const [isRideMode, setIsRideMode] = useState(false) const [rideCursor, setRideCursor] = useState(null) const [pathHover, setPathHover] = useState<{ x: number; y: number; pct: number } | null>(null) const path = useCoasterPath(map, showPath, showAnchors) const accel = useAccelerationStrips(map, path.pathPts, path.mode === 'strip', showStrips) const terrain = useTerrainCapture(map, simResult) // Auto-load preloaded coaster (viewer mode) useEffect(() => { if (preloadCoaster) handleLoad(preloadCoaster) // eslint-disable-next-line react-hooks/exhaustive-deps }, [preloadCoaster]) // Exit ride mode when sim result changes useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) // ── Set up region + sim-rail sources ────────────────────────────────────── useEffect(() => { map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() }) map.addLayer({ id: LYR_REGION_F, type: 'fill', source: SRC_REGION, paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.04 } }) map.addLayer({ id: LYR_REGION_L, type: 'line', source: SRC_REGION, paint: { 'line-color': '#06b6d4', 'line-opacity': 0.45, 'line-width': 2 } }) const handle = createCustom3DLayer(LYR_SIM_RAILS, map) simRailLayerRef.current = handle return () => { handle.destroy() simRailLayerRef.current = null safeRemoveLayers(map, [LYR_REGION_F, LYR_REGION_L], [SRC_REGION]) } }, [map]) // ── Fly to challenge region ──────────────────────────────────────────────── useEffect(() => { if (!challenge) return const coords = challenge.region.coordinates[0] as [number, number][] const lons = coords.map(c => c[0]) const lats = coords.map(c => c[1]) map.fitBounds( [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], { padding: 60, pitch: 55, bearing: 0, duration: 1200 }, ) setData(map, SRC_REGION, { type: 'Feature', geometry: challenge.region, properties: {} }) }, [challenge, map]) // ── Simulation result rails (custom 3D layer at absolute altitude) ──────── useEffect(() => { if (!simResult) { simRailLayerRef.current?.update([]) return } simRailLayerRef.current?.update([ { pts: simResult.rail_1 as [number, number, number][], color: 0xef4444, radiusMeters: 0.12, }, { pts: simResult.rail_2 as [number, number, number][], color: 0xef4444, radiusMeters: 0.12, }, ]) }, [simResult]) // ── Path hover tooltip ──────────────────────────────────────────────────── useEffect(() => { if (path.pathPts.length < 2) { setPathHover(null); return } const arcs = computeArcLengths(path.pathPts) function onMove(e: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) { const { x, y } = e.point const hits = map.queryRenderedFeatures( [{ x: x - 4, y: y - 4 }, { x: x + 4, y: y + 4 }], { layers: ['coaster-path-lyr'] }, ) if (hits.length === 0) { setPathHover(null); return } const { frac } = snapToPath([e.lngLat.lng, e.lngLat.lat], path.pathPts, arcs) setPathHover({ x, y, pct: frac * 100 }) } map.on('mousemove', onMove) return () => { map.off('mousemove', onMove) } }, [map, path.pathPts]) // ── Load / Save handlers ───────────────────────────────────────────────── function handleLoad(coaster: SavedCoaster) { const anchorPoints: AnchorPoint[] = coaster.anchors.map(a => ({ id: a.id, lngLat: [a.lon, a.lat] as [number, number], terrainHeight: a.terrainAlt, heightOffset: a.heightOffset, })) path.loadAnchors(anchorPoints) accel.loadStrips(coaster.acceleration_strips) setCoasterName(coaster.name || '') setSimResult(null) } async function handleSave() { if (!challengeId || path.anchors.length < 2) return const storedAnchors = path.anchors.map(a => ({ id: a.id, lon: a.lngLat[0], lat: a.lngLat[1], terrainAlt: a.terrainHeight, heightOffset: a.heightOffset, })) await saveCoaster(challengeId, { name: coasterName.trim(), anchors: storedAnchors, acceleration_strips: accel.strips, }) setCoasterListKey(k => k + 1) } // ── Simulate handler ────────────────────────────────────────────────────── async function handleSimulate() { if (path.anchors.length < 2) return setSimulating(true) setSimError(null) try { const geoPath = path.anchors.map(anchor => effectiveLngLatAlt(anchor)) const result = await simulateCoaster({ path: geoPath, params: { initial_velocity: initialVelocity }, acceleration_strips: accel.strips.map(s => ({ start_frac: s.startFrac, end_frac: s.endFrac, accel_ms2: s.accel_ms2, })), }) setSimResult(result) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Simulation failed' setSimError(msg) } finally { setSimulating(false) } } // ── Derived UI state ────────────────────────────────────────────────────── const selected = path.anchors.find(a => a.id === path.selectedId) const selectedIndex = path.anchors.findIndex(a => a.id === path.selectedId) const diag = simResult?.diagnostics return ( <> {/* ── Three.js ride renderer (fullscreen, mounts over map) ──────────── */} {isRideMode && simResult && terrain.captureData && ( { setIsRideMode(false); setRideCursor(null) }} /> )} {/* ── Top bar ─────────────────────────────────────────────────────── */}
{!readonly && ( setCoasterName(e.target.value)} maxLength={255} /> )}

{challenge ? challenge.title : 'Loading…'}

{readonly ? 'Viewer' : 'Coaster Editor'} {challengeId && ( )}
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */} {!isRideMode && (
{!readonly && ( <> path.setMode('add')} /> path.setMode('select')} /> path.setMode('strip')} />
{path.anchors.length > 0 && ( {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} )}
)}
{!readonly && ( )} {simResult && ( <>
{terrain.status === 'loading' ? ( Preparing… ) : ( )} )}
)} {/* ── Simulation error ─────────────────────────────────────────────── */} {simError &&
{simError}
} {/* ── Diagnostics strip ────────────────────────────────────────────── */} {diag && !simError && (
H: {diag.height_range_m[0].toFixed(0)}–{diag.height_range_m[1].toFixed(0)} m V: {diag.velocity_range_ms[0].toFixed(1)}–{diag.velocity_range_ms[1].toFixed(1)} m/s {diag.g_force_range && ( G: {diag.g_force_range[0].toFixed(2)}–{diag.g_force_range[1].toFixed(2)} g )} {diag.stall_at_pct !== null && ( ⚠ stall at {diag.stall_at_pct.toFixed(0)}% )}
)} {/* ── Hint strip ──────────────────────────────────────────────────── */} {!selected && !diag && (
{path.mode === 'add' ? 'Left-click terrain to place track points · Right-click to undo' : path.mode === 'strip' ? 'Click path start of strip · Click path end · Right-click to cancel' : 'Left-click a point to select · Drag to reposition · Right-click to delete'}
)} {/* ── Selected-point panel ─────────────────────────────────────────── */} {selected && !readonly && (

Point {selectedIndex + 1} of {path.anchors.length}

Height offset {selected.heightOffset.toFixed(1)} m
{selectedIndex === 0 && (
Start velocity setInitialVelocity(Math.max(0, Math.min(100, Number(e.target.value))))} className={styles.velocityInput} /> m/s
)}
)} {/* ── Simulation profile charts (left panel) ──────────────────────── */} {!isRideMode && simResult?.profile && ( )} {/* ── Right panel column (acceleration strips) ─────────────────────── */} {!isRideMode && accel.strips.length > 0 && (
)} {/* ── Path hover tooltip ──────────────────────────────────────────── */} {!isRideMode && pathHover && (
{pathHover.pct.toFixed(1)}%
)} {/* ── Loading overlay ──────────────────────────────────────────────── */} {!challenge &&
Loading challenge…
} ) } // ── Small helper component ───────────────────────────────────────────────────── function ModeButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { return ( ) }