502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|