rcnn/web/src/coaster/RideRenderer.tsx
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

650 lines
25 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, useCallback } from 'react'
import { createPortal } from 'react-dom'
import * as THREE from 'three'
import type { CoasterSimulationResult } from '../types/api'
import type { TerrainCaptureData } from './useTerrainCapture'
import { geoToEnu } from './useTerrainCapture'
import styles from './RideRenderer.module.css'
// ── Types ──────────────────────────────────────────────────────────────────────
type CameraMode = 'first' | 'third'
interface Props {
simResult: CoasterSimulationResult
captureData: TerrainCaptureData[]
onStop: () => void
/** Called at ~4 Hz with the current track-fraction position [0,1]. */
onRideProgress?: (sFrac: number) => void
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Largest index i such that arr[i] <= t (arr must be non-decreasing). */
function bisect(arr: number[], t: number): number {
let lo = 0, hi = arr.length - 2
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (arr[mid] <= t) lo = mid
else hi = mid - 1
}
return lo
}
function formatRideTime(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
const UP = new THREE.Vector3(0, 1, 0)
// ── Precomputed ride data in Three.js ENU coords ───────────────────────────────
interface RideData {
timeArray: number[]
sFrac: number[] // profile s_frac at each sample — parallel to timeArray
centerline: THREE.Vector3[]
rail1: THREE.Vector3[]
rail2: THREE.Vector3[]
totalDuration: number
count: number
}
function buildRideData(simResult: CoasterSimulationResult): RideData {
const { profile, rail_1, rail_2, origin } = simResult
const n = Math.min(profile.s_frac.length, rail_1.length, rail_2.length)
const r1 = (rail_1 as [number, number, number][]).slice(0, n)
.map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin))
const r2 = (rail_2 as [number, number, number][]).slice(0, n)
.map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin))
const cl = r1.map((a, i) => a.clone().lerp(r2[i], 0.5))
// Cumulative time using same 1 m/s velocity floor as backend
const timeArray: number[] = [0]
for (let i = 1; i < n; i++) {
const ds = (profile.s_frac[i] - profile.s_frac[i - 1]) * profile.total_length_m
const v = Math.max(profile.velocity_ms[i - 1], 1.0)
timeArray.push(timeArray[i - 1] + ds / v)
}
const sFrac = profile.s_frac.slice(0, n)
return { timeArray, sFrac, centerline: cl, rail1: r1, rail2: r2,
totalDuration: timeArray[n - 1], count: n }
}
// ── Camera target computation (zero allocation per call) ──────────────────────
//
// All scratch objects are pre-allocated at module level so the hot rAF path
// never triggers the garbage collector.
const _mat = new THREE.Matrix4()
const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → Z toward target
// Scratch vectors reused every frame
const _scPos = new THREE.Vector3()
const _scR1 = new THREE.Vector3()
const _scR2 = new THREE.Vector3()
const _scTang = new THREE.Vector3()
const _scSide = new THREE.Vector3()
const _scTUp = new THREE.Vector3()
const _scLook = new THREE.Vector3()
// Output objects — callers must copy out before the next call
const _outPos = new THREE.Vector3()
const _outQuat = new THREE.Quaternion()
// Scratch for user drag rotation offset (first-person only)
const _userOffsetQuat = new THREE.Quaternion()
const _userOffsetEuler = new THREE.Euler(0, 0, 0, 'YXZ')
interface CameraTarget { pos: THREE.Vector3; quat: THREE.Quaternion }
/** Writes result into _outPos / _outQuat — no heap allocation. */
function computeCameraTarget(
t: number,
data: RideData,
mode: CameraMode,
): CameraTarget {
const { timeArray, centerline, rail1, rail2, count } = data
const ct = Math.max(0, Math.min(t, timeArray[count - 1]))
const i = bisect(timeArray, ct)
const i1 = Math.min(i + 1, count - 1)
const alpha = (timeArray[i1] > timeArray[i])
? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i])
: 0
_scPos.lerpVectors(centerline[i], centerline[i1], alpha)
_scR1.lerpVectors(rail1[i], rail1[i1], alpha)
_scR2.lerpVectors(rail2[i], rail2[i1], alpha)
// Wider stencil for smoother forward tangent
const pi = Math.max(0, i - 2)
const pj = Math.min(count - 1, i1 + 2)
_scTang.subVectors(centerline[pj], centerline[pi]).normalize()
// Track-local "up": cross(tangent, rail1→rail2) → binormal
_scSide.subVectors(_scR1, _scR2).normalize()
_scTUp.crossVectors(_scTang, _scSide).normalize()
if (_scTUp.dot(UP) < 0) _scTUp.negate()
if (mode === 'first') {
// Sit 1.5 m above centreline, look 10 m ahead
_outPos.copy(_scPos).addScaledVector(UP, 1.5)
_scLook.copy(_outPos).addScaledVector(_scTang, 10)
_dummy.up.copy(_scTUp)
} else {
// 40 m behind + 12 m above, looking at the car
_outPos.copy(_scPos).addScaledVector(_scTang, -40).addScaledVector(UP, 12)
_scLook.copy(_scPos)
_dummy.up.copy(UP)
}
_dummy.position.copy(_outPos)
_dummy.lookAt(_scLook)
_dummy.updateMatrixWorld(true)
_mat.extractRotation(_dummy.matrixWorld)
_outQuat.setFromRotationMatrix(_mat)
return { pos: _outPos, quat: _outQuat }
}
// ── Terrain mesh builder (one per patch) ──────────────────────────────────────
function buildTerrainMesh(
patch: TerrainCaptureData,
renderer: THREE.WebGLRenderer,
visible: boolean,
): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
const GRID = patch.gridSize
const verts = patch.terrainVertices
const posArr = new Float32Array(GRID * GRID * 3)
const uvArr = new Float32Array(GRID * GRID * 2)
for (let j = 0; j < GRID; j++) {
for (let i = 0; i < GRID; i++) {
const idx = j * GRID + i
const v = verts[idx]
posArr[idx * 3] = v.x
posArr[idx * 3 + 1] = v.y - 0.5
posArr[idx * 3 + 2] = v.z
uvArr[idx * 2] = i / (GRID - 1)
uvArr[idx * 2 + 1] = j / (GRID - 1)
}
}
const idxArr: number[] = []
for (let j = 0; j < GRID - 1; j++) {
for (let i = 0; i < GRID - 1; i++) {
const a = j * GRID + i, b = j * GRID + i + 1
const c = (j + 1) * GRID + i + 1, d = (j + 1) * GRID + i
idxArr.push(a, b, d, b, c, d)
}
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3))
geo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2))
geo.setIndex(idxArr)
geo.computeVertexNormals()
const tex = new THREE.Texture(patch.imageBitmap)
tex.colorSpace = THREE.SRGBColorSpace
tex.anisotropy = renderer.capabilities.getMaxAnisotropy()
tex.minFilter = THREE.LinearMipmapLinearFilter
tex.magFilter = THREE.LinearFilter
tex.needsUpdate = true
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
const mesh = new THREE.Mesh(geo, mat)
mesh.visible = visible
return { mesh, geo, mat, tex }
}
// ── Three.js scene builder ─────────────────────────────────────────────────────
function buildScene(captureData: TerrainCaptureData[], rideData: RideData, renderer: THREE.WebGLRenderer) {
const scene = new THREE.Scene()
scene.background = new THREE.Color(0x87ceeb)
scene.add(new THREE.AmbientLight(0xffffff, 0.7))
const sun = new THREE.DirectionalLight(0xfffbe6, 0.85)
sun.position.set(200, 500, -300)
scene.add(sun)
const geos: THREE.BufferGeometry[] = []
const mats: THREE.Material[] = []
const texes: THREE.Texture[] = []
// ── Terrain patches — one mesh per patch, only the active one visible ────
const terrainMeshes = captureData.map((patch, i) => {
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0)
geos.push(geo)
mats.push(mat)
texes.push(tex)
scene.add(mesh)
// Pre-upload texture to GPU so visibility swaps have zero hitch
renderer.initTexture(tex)
return mesh
})
function setActivePatch(idx: number) {
const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1))
terrainMeshes.forEach((m, i) => { m.visible = i === clamped })
}
// ── Coaster rails ──────────────────────────────────────────────────────────
function addRail(pts: THREE.Vector3[]) {
const step = Math.max(1, Math.floor(pts.length / 200))
const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
const curve = new THREE.CatmullRomCurve3(dpts)
const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 3, 600), 0.075, 6, false)
const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 })
geos.push(geo)
mats.push(mat)
scene.add(new THREE.Mesh(geo, mat))
}
addRail(rideData.rail1)
addRail(rideData.rail2)
return {
scene,
setActivePatch,
disposeAll: () => {
geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose())
texes.forEach(t => t.dispose())
},
}
}
// ── Component ─────────────────────────────────────────────────────────────────
export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000))
const sceneRef = useRef<THREE.Scene | null>(null)
const rafRef = useRef<number | null>(null)
const fpsElRef = useRef<HTMLDivElement | null>(null)
// Ride playback refs (mutable, used inside rAF loop)
const rideDataRef = useRef<RideData | null>(null)
const isPlayingRef = useRef(false)
const rideTimeRef = useRef(0)
const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz)
const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive)
const cameraModeRef = useRef<CameraMode>('first')
const prevWallRef = useRef(0)
// FPS tracking (updated via DOM ref — zero React overhead)
const fpsCountRef = useRef(0)
const fpsLastRef = useRef(0)
// Smooth camera state
const smoothPosRef = useRef(new THREE.Vector3())
const smoothQuatRef = useRef(new THREE.Quaternion())
const needsInitRef = useRef(true)
// Active terrain patch switching
const setActivePatchRef = useRef<((idx: number) => void) | null>(null)
const activePatchIdxRef = useRef(0)
// First-person drag rotation state
const userYawRef = useRef(0) // radians — current yaw offset from track forward
const userPitchRef = useRef(0) // radians — current pitch offset
const isDraggingRef = useRef(false)
const lastDragXRef = useRef(0)
const lastDragYRef = useRef(0)
// React state (updated ~4 Hz)
const [isPlaying, setIsPlaying] = useState(false)
const [rideTime, setRideTime] = useState(0)
const [totalDuration, setTotalDuration] = useState(0)
const [cameraMode, setCameraMode] = useState<CameraMode>('first')
useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode])
// ── Build Three.js scene ────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
// antialias: false — significant GPU cost saving; no pixelRatio scaling on retina
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false })
renderer.setPixelRatio(1)
rendererRef.current = renderer
const rideData = buildRideData(simResult)
rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration)
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene
setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0
function onResize() {
const w = window.innerWidth, h = window.innerHeight
renderer.setSize(w, h)
cameraRef.current.aspect = w / h
cameraRef.current.updateProjectionMatrix()
}
window.addEventListener('resize', onResize)
onResize()
if (rideData.count > 1) {
const t0 = computeCameraTarget(0, rideData, 'first')
smoothPosRef.current.copy(t0.pos)
smoothQuatRef.current.copy(t0.quat)
cameraRef.current.position.copy(t0.pos)
cameraRef.current.quaternion.copy(t0.quat)
cameraRef.current.updateMatrixWorld()
}
renderer.render(scene, cameraRef.current)
return () => {
window.removeEventListener('resize', onResize)
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
disposeAll()
renderer.dispose()
rendererRef.current = null
sceneRef.current = null
}
}, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps
// ── rAF render loop ──────────────────────────────────────────────────────────
// Smoothing time constants (seconds)
const POS_TAU = 0.05
const ROT_TAU = 0.08
function startLoop() {
function tick(wallMs: number) {
const renderer = rendererRef.current
const scene = sceneRef.current
if (!renderer || !scene) return
const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1)
prevWallRef.current = wallMs
// ── FPS counter (DOM write, no React) ──────────────────────────────────
fpsCountRef.current++
if (wallMs - fpsLastRef.current >= 500) {
const elapsed = (wallMs - fpsLastRef.current) / 1000
const fps = Math.round(fpsCountRef.current / elapsed)
if (fpsElRef.current) fpsElRef.current.textContent = `${fps} FPS`
fpsCountRef.current = 0
fpsLastRef.current = wallMs
}
// ── Ride time ──────────────────────────────────────────────────────────
let rideT = rideTimeRef.current
if (isPlayingRef.current) {
rideTimeRef.current += dt
rideT = rideTimeRef.current
// Ride time display: ~4 Hz (cheap — only updates RideRenderer itself)
if (rideT - lastTimeUpdateRef.current > 0.25) {
setRideTime(rideT)
lastTimeUpdateRef.current = rideT
}
// Plot cursor: ~1 Hz (expensive — triggers Recharts SVG repaint in parent)
if (onRideProgress && rideT - lastCursorUpdateRef.current > 1.0) {
lastCursorUpdateRef.current = rideT
const d = rideDataRef.current
if (d) {
const ci = bisect(d.timeArray, rideT)
const ci1 = Math.min(ci + 1, d.count - 1)
const ca = (d.timeArray[ci1] > d.timeArray[ci])
? (rideT - d.timeArray[ci]) / (d.timeArray[ci1] - d.timeArray[ci])
: 0
onRideProgress(d.sFrac[ci] + (d.sFrac[ci1] - d.sFrac[ci]) * ca)
}
}
const data = rideDataRef.current
if (data && rideT >= data.totalDuration) {
rideTimeRef.current = data.totalDuration
rideT = data.totalDuration
isPlayingRef.current = false
setIsPlaying(false)
setRideTime(rideT)
}
}
// ── Active terrain patch ──────────────────────────────────────────────
if (captureData.length > 1 && setActivePatchRef.current) {
const d = rideDataRef.current
if (d) {
const pi = bisect(d.timeArray, rideT)
const pi1 = Math.min(pi + 1, d.count - 1)
const pa = d.timeArray[pi1] > d.timeArray[pi]
? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi])
: 0
const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa
const target = Math.round(frac * (captureData.length - 1))
if (target !== activePatchIdxRef.current) {
activePatchIdxRef.current = target
setActivePatchRef.current(target)
}
}
}
// ── Camera ────────────────────────────────────────────────────────────
const data = rideDataRef.current
if (data) {
const target = computeCameraTarget(rideT, data, cameraModeRef.current)
// First-person drag rotation: lerp yaw/pitch back to 0 when not dragging,
// then bake the offset into the target quaternion.
if (cameraModeRef.current === 'first') {
if (!isDraggingRef.current) {
const returnAlpha = 1 - Math.exp(-dt * 2.5)
userYawRef.current *= (1 - returnAlpha)
userPitchRef.current *= (1 - returnAlpha)
if (Math.abs(userYawRef.current) < 0.0001) userYawRef.current = 0
if (Math.abs(userPitchRef.current) < 0.0001) userPitchRef.current = 0
}
_userOffsetEuler.set(userPitchRef.current, userYawRef.current, 0, 'YXZ')
_userOffsetQuat.setFromEuler(_userOffsetEuler)
target.quat.multiply(_userOffsetQuat)
}
if (needsInitRef.current || isDraggingRef.current) {
// Snap instantly: on init, or while dragging so the view tracks the finger
smoothPosRef.current.copy(target.pos)
smoothQuatRef.current.copy(target.quat)
needsInitRef.current = false
} else {
const posAlpha = 1 - Math.exp(-dt / POS_TAU)
const rotAlpha = 1 - Math.exp(-dt / ROT_TAU)
smoothPosRef.current.lerp(target.pos, posAlpha)
smoothQuatRef.current.slerp(target.quat, rotAlpha)
}
cameraRef.current.position.copy(smoothPosRef.current)
cameraRef.current.quaternion.copy(smoothQuatRef.current)
cameraRef.current.updateMatrixWorld()
}
renderer.render(scene, cameraRef.current)
rafRef.current = requestAnimationFrame(tick)
}
prevWallRef.current = performance.now()
fpsLastRef.current = performance.now()
rafRef.current = requestAnimationFrame(tick)
}
// ── Playback controls ──────────────────────────────────────────────────────
const play = useCallback(() => {
if (!rideDataRef.current) return
// Restart from beginning if ride has finished
const restarting = rideTimeRef.current >= rideDataRef.current.totalDuration - 0.05
if (restarting) {
rideTimeRef.current = 0
userYawRef.current = 0
userPitchRef.current = 0
setRideTime(0)
needsInitRef.current = true // snap camera to start position
if (onRideProgress) onRideProgress(0)
} else {
needsInitRef.current = false // resume with smooth camera
}
lastTimeUpdateRef.current = -1
lastCursorUpdateRef.current = -1
isPlayingRef.current = true
setIsPlaying(true)
}, [onRideProgress])
const pause = useCallback(() => {
isPlayingRef.current = false
setIsPlaying(false)
}, [])
const stop = useCallback(() => {
isPlayingRef.current = false
rideTimeRef.current = 0
setIsPlaying(false)
setRideTime(0)
onStop()
}, [onStop])
// ── First-person drag-to-look event listeners ─────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const DRAG_SENSITIVITY = 0.004
const MAX_PITCH = Math.PI / 2.5 // ±72°
const MAX_YAW = Math.PI // ±180°
function onMouseDown(e: MouseEvent) {
if (cameraModeRef.current !== 'first') return
e.preventDefault() // prevent text-selection flicker during drag
isDraggingRef.current = true
lastDragXRef.current = e.clientX
lastDragYRef.current = e.clientY
}
function onMouseMove(e: MouseEvent) {
if (!isDraggingRef.current) return
const dx = e.clientX - lastDragXRef.current
const dy = e.clientY - lastDragYRef.current
lastDragXRef.current = e.clientX
lastDragYRef.current = e.clientY
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
}
function onMouseUp() { isDraggingRef.current = false }
function onTouchStart(e: TouchEvent) {
if (cameraModeRef.current !== 'first' || e.touches.length === 0) return
isDraggingRef.current = true
lastDragXRef.current = e.touches[0].clientX
lastDragYRef.current = e.touches[0].clientY
}
function onTouchMove(e: TouchEvent) {
if (!isDraggingRef.current || e.touches.length === 0) return
const dx = e.touches[0].clientX - lastDragXRef.current
const dy = e.touches[0].clientY - lastDragYRef.current
lastDragXRef.current = e.touches[0].clientX
lastDragYRef.current = e.touches[0].clientY
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
}
function onTouchEnd() { isDraggingRef.current = false }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
window.addEventListener('touchmove', onTouchMove, { passive: true })
window.addEventListener('touchend', onTouchEnd)
return () => {
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('touchstart', onTouchStart)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
}, [])
// Start render loop on mount; auto-play immediately
useEffect(() => {
needsInitRef.current = true
startLoop()
const data = rideDataRef.current
if (data) {
lastTimeUpdateRef.current = -1
lastCursorUpdateRef.current = -1
isPlayingRef.current = true
setIsPlaying(true)
}
return () => {
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// ── UI ────────────────────────────────────────────────────────────────────
const isRideDone = !isPlaying && totalDuration > 0 && rideTime >= totalDuration - 0.05
const controls = (
<div className={styles.rideBar}>
<button
className={styles.ridePlayBtn}
onClick={isPlaying ? pause : play}
title={isPlaying ? 'Pause' : isRideDone ? 'Restart' : 'Resume'}
>
{isPlaying ? '⏸' : isRideDone ? '↺' : '▶'}
</button>
<button className={styles.rideStopBtn} onClick={stop} title="Stop ride">
</button>
<div className={styles.rideBarDivider} />
<span className={styles.rideCameraLabel}>Camera</span>
<button
className={`${styles.rideCameraBtn}${cameraMode === 'first' ? ` ${styles.rideCameraActive}` : ''}`}
onClick={() => setCameraMode('first')}
>
1st Person
</button>
<button
className={`${styles.rideCameraBtn}${cameraMode === 'third' ? ` ${styles.rideCameraActive}` : ''}`}
onClick={() => setCameraMode('third')}
>
3rd Person
</button>
<div className={styles.rideBarDivider} />
<span className={styles.rideTimeDisplay}>
{formatRideTime(rideTime)}
<span className={styles.rideTimeSep}>/</span>
{formatRideTime(totalDuration)}
</span>
</div>
)
return (
<>
<canvas ref={canvasRef} className={styles.canvas} />
<div ref={fpsElRef} className={styles.fpsCounter} />
{createPortal(controls, document.body)}
</>
)
}