ui tweaks and performance tweaks
This commit is contained in:
parent
42197bfbc9
commit
3aeff0a7e7
@ -1,9 +1,5 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 60px;
|
||||
z-index: 600;
|
||||
width: 280px;
|
||||
width: 340px;
|
||||
background: rgba(8, 8, 12, 0.84);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
@ -57,7 +53,7 @@
|
||||
.stripRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@ -66,37 +62,17 @@
|
||||
color: rgba(245, 158, 11, 0.9);
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stripRange {
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-size: 11px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.accelInput {
|
||||
width: 50px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
text-align: right;
|
||||
}
|
||||
.accelInput:focus {
|
||||
outline: none;
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.stripUnit {
|
||||
.rangeSep {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stripDelete {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AccelerationStrip } from '../types/api'
|
||||
import { BlenderInput } from '../ui/BlenderInput'
|
||||
import styles from './AccelerationStripsPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
strips: AccelerationStrip[]
|
||||
onRemoveStrip: (id: string) => void
|
||||
onUpdateStrip: (id: string, accel_ms2: number) => void
|
||||
onUpdateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||
}
|
||||
|
||||
function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
|
||||
|
||||
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) {
|
||||
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
// Auto-open when a strip is added
|
||||
@ -31,19 +31,45 @@ export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }
|
||||
: strips.map((s, i) => (
|
||||
<div key={s.id} className={styles.stripRow}>
|
||||
<span className={styles.stripIndex}>Strip {i + 1}</span>
|
||||
<span className={styles.stripRange}>
|
||||
{pct(s.startFrac)}–{pct(s.endFrac)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={0.5}
|
||||
|
||||
<BlenderInput
|
||||
value={s.startFrac * 100}
|
||||
onChange={v => {
|
||||
const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001))
|
||||
onUpdateStripFrac(s.id, sf, s.endFrac)
|
||||
}}
|
||||
min={0}
|
||||
max={s.endFrac * 100 - 0.1}
|
||||
step={0.2}
|
||||
decimals={1}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<span className={styles.rangeSep}>–</span>
|
||||
|
||||
<BlenderInput
|
||||
value={s.endFrac * 100}
|
||||
onChange={v => {
|
||||
const ef = Math.min(100, Math.max(v / 100, s.startFrac + 0.001))
|
||||
onUpdateStripFrac(s.id, s.startFrac, ef)
|
||||
}}
|
||||
min={s.startFrac * 100 + 0.1}
|
||||
max={100}
|
||||
step={0.2}
|
||||
decimals={1}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<BlenderInput
|
||||
value={s.accel_ms2}
|
||||
onChange={v => onUpdateStrip(s.id, v)}
|
||||
min={-50}
|
||||
max={50}
|
||||
value={s.accel_ms2}
|
||||
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
|
||||
className={styles.accelInput}
|
||||
step={0.1}
|
||||
decimals={1}
|
||||
suffix="m/s²"
|
||||
/>
|
||||
<span className={styles.stripUnit}>m/s²</span>
|
||||
|
||||
<button
|
||||
className={styles.stripDelete}
|
||||
onClick={() => onRemoveStrip(s.id)}
|
||||
|
||||
@ -516,6 +516,20 @@
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Right panel column ──────────────────────────────────────────────────────── */
|
||||
|
||||
.rightColumn {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 60px;
|
||||
z-index: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 76px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.loading {
|
||||
|
||||
@ -118,10 +118,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
// Exit ride mode whenever the sim result changes; clear cursor on exit
|
||||
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
|
||||
|
||||
// Suspend Cesium's render loop while Three.js ride view is active so both
|
||||
// renderers don't compete for the GPU simultaneously.
|
||||
// Suspend Cesium while Three.js ride view is active: stop the render loop AND
|
||||
// disable the globe so Cesium's terrain web-workers stop posting decode results
|
||||
// back to the main thread (those async completions caused periodic ~5 s spikes).
|
||||
useEffect(() => {
|
||||
viewer.useDefaultRenderLoop = !isRideMode
|
||||
viewer.useDefaultRenderLoop = !isRideMode
|
||||
viewer.scene.globe.enabled = !isRideMode
|
||||
}, [isRideMode, viewer])
|
||||
|
||||
// Refs for simulation result entities (cleared on each new run / unmount)
|
||||
@ -135,10 +137,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
const coords = challenge.region.coordinates[0]
|
||||
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
|
||||
sphere.radius = Math.max(sphere.radius + 20, 50)
|
||||
// Add 25 % padding so the polygon doesn't butt right up against the frame
|
||||
// edges, then let Cesium compute the exact range (range = 0 = auto-fit).
|
||||
const padded = new Cesium.BoundingSphere(sphere.center, sphere.radius * 1.25)
|
||||
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
|
||||
viewer.camera.flyToBoundingSphere(padded, {
|
||||
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 0),
|
||||
duration: 1.2,
|
||||
})
|
||||
}, [challenge, viewer])
|
||||
@ -324,7 +328,6 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
simResult={simResult}
|
||||
captureData={terrain.captureData}
|
||||
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
|
||||
onRideProgress={setRideCursor}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -496,7 +499,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
)}
|
||||
|
||||
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
|
||||
{simResult?.profile && (
|
||||
{!isRideMode && simResult?.profile && (
|
||||
<SimulationPlots
|
||||
profile={simResult.profile}
|
||||
strips={accel.strips}
|
||||
@ -504,23 +507,26 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Acceleration strips (right panel) ───────────────────────────── */}
|
||||
{accel.strips.length > 0 && (
|
||||
<AccelerationStripsPanel
|
||||
strips={accel.strips}
|
||||
onRemoveStrip={accel.removeStrip}
|
||||
onUpdateStrip={accel.updateStrip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
|
||||
{challengeId && (
|
||||
<CoasterListPanel
|
||||
challengeId={challengeId}
|
||||
currentUsername={currentUsername}
|
||||
onLoad={handleLoad}
|
||||
refreshKey={coasterListKey}
|
||||
/>
|
||||
{/* ── Right panel column (acceleration strips + coaster list) ────── */}
|
||||
{!isRideMode && (
|
||||
<div className={styles.rightColumn}>
|
||||
{accel.strips.length > 0 && (
|
||||
<AccelerationStripsPanel
|
||||
strips={accel.strips}
|
||||
onRemoveStrip={accel.removeStrip}
|
||||
onUpdateStrip={accel.updateStrip}
|
||||
onUpdateStripFrac={accel.updateStripFrac}
|
||||
/>
|
||||
)}
|
||||
{challengeId && (
|
||||
<CoasterListPanel
|
||||
challengeId={challengeId}
|
||||
currentUsername={currentUsername}
|
||||
onLoad={handleLoad}
|
||||
refreshKey={coasterListKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loading overlay ──────────────────────────────────────────────── */}
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 112px; /* 52px header + ~44px topBar + 16px gap */
|
||||
z-index: 200;
|
||||
width: 240px;
|
||||
background: rgba(8, 8, 12, 0.84);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
@ -249,9 +249,20 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
|
||||
addRail(rideData.rail1)
|
||||
addRail(rideData.rail2)
|
||||
|
||||
// Expose a warm-up helper: makes every terrain patch visible so the caller can
|
||||
// do one render that forces the GPU driver to flush all queued texImage2D /
|
||||
// generateMipmap commands for every patch at once. Without this, those
|
||||
// commands are deferred and cause a GPU pipeline stall the first time each
|
||||
// patch becomes the active (visible) one during the ride — which at high
|
||||
// coaster speeds can be every few seconds.
|
||||
function warmUpPatches() {
|
||||
terrainMeshes.forEach(m => { m.visible = true })
|
||||
}
|
||||
|
||||
return {
|
||||
scene,
|
||||
setActivePatch,
|
||||
warmUpPatches,
|
||||
disposeAll: () => {
|
||||
geos.forEach(g => g.dispose())
|
||||
mats.forEach(m => m.dispose())
|
||||
@ -322,7 +333,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
rideDataRef.current = rideData
|
||||
setTotalDuration(rideData.totalDuration)
|
||||
|
||||
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
|
||||
const { scene, setActivePatch, warmUpPatches, disposeAll } = buildScene(captureData, rideData, renderer)
|
||||
sceneRef.current = scene
|
||||
setActivePatchRef.current = setActivePatch
|
||||
activePatchIdxRef.current = 0
|
||||
@ -345,7 +356,13 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
cameraRef.current.updateMatrixWorld()
|
||||
}
|
||||
|
||||
// Warm-up: make every terrain patch visible and render once. This forces the
|
||||
// GPU driver to flush all pending texImage2D / generateMipmap commands for
|
||||
// every patch right now, so patch-visibility switches during the ride have
|
||||
// zero GPU stall cost. Then reset to only the first patch.
|
||||
warmUpPatches()
|
||||
renderer.render(scene, cameraRef.current)
|
||||
setActivePatch(0)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
|
||||
@ -43,6 +43,7 @@ export interface AccelerationStripsHandle {
|
||||
strips: AccelerationStrip[]
|
||||
removeStrip: (id: string) => void
|
||||
updateStrip: (id: string, accel_ms2: number) => void
|
||||
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||
clearStrips: () => void
|
||||
loadStrips: (strips: AccelerationStrip[]) => void
|
||||
}
|
||||
@ -75,6 +76,10 @@ export function useAccelerationStrips(
|
||||
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
||||
}, [])
|
||||
|
||||
const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
|
||||
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
|
||||
}, [])
|
||||
|
||||
const clearStrips = useCallback(() => {
|
||||
setStrips([])
|
||||
}, [])
|
||||
@ -236,5 +241,5 @@ export function useAccelerationStrips(
|
||||
}
|
||||
}, [viewer])
|
||||
|
||||
return { strips, removeStrip, updateStrip, clearStrips, loadStrips }
|
||||
return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
|
||||
}
|
||||
|
||||
@ -207,7 +207,7 @@ export function useTerrainCapture(
|
||||
|
||||
if (!simResult) {
|
||||
setStatus('idle')
|
||||
return
|
||||
return () => { abortRef.current = true }
|
||||
}
|
||||
|
||||
abortRef.current = false
|
||||
@ -224,6 +224,10 @@ export function useTerrainCapture(
|
||||
console.error('Terrain capture failed:', err)
|
||||
setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortRef.current = true
|
||||
}
|
||||
}, [simResult, viewer])
|
||||
|
||||
return { status, captureData }
|
||||
|
||||
50
web/src/ui/BlenderInput.module.css
Normal file
50
web/src/ui/BlenderInput.module.css
Normal file
@ -0,0 +1,50 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.root:hover .display {
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 64px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.6);
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
text-align: right;
|
||||
}
|
||||
112
web/src/ui/BlenderInput.tsx
Normal file
112
web/src/ui/BlenderInput.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import styles from './BlenderInput.module.css'
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
/** How much the value changes per pixel dragged. Default 0.1 */
|
||||
step?: number
|
||||
/** Decimal places shown. Default 1 */
|
||||
decimals?: number
|
||||
/** Optional suffix shown after the number, e.g. "%" or "m/s²" */
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 4 // px before we treat motion as a drag
|
||||
|
||||
function clamp(v: number, min: number | undefined, max: number | undefined) {
|
||||
if (min !== undefined && v < min) return min
|
||||
if (max !== undefined && v > max) return max
|
||||
return v
|
||||
}
|
||||
|
||||
export function BlenderInput({ value, onChange, min, max, step = 0.1, decimals = 1, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dragState = useRef<{
|
||||
startX: number
|
||||
startValue: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
// ── Focus the real input when we enter edit mode ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
// ── Mouse-down: begin drag tracking ────────────────────────────────────────
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (editing) return
|
||||
e.preventDefault()
|
||||
dragState.current = { startX: e.clientX, startValue: value, moved: false }
|
||||
|
||||
function onMove(me: MouseEvent) {
|
||||
if (!dragState.current) return
|
||||
const dx = me.clientX - dragState.current.startX
|
||||
if (Math.abs(dx) >= DRAG_THRESHOLD) {
|
||||
dragState.current.moved = true
|
||||
const next = clamp(
|
||||
dragState.current.startValue + dx * step,
|
||||
min,
|
||||
max,
|
||||
)
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (!dragState.current) return
|
||||
if (!dragState.current.moved) {
|
||||
// treat as a click → enter edit mode
|
||||
setText(value.toFixed(decimals))
|
||||
setEditing(true)
|
||||
}
|
||||
dragState.current = null
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [editing, value, step, min, max, decimals, onChange])
|
||||
|
||||
// ── Commit text input ───────────────────────────────────────────────────────
|
||||
const commit = useCallback(() => {
|
||||
const parsed = parseFloat(text)
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(clamp(parsed, min, max))
|
||||
}
|
||||
setEditing(false)
|
||||
}, [text, min, max, onChange])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}, [commit])
|
||||
|
||||
return (
|
||||
<span className={styles.root} onMouseDown={handleMouseDown}>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.display}>
|
||||
{value.toFixed(decimals)}
|
||||
{suffix && <span className={styles.suffix}>{suffix}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user