rcnn/web/src/coaster/CoasterEditorPage.tsx
2026-04-25 23:15:46 +02:00

502 lines
20 KiB
TypeScript
Raw 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 { 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<ChallengeDetail | null>(null)
useEffect(() => {
if (!id) return
fetchChallengeDetail(id).then(setChallenge).catch(console.error)
}, [id])
return (
<MapLibreViewer>
<CoasterEditorScene challengeId={id} challenge={challenge} />
</MapLibreViewer>
)
}
export function CoasterViewerPage() {
const { challengeId, coasterId } = useParams<{ challengeId: string; coasterId: string }>()
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null)
const [preloadCoaster, setPreloadCoaster] = useState<SavedCoaster | null>(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 (
<MapLibreViewer>
<CoasterEditorScene
challengeId={challengeId}
challenge={challenge}
readonly
preloadCoaster={preloadCoaster ?? undefined}
/>
</MapLibreViewer>
)
}
// ── 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<Layer3DHandle | null>(null)
const authUser = useAuthStore(s => s.user)
const currentUsername = authUser?.profile?.preferred_username as string | undefined
const [coasterName, setCoasterName] = useState('')
const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null)
const [simulating, setSimulating] = useState(false)
const [simError, setSimError] = useState<string | null>(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<number | null>(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 && (
<RideRenderer
simResult={simResult}
captureData={terrain.captureData}
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
/>
)}
{/* ── Top bar ─────────────────────────────────────────────────────── */}
<div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => navigate(-1)}>
Back
</button>
{!readonly && (
<input
className={styles.nameInput}
type="text"
placeholder="Name your coaster…"
value={coasterName}
onChange={(e) => setCoasterName(e.target.value)}
maxLength={255}
/>
)}
<h1 className={styles.title}>
{challenge ? challenge.title : 'Loading…'}
</h1>
<span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span>
{challengeId && (
<CoasterListPanel
challengeId={challengeId}
currentUsername={currentUsername}
onLoad={handleLoad}
refreshKey={coasterListKey}
menuMode
/>
)}
</div>
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
{!isRideMode && (
<div className={styles.toolbar}>
{!readonly && (
<>
<ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} />
<ModeButton label="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} />
<div className={styles.divider} />
<button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}>
Undo
</button>
<button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}>
Clear
</button>
{path.anchors.length > 0 && (
<span className={styles.countBadge}>
{path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''}
</span>
)}
<div className={styles.divider} />
</>
)}
<button
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
onClick={handleSimulate}
disabled={path.anchors.length < 2 || simulating}
>
{simulating ? 'Simulating…' : 'Simulate'}
</button>
<div className={styles.divider} />
<button
className={`${styles.toolBtn}${showPath ? ` ${styles.active}` : ''}`}
onClick={() => setShowPath(p => !p)}
>
Path
</button>
<button
className={`${styles.toolBtn}${showAnchors ? ` ${styles.active}` : ''}`}
onClick={() => setShowAnchors(a => !a)}
>
Anchors
</button>
<button
className={`${styles.toolBtn}${showStrips ? ` ${styles.active}` : ''}`}
onClick={() => setShowStrips(s => !s)}
>
Strips
</button>
{!readonly && (
<button
className={styles.ghostBtn}
onClick={handleSave}
disabled={path.anchors.length < 2}
>
Save
</button>
)}
{simResult && (
<>
<div className={styles.divider} />
{terrain.status === 'loading' ? (
<span className={styles.badge}>Preparing</span>
) : (
<button
className={styles.rideBtn}
onClick={() => setIsRideMode(true)}
disabled={terrain.status !== 'ready'}
>
Ride
</button>
)}
</>
)}
</div>
)}
{/* ── Simulation error ─────────────────────────────────────────────── */}
{simError && <div className={styles.simError}>{simError}</div>}
{/* ── Diagnostics strip ────────────────────────────────────────────── */}
{diag && !simError && (
<div className={styles.diagStrip}>
<span>H: {diag.height_range_m[0].toFixed(0)}{diag.height_range_m[1].toFixed(0)} m</span>
<span>V: {diag.velocity_range_ms[0].toFixed(1)}{diag.velocity_range_ms[1].toFixed(1)} m/s</span>
{diag.g_force_range && (
<span>G: {diag.g_force_range[0].toFixed(2)}{diag.g_force_range[1].toFixed(2)} g</span>
)}
{diag.stall_at_pct !== null && (
<span className={styles.diagWarn}> stall at {diag.stall_at_pct.toFixed(0)}%</span>
)}
</div>
)}
{/* ── Hint strip ──────────────────────────────────────────────────── */}
{!selected && !diag && (
<div className={styles.hint}>
{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'}
</div>
)}
{/* ── Selected-point panel ─────────────────────────────────────────── */}
{selected && !readonly && (
<div className={styles.selectedPanel}>
<p className={styles.panelHeading}>
Point {selectedIndex + 1} of {path.anchors.length}
</p>
<div className={styles.heightRow}>
<span className={styles.heightLabel}>Height offset</span>
<span className={styles.heightValue}>{selected.heightOffset.toFixed(1)} m</span>
</div>
{selectedIndex === 0 && (
<div className={styles.heightRow}>
<span className={styles.heightLabel}>Start velocity</span>
<input
type="number"
min={0} max={100} step={0.5}
value={initialVelocity}
onChange={e => setInitialVelocity(Math.max(0, Math.min(100, Number(e.target.value))))}
className={styles.velocityInput}
/>
<span className={styles.heightLabel}>m/s</span>
</div>
)}
<div className={styles.stepGroup}>
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 5)}>+5 m</button>
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 1)}>+1 m</button>
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -1)}>1 m</button>
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -5)}>5 m</button>
</div>
<button className={styles.removeBtn} onClick={() => path.removeAnchor(selected.id)}>
Remove point
</button>
</div>
)}
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
{!isRideMode && simResult?.profile && (
<SimulationPlots
profile={simResult.profile}
strips={accel.strips}
rideCursor={rideCursor}
/>
)}
{/* ── Right panel column (acceleration strips) ─────────────────────── */}
{!isRideMode && accel.strips.length > 0 && (
<div className={styles.rightColumn}>
<AccelerationStripsPanel
strips={accel.strips}
onRemoveStrip={accel.removeStrip}
onUpdateStrip={accel.updateStrip}
onUpdateStripFrac={accel.updateStripFrac}
/>
</div>
)}
{/* ── Path hover tooltip ──────────────────────────────────────────── */}
{!isRideMode && pathHover && (
<div
className={styles.pathTooltip}
style={{ left: pathHover.x + 14, top: pathHover.y - 10 }}
>
{pathHover.pct.toFixed(1)}%
</div>
)}
{/* ── Loading overlay ──────────────────────────────────────────────── */}
{!challenge && <div className={styles.loading}>Loading challenge</div>}
</>
)
}
// ── Small helper component ─────────────────────────────────────────────────────
function ModeButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button className={`${styles.toolBtn}${active ? ` ${styles.active}` : ''}`} onClick={onClick}>
{label}
</button>
)
}