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>
This commit is contained in:
munsel 2026-04-23 01:43:10 +02:00
parent ff11fe1d2a
commit 42197bfbc9
39 changed files with 2252 additions and 350 deletions

View File

@ -31,6 +31,7 @@ class ChallengeMapSerializer(GeoFeatureModelSerializer):
GeoJSON FeatureCollection using region_centroid as geometry. GeoJSON FeatureCollection using region_centroid as geometry.
Used for the map pin list does not include the full region polygon. Used for the map pin list does not include the full region polygon.
""" """
coaster_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = Challenge model = Challenge
@ -38,9 +39,13 @@ class ChallengeMapSerializer(GeoFeatureModelSerializer):
fields = [ fields = [
"id", "title", "status", "id", "title", "status",
"submission_count", "max_submissions", "submission_count", "max_submissions",
"coaster_count",
"expires_at", "created_at", "expires_at", "created_at",
] ]
def get_coaster_count(self, obj):
return obj.coasters.count()
class ChallengeSplatPreviewSerializer(serializers.Serializer): class ChallengeSplatPreviewSerializer(serializers.Serializer):
id = serializers.UUIDField() id = serializers.UUIDField()
@ -58,6 +63,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
participant_count = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField()
is_participating = serializers.SerializerMethodField() is_participating = serializers.SerializerMethodField()
preview_splats = serializers.SerializerMethodField() preview_splats = serializers.SerializerMethodField()
coaster_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = Challenge model = Challenge
@ -67,6 +73,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
"region", "region_centroid", "region", "region_centroid",
"max_submissions", "submission_count", "max_submissions", "submission_count",
"participant_count", "is_participating", "participant_count", "is_participating",
"coaster_count",
"preview_splats", "preview_splats",
"expires_at", "created_at", "updated_at", "expires_at", "created_at", "updated_at",
] ]
@ -88,6 +95,9 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
return False return False
return obj.participants.filter(user=request.user).exists() return obj.participants.filter(user=request.user).exists()
def get_coaster_count(self, obj):
return obj.coasters.count()
def get_preview_splats(self, obj): def get_preview_splats(self, obj):
from apps.splats.models import Splat from apps.splats.models import Splat
qs = ( qs = (

View File

@ -0,0 +1,30 @@
# Generated by Django 5.1.4 on 2026-04-22 15:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('coaster', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CoasterRating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveSmallIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('coaster', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='coaster.coaster')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coaster_ratings', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('coaster', 'user')},
},
),
]

View File

@ -29,3 +29,25 @@ class Coaster(models.Model):
def __str__(self): def __str__(self):
return f'{self.creator.username} / {self.challenge_id}' return f'{self.creator.username} / {self.challenge_id}'
class CoasterRating(models.Model):
coaster = models.ForeignKey(
Coaster,
on_delete=models.CASCADE,
related_name='ratings',
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='coaster_ratings',
)
rating = models.PositiveSmallIntegerField() # 15
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [('coaster', 'user')]
def __str__(self):
return f'{self.user.username}{self.coaster_id} ({self.rating}★)'

View File

@ -349,7 +349,7 @@ def build_profile_arrays(s, velocities, k, downsample_factor, B, T) -> dict:
def generate_rails( def generate_rails(
points_m: np.ndarray, points_m: np.ndarray,
rail_spacing: float = 1.5, rail_spacing: float = 0.6,
mass: float = 1000.0, mass: float = 1000.0,
initial_velocity: float = 1.0, initial_velocity: float = 1.0,
friction_coeff: float = 0.02, friction_coeff: float = 0.02,
@ -368,7 +368,7 @@ def generate_rails(
points_m : np.ndarray shape (n, 3) points_m : np.ndarray shape (n, 3)
Centreline waypoints in **metres**, local coordinate frame. Centreline waypoints in **metres**, local coordinate frame.
rail_spacing : float rail_spacing : float
Distance between the two rails in metres (default 1.5 standard gauge). Half-distance from centreline to each rail in metres (total width = 2×, default 0.6 1.2 m gauge).
mass : float mass : float
Mass of the coaster car in kg, used for friction losses. Mass of the coaster car in kg, used for friction losses.
initial_velocity : float initial_velocity : float

View File

@ -4,11 +4,32 @@ from .models import Coaster
class CoasterSerializer(serializers.ModelSerializer): class CoasterSerializer(serializers.ModelSerializer):
creator_username = serializers.ReadOnlyField(source='creator.username') creator_username = serializers.ReadOnlyField(source='creator.username')
rating_avg = serializers.SerializerMethodField()
rating_count = serializers.SerializerMethodField()
user_rating = serializers.SerializerMethodField()
class Meta: class Meta:
model = Coaster model = Coaster
fields = [ fields = [
'id', 'creator_username', 'challenge', 'name', 'id', 'creator_username', 'challenge', 'name',
'anchors', 'acceleration_strips', 'created_at', 'updated_at', 'anchors', 'acceleration_strips',
'rating_avg', 'rating_count', 'user_rating',
'created_at', 'updated_at',
] ]
read_only_fields = ['id', 'creator_username', 'created_at', 'updated_at'] read_only_fields = ['id', 'creator_username', 'created_at', 'updated_at']
def get_rating_avg(self, obj):
ratings = list(obj.ratings.values_list('rating', flat=True))
if not ratings:
return None
return round(sum(ratings) / len(ratings), 2)
def get_rating_count(self, obj):
return obj.ratings.count()
def get_user_rating(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return None
rating = obj.ratings.filter(user=request.user).first()
return rating.rating if rating else None

View File

@ -1,8 +1,16 @@
from django.urls import path from django.urls import path
from .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView from .views import (
CoasterSimulateView,
CoasterListCreateView,
CoasterDeleteView,
CoasterGlobalListView,
CoasterRateView,
)
urlpatterns = [ urlpatterns = [
path("simulate/", CoasterSimulateView.as_view()), path("simulate/", CoasterSimulateView.as_view()),
path("coasters/", CoasterGlobalListView.as_view()),
path("challenges/<uuid:challenge_id>/coasters/", CoasterListCreateView.as_view()), path("challenges/<uuid:challenge_id>/coasters/", CoasterListCreateView.as_view()),
path("<uuid:pk>/", CoasterDeleteView.as_view()), path("<uuid:pk>/", CoasterDeleteView.as_view()),
path("<uuid:pk>/rate/", CoasterRateView.as_view()),
] ]

View File

@ -93,7 +93,7 @@ def _upload_glb(glb_bytes: bytes) -> str | None:
# ── Allowed simulation params ────────────────────────────────────────────────── # ── Allowed simulation params ──────────────────────────────────────────────────
_PARAM_SCHEMA = { _PARAM_SCHEMA = {
"rail_spacing": (float, 0.5, 10.0, 1.5), "rail_spacing": (float, 0.5, 10.0, 0.6),
"mass": (float, 1.0, 50000, 1000.0), "mass": (float, 1.0, 50000, 1000.0),
"initial_velocity": (float, 0.0, 100.0, 1.0), "initial_velocity": (float, 0.0, 100.0, 1.0),
"friction_coeff": (float, 0.0, 1.0, 0.02), "friction_coeff": (float, 0.0, 1.0, 0.02),
@ -242,9 +242,10 @@ class CoasterListCreateView(APIView):
Coaster.objects Coaster.objects
.filter(challenge_id=challenge_id) .filter(challenge_id=challenge_id)
.select_related('creator') .select_related('creator')
.prefetch_related('ratings')
.order_by('-updated_at') .order_by('-updated_at')
) )
return Response(CoasterSerializer(coasters, many=True).data) return Response(CoasterSerializer(coasters, many=True, context={'request': request}).data)
def post(self, request, challenge_id): def post(self, request, challenge_id):
from .models import Coaster from .models import Coaster
@ -269,7 +270,7 @@ class CoasterListCreateView(APIView):
}, },
) )
return Response( return Response(
CoasterSerializer(coaster).data, CoasterSerializer(coaster, context={'request': request}).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
) )
@ -288,3 +289,62 @@ class CoasterDeleteView(APIView):
return Response(status=status.HTTP_403_FORBIDDEN) return Response(status=status.HTTP_403_FORBIDDEN)
coaster.delete() coaster.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class CoasterGlobalListView(APIView):
"""GET /api/v1/coaster/coasters/ — list all coasters across all challenges."""
permission_classes = [IsAuthenticated]
def get(self, request):
from .models import Coaster
from .serializers import CoasterSerializer
coasters = (
Coaster.objects
.select_related('creator')
.prefetch_related('ratings')
.order_by('-updated_at')
)
return Response(
CoasterSerializer(coasters, many=True, context={'request': request}).data
)
class CoasterRateView(APIView):
"""POST /api/v1/coaster/{pk}/rate/ — submit or update a 15 star rating."""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
from .models import Coaster, CoasterRating
from .serializers import CoasterSerializer
try:
coaster = Coaster.objects.prefetch_related('ratings').get(id=pk)
except Coaster.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
rating_value = int(request.data.get('rating', 0))
except (TypeError, ValueError):
return Response(
{'error': 'rating must be an integer 15'},
status=status.HTTP_400_BAD_REQUEST,
)
if not (1 <= rating_value <= 5):
return Response(
{'error': 'rating must be between 1 and 5'},
status=status.HTTP_400_BAD_REQUEST,
)
CoasterRating.objects.update_or_create(
coaster=coaster,
user=request.user,
defaults={'rating': rating_value},
)
# Re-fetch to get updated rating aggregates
coaster.refresh_from_db()
coaster_fresh = Coaster.objects.prefetch_related('ratings').get(id=pk)
return Response(
CoasterSerializer(coaster_fresh, context={'request': request}).data
)

View File

@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SplatMap</title> <title>RCNN</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet">
<style> <style>
/* Cesium viewer requires the host element to have explicit dimensions. /* Cesium viewer requires the host element to have explicit dimensions.
Set the entire page to full viewport with no scroll. */ Set the entire page to full viewport with no scroll. */

View File

@ -7,8 +7,13 @@ import { SplatRenderer } from './splat/SplatRenderer'
import { ChallengeLayer } from './challenges/ChallengeLayer' import { ChallengeLayer } from './challenges/ChallengeLayer'
import { ChallengePanel } from './challenges/ChallengePanel' import { ChallengePanel } from './challenges/ChallengePanel'
import { ChallengeCreator } from './challenges/ChallengeCreator' import { ChallengeCreator } from './challenges/ChallengeCreator'
import { ChallengesListPanel } from './challenges/ChallengesListPanel'
import { MyChallengesSidePanel } from './challenges/MyChallengesSidePanel'
import { MapOverlay } from './ui/MapOverlay' import { MapOverlay } from './ui/MapOverlay'
import { CoasterEditorPage } from './coaster/CoasterEditorPage' import { Header } from './ui/Header'
import { CoasterEditorPage, CoasterViewerPage } from './coaster/CoasterEditorPage'
import { AllCoastersPanel } from './coaster/AllCoastersPanel'
import { UserProfilePage } from './users/UserProfilePage'
function MapPage() { function MapPage() {
return ( return (
@ -30,10 +35,18 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Header />
{/* Global overlay panels — rendered above everything */}
<ChallengesListPanel />
<MyChallengesSidePanel />
<AllCoastersPanel />
<Routes> <Routes>
<Route path="/auth/callback" element={<CallbackPage />} /> <Route path="/auth/callback" element={<CallbackPage />} />
<Route path="/" element={<MapPage />} /> <Route path="/" element={<MapPage />} />
<Route path="/challenges/:id/coaster" element={<CoasterEditorPage />} /> <Route path="/challenges/:id/coaster" element={<CoasterEditorPage />} />
<Route path="/challenges/:challengeId/coasters/:coasterId" element={<CoasterViewerPage />} />
<Route path="/profile" element={<UserProfilePage />} />
</Routes> </Routes>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>

View File

@ -6,9 +6,36 @@ export const apiClient = axios.create({
}) })
apiClient.interceptors.request.use(async (config) => { apiClient.interceptors.request.use(async (config) => {
const user = await userManager.getUser() let user = await userManager.getUser()
if (user?.expired) {
try {
user = await userManager.signinSilent()
} catch {
await userManager.signinRedirect()
return Promise.reject(new Error('Session expired, redirecting to login'))
}
}
if (user?.access_token) { if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}` config.headers.Authorization = `Bearer ${user.access_token}`
} }
return config return config
}) })
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retried) {
error.config._retried = true
try {
const user = await userManager.signinSilent()
if (user?.access_token) {
error.config.headers.Authorization = `Bearer ${user.access_token}`
return apiClient(error.config)
}
} catch {
await userManager.signinRedirect()
}
}
return Promise.reject(error)
},
)

View File

@ -37,3 +37,13 @@ export async function saveCoaster(
export async function deleteCoaster(id: string): Promise<void> { export async function deleteCoaster(id: string): Promise<void> {
await apiClient.delete(`/coaster/${id}/`) await apiClient.delete(`/coaster/${id}/`)
} }
export async function listAllCoasters(): Promise<SavedCoaster[]> {
const res = await apiClient.get<SavedCoaster[]>('/coaster/coasters/')
return res.data
}
export async function rateCoaster(id: string, rating: number): Promise<SavedCoaster> {
const res = await apiClient.post<SavedCoaster>(`/coaster/${id}/rate/`, { rating })
return res.data
}

11
web/src/api/users.ts Normal file
View File

@ -0,0 +1,11 @@
import { apiClient } from './client'
import type { UserProfile } from '../types/api'
export async function getMyProfile(): Promise<UserProfile> {
const res = await apiClient.get<UserProfile>('/users/me/')
return res.data
}
export async function deleteMyAccount(): Promise<void> {
await apiClient.delete('/users/me/')
}

View File

@ -4,8 +4,9 @@ export const userManager = new UserManager({
authority: import.meta.env.VITE_OIDC_AUTHORITY, authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID, client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`, redirect_uri: `${window.location.origin}/auth/callback`,
silent_redirect_uri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email', scope: 'openid profile email',
response_type: 'code', response_type: 'code',
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false, automaticSilentRenew: true,
}) })

View File

@ -41,7 +41,7 @@ export function ChallengeLayer() {
lastBboxRef.current = bbox lastBboxRef.current = bbox
fetchChallenges({ bbox }).then((fc) => { fetchChallenges({ bbox }).then((fc) => {
const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => f.properties.id)) const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => String(f.id)))
entityMapRef.current.forEach((entity, id) => { entityMapRef.current.forEach((entity, id) => {
if (!incoming.has(id)) { if (!incoming.has(id)) {
@ -51,7 +51,7 @@ export function ChallengeLayer() {
}) })
fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => { fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => {
const id = feature.properties.id const id = String(feature.id)
if (entityMapRef.current.has(id)) return if (entityMapRef.current.has(id)) return
const [lon, lat] = feature.geometry.coordinates const [lon, lat] = feature.geometry.coordinates

View File

@ -0,0 +1,284 @@
.overlay {
position: fixed;
inset: 0;
z-index: 700;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
justify-content: flex-end;
}
.panel {
width: 400px;
max-width: 100%;
height: 100%;
background: rgba(8, 8, 14, 0.97);
border-left: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 68px 20px 16px; /* 52px header + 16px */
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
}
.closeBtn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.12s;
}
.closeBtn:hover { color: #fff; }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.state {
padding: 20px;
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
text-align: center;
}
.errorState {
padding: 20px;
margin: 0;
font-size: 14px;
color: #f87171;
text-align: center;
}
/* ── Row ──────────────────────────────────────────────────────────────────── */
.row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.12s;
cursor: pointer;
}
.row:hover {
background: rgba(255, 255, 255, 0.05);
}
.row:last-child {
border-bottom: none;
}
.rowMain {
flex: 1;
min-width: 0;
}
.rowTitle {
display: block;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.rowMeta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.statusBadge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 4px;
}
.active { background: rgba(34, 197, 94, 0.15); color: #4ade80; }
.closed { background: rgba(239, 68, 68, 0.15); color: #f87171; }
.metaText {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
}
.expired { color: #f87171; }
.openBtn {
flex-shrink: 0;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.openBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
/* ── Back button (detail view header) ───────────────────────────────────────── */
.backBtn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
cursor: pointer;
padding: 4px 0;
transition: color 0.12s;
flex: 1;
text-align: left;
}
.backBtn:hover { color: #fff; }
/* ── Detail view ─────────────────────────────────────────────────────────────── */
.detailContent {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.detailHeader {
display: flex;
align-items: flex-start;
gap: 10px;
}
.detailTitle {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #fff;
flex: 1;
line-height: 1.3;
}
.detailDesc {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.55;
}
.detailMeta {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 0;
font-size: 13px;
}
.detailMeta dt {
color: rgba(255, 255, 255, 0.35);
font-weight: 500;
}
.detailMeta dd {
margin: 0;
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
}
.detailActions {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 4px;
}
.centerBtn {
width: 100%;
padding: 10px;
border-radius: 9px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.75);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.centerBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.coasterBtn {
width: 100%;
padding: 10px;
border-radius: 9px;
border: 1px solid rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s;
}
.coasterBtn:hover {
background: rgba(245, 158, 11, 0.2);
}
.acceptBtn {
width: 100%;
padding: 10px;
border-radius: 9px;
border: 1px solid rgba(74, 222, 128, 0.35);
background: rgba(74, 222, 128, 0.1);
color: #4ade80;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s;
}
.acceptBtn:hover {
background: rgba(74, 222, 128, 0.2);
}
.joinedNote {
margin: 0;
font-size: 13px;
color: #4ade80;
text-align: center;
padding: 8px 0;
}

View File

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { fetchChallenges, fetchChallengeDetail, participateInChallenge } from '../api/challenges'
import { useChallengeStore } from '../store/challengeStore'
import { useUIStore } from '../store/uiStore'
import type { ChallengeMapProperties, ChallengeDetail } from '../types/api'
import styles from './ChallengesListPanel.module.css'
export function ChallengesListPanel() {
const { showAllChallenges, setShowAllChallenges } = useUIStore()
const { setSelectedChallengeId } = useChallengeStore()
const navigate = useNavigate()
const location = useLocation()
const [challenges, setChallenges] = useState<GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Detail drill-in state
const [selectedId, setSelectedId] = useState<string | null>(null)
const [detail, setDetail] = useState<ChallengeDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [participating, setParticipating] = useState(false)
useEffect(() => {
if (!showAllChallenges) return
setLoading(true)
setError(null)
fetchChallenges()
.then((fc) => setChallenges(fc.features))
.catch(() => setError('Failed to load challenges.'))
.finally(() => setLoading(false))
}, [showAllChallenges])
// Reset detail when panel closes
useEffect(() => {
if (!showAllChallenges) {
setSelectedId(null)
setDetail(null)
}
}, [showAllChallenges])
if (!showAllChallenges) return null
function handleSelect(id: string) {
setSelectedId(id)
setDetail(null)
setDetailLoading(true)
fetchChallengeDetail(id)
.then((d) => {
setDetail(d)
setParticipating(d.is_participating)
})
.catch(() => setDetail(null))
.finally(() => setDetailLoading(false))
}
function handleCenterMap(id: string) {
setSelectedChallengeId(id)
setShowAllChallenges(false)
if (location.pathname !== '/') navigate('/')
}
async function handleAccept(id: string) {
await participateInChallenge(id)
setParticipating(true)
setDetail((prev) => prev ? { ...prev, is_participating: true } : prev)
}
function handlePlanCoaster(id: string) {
setShowAllChallenges(false)
navigate(`/challenges/${id}/coaster`)
}
// ── Detail view ──────────────────────────────────────────────────────────────
if (selectedId) {
return (
<div className={styles.overlay} onClick={() => setShowAllChallenges(false)}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<button className={styles.backBtn} onClick={() => { setSelectedId(null); setDetail(null) }}>
Back
</button>
<button className={styles.closeBtn} onClick={() => setShowAllChallenges(false)}></button>
</div>
<div className={styles.body}>
{detailLoading && <p className={styles.state}>Loading</p>}
{!detailLoading && !detail && <p className={styles.errorState}>Failed to load challenge.</p>}
{detail && (
<div className={styles.detailContent}>
<div className={styles.detailHeader}>
<h3 className={styles.detailTitle}>{detail.title}</h3>
<span className={`${styles.statusBadge} ${detail.status === 'active' ? styles.active : styles.closed}`}>
{detail.status}
</span>
</div>
{detail.description && (
<p className={styles.detailDesc}>{detail.description}</p>
)}
<dl className={styles.detailMeta}>
<dt>Coasters</dt>
<dd>{detail.coaster_count}</dd>
<dt>Submissions</dt>
<dd>{detail.submission_count}{detail.max_submissions ? ` / ${detail.max_submissions}` : ''}</dd>
<dt>Participants</dt>
<dd>{detail.participant_count}</dd>
{detail.expires_at && (
<>
<dt>Expires</dt>
<dd className={new Date(detail.expires_at) < new Date() ? styles.expired : ''}>
{new Date(detail.expires_at) < new Date()
? 'Expired'
: new Date(detail.expires_at).toLocaleDateString()}
</dd>
</>
)}
</dl>
<div className={styles.detailActions}>
<button className={styles.centerBtn} onClick={() => handleCenterMap(detail.id)}>
Center map
</button>
{detail.status === 'active' && (
<button className={styles.coasterBtn} onClick={() => handlePlanCoaster(detail.id)}>
Plan coaster
</button>
)}
{detail.status === 'active' && !participating && (
<button className={styles.acceptBtn} onClick={() => handleAccept(detail.id)}>
Accept challenge
</button>
)}
{participating && (
<p className={styles.joinedNote}>You've accepted this challenge</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
// ── List view ────────────────────────────────────────────────────────────────
return (
<div className={styles.overlay} onClick={() => setShowAllChallenges(false)}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<h2 className={styles.title}>All Challenges</h2>
<button className={styles.closeBtn} onClick={() => setShowAllChallenges(false)}></button>
</div>
<div className={styles.body}>
{loading && <p className={styles.state}>Loading</p>}
{error && <p className={styles.errorState}>{error}</p>}
{!loading && !error && challenges.length === 0 && (
<p className={styles.state}>No challenges found.</p>
)}
{challenges.map((f) => {
const c = f.properties
const id = String(f.id)
const expired = c.expires_at ? new Date(c.expires_at) < new Date() : false
return (
<div key={id} className={styles.row} onClick={() => handleSelect(id)}>
<span className={styles.rowTitle}>{c.title}</span>
<div className={styles.rowMeta}>
<span className={`${styles.statusBadge} ${c.status === 'active' ? styles.active : styles.closed}`}>
{c.status}
</span>
<span className={styles.metaText}>
{c.coaster_count} coaster{c.coaster_count !== 1 ? 's' : ''}
</span>
<span className={styles.metaText}>
{c.submission_count}{c.max_submissions ? `/${c.max_submissions}` : ''} submissions
</span>
{c.expires_at && (
<span className={`${styles.metaText} ${expired ? styles.expired : ''}`}>
{expired ? 'Expired' : `Expires ${new Date(c.expires_at).toLocaleDateString()}`}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
.panel { .panel {
position: fixed; position: fixed;
top: 16px; top: 68px; /* 52px header + 16px gap */
left: 140px; left: 140px;
z-index: 20; z-index: 20;
width: 300px; width: 300px;

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { fetchMyChallenges } from '../api/challenges'
import { useChallengeStore } from '../store/challengeStore'
import { useUIStore } from '../store/uiStore'
import type { ChallengeDetail } from '../types/api'
import styles from './ChallengesListPanel.module.css'
/** Full-page side panel for My Challenges — rendered outside CesiumViewer. */
export function MyChallengesSidePanel() {
const { showMyChallenges, setShowMyChallenges } = useUIStore()
const { setSelectedChallengeId } = useChallengeStore()
const navigate = useNavigate()
const location = useLocation()
const [challenges, setChallenges] = useState<ChallengeDetail[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!showMyChallenges) return
setLoading(true)
setError(null)
fetchMyChallenges()
.then(setChallenges)
.catch(() => setError('Failed to load your challenges.'))
.finally(() => setLoading(false))
}, [showMyChallenges])
if (!showMyChallenges) return null
function handleSelect(id: string) {
setSelectedChallengeId(id)
setShowMyChallenges(false)
if (location.pathname !== '/') navigate('/')
}
return (
<div className={styles.overlay} onClick={() => setShowMyChallenges(false)}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<h2 className={styles.title}>My Challenges</h2>
<button className={styles.closeBtn} onClick={() => setShowMyChallenges(false)}></button>
</div>
<div className={styles.body}>
{loading && <p className={styles.state}>Loading</p>}
{error && <p className={styles.errorState}>{error}</p>}
{!loading && !error && challenges.length === 0 && (
<p className={styles.state}>You haven't created any challenges yet.</p>
)}
{challenges.map((c) => (
<div key={c.id} className={styles.row} onClick={() => handleSelect(c.id)}>
<span className={styles.rowTitle}>{c.title}</span>
<div className={styles.rowMeta}>
<span className={`${styles.statusBadge} ${c.status === 'active' ? styles.active : styles.closed}`}>
{c.status}
</span>
<span className={styles.metaText}>
{c.submission_count} submission{c.submission_count !== 1 ? 's' : ''}
</span>
{c.expires_at && (
<span className={styles.metaText}>
{new Date(c.expires_at) < new Date() ? 'Expired' : `Expires ${new Date(c.expires_at).toLocaleDateString()}`}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,159 @@
.overlay {
position: fixed;
inset: 0;
z-index: 700;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
justify-content: flex-end;
}
.panel {
width: 400px;
max-width: 100%;
height: 100%;
background: rgba(8, 8, 14, 0.97);
border-left: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 68px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
}
.closeBtn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.12s;
}
.closeBtn:hover { color: #fff; }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.state {
padding: 20px;
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
text-align: center;
}
.errorState {
padding: 20px;
margin: 0;
font-size: 13px;
color: rgba(248, 113, 113, 0.8);
text-align: center;
line-height: 1.5;
}
/* ── Row ──────────────────────────────────────────────────────────────────── */
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.12s;
}
.row:hover {
background: rgba(255, 255, 255, 0.03);
}
.row:last-child {
border-bottom: none;
}
.rowMain {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowTop {
display: flex;
align-items: center;
gap: 8px;
}
.rowName {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.youBadge {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 4px;
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.creator {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
}
.ratingRow {
display: flex;
align-items: center;
}
.viewBtn {
flex-shrink: 0;
padding: 6px 14px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 7px;
color: #f59e0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s;
}
.viewBtn:hover {
background: rgba(245, 158, 11, 0.18);
}

View File

@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listAllCoasters, rateCoaster } from '../api/coaster'
import { useUIStore } from '../store/uiStore'
import { useAuthStore } from '../store/authStore'
import { StarRating } from '../ui/StarRating'
import type { SavedCoaster } from '../types/api'
import styles from './AllCoastersPanel.module.css'
export function AllCoastersPanel() {
const { showAllCoasters, setShowAllCoasters } = useUIStore()
const { user } = useAuthStore()
const navigate = useNavigate()
const [coasters, setCoasters] = useState<SavedCoaster[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentUsername = (user?.profile?.preferred_username as string | undefined)
useEffect(() => {
if (!showAllCoasters) return
setLoading(true)
setError(null)
listAllCoasters()
.then(setCoasters)
.catch(() => setError('Failed to load coasters. The global coaster list endpoint may not be available yet.'))
.finally(() => setLoading(false))
}, [showAllCoasters])
if (!showAllCoasters) return null
async function handleRate(id: string, rating: number) {
try {
const updated = await rateCoaster(id, rating)
setCoasters((prev) => prev.map((c) => (c.id === id ? updated : c)))
} catch {
// silently fail — backend endpoint may not be wired yet
}
}
function handleView(coaster: SavedCoaster) {
setShowAllCoasters(false)
navigate(`/challenges/${coaster.challenge}/coasters/${coaster.id}`)
}
return (
<div className={styles.overlay} onClick={() => setShowAllCoasters(false)}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<h2 className={styles.title}>All Coasters</h2>
<button className={styles.closeBtn} onClick={() => setShowAllCoasters(false)}></button>
</div>
<div className={styles.body}>
{loading && <p className={styles.state}>Loading</p>}
{error && <p className={styles.errorState}>{error}</p>}
{!loading && !error && coasters.length === 0 && (
<p className={styles.state}>No coasters found.</p>
)}
{coasters.map((c) => {
const isOwn = c.creator_username === currentUsername
return (
<div key={c.id} className={styles.row}>
<div className={styles.rowMain}>
<div className={styles.rowTop}>
<span className={styles.rowName}>{c.name || 'Unnamed coaster'}</span>
{isOwn && <span className={styles.youBadge}>you</span>}
</div>
<span className={styles.creator}>@{c.creator_username}</span>
<div className={styles.ratingRow}>
<StarRating
value={c.rating_avg}
userRating={c.user_rating}
count={c.rating_count}
onRate={(r) => handleRate(c.id, r)}
size="sm"
/>
</div>
</div>
<button className={styles.viewBtn} onClick={() => handleView(c)}>
View
</button>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -2,7 +2,7 @@
.topBar { .topBar {
position: fixed; position: fixed;
top: 0; top: 52px; /* below global header */
left: 0; left: 0;
right: 0; right: 0;
z-index: 200; z-index: 200;
@ -36,6 +36,27 @@
color: #fff; color: #fff;
} }
.nameInput {
width: 200px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 6px 12px;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.nameInput::placeholder {
color: rgba(255, 255, 255, 0.28);
}
.nameInput:focus {
border-color: rgba(245, 158, 11, 0.5);
background: rgba(255, 255, 255, 0.1);
}
.title { .title {
flex: 1; flex: 1;
margin: 0; margin: 0;

View File

@ -4,7 +4,7 @@ import * as Cesium from 'cesium'
import { CesiumViewer } from '../cesium/CesiumViewer' import { CesiumViewer } from '../cesium/CesiumViewer'
import { useCesiumViewer } from '../cesium/cesiumContext' import { useCesiumViewer } from '../cesium/cesiumContext'
import { fetchChallengeDetail } from '../api/challenges' import { fetchChallengeDetail } from '../api/challenges'
import { simulateCoaster, saveCoaster } from '../api/coaster' import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster'
import { useCoasterPath } from './useCoasterPath' import { useCoasterPath } from './useCoasterPath'
import { useAccelerationStrips } from './useAccelerationStrips' import { useAccelerationStrips } from './useAccelerationStrips'
import { useTerrainCapture } from './useTerrainCapture' import { useTerrainCapture } from './useTerrainCapture'
@ -17,7 +17,7 @@ import { useAuthStore } from '../store/authStore'
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api' import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
import styles from './CoasterEditorPage.module.css' import styles from './CoasterEditorPage.module.css'
// ── Route page ─────────────────────────────────────────────────────────────── // ── Route pages ───────────────────────────────────────────────────────────────
export function CoasterEditorPage() { export function CoasterEditorPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@ -35,6 +35,34 @@ export function CoasterEditorPage() {
) )
} }
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 (
<CesiumViewer>
<CoasterEditorScene
challengeId={challengeId}
challenge={challenge}
readonly
preloadCoaster={preloadCoaster ?? undefined}
/>
</CesiumViewer>
)
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a circle cross-section shape for PolylineVolumeGraphics. */ /** Build a circle cross-section shape for PolylineVolumeGraphics. */
@ -47,22 +75,25 @@ function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] {
return pts return pts
} }
const RAIL_SHAPE = buildCircleShape(0.35, 8) // 35 cm radius tube const RAIL_SHAPE = buildCircleShape(0.075, 8) // 7.5 cm radius = 15 cm diameter
// ── Inner scene (needs viewer context) ──────────────────────────────────────── // ── Inner scene (needs viewer context) ────────────────────────────────────────
interface SceneProps { interface SceneProps {
challengeId: string | undefined challengeId: string | undefined
challenge: ChallengeDetail | null challenge: ChallengeDetail | null
readonly?: boolean
preloadCoaster?: SavedCoaster
} }
function CoasterEditorScene({ challengeId, challenge }: SceneProps) { function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) {
const viewer = useCesiumViewer() const viewer = useCesiumViewer()
const navigate = useNavigate() const navigate = useNavigate()
const authUser = useAuthStore(s => s.user) const authUser = useAuthStore(s => s.user)
const currentUsername = authUser?.profile?.preferred_username as string | undefined const currentUsername = authUser?.profile?.preferred_username as string | undefined
const [coasterName, setCoasterName] = useState('')
const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null) const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null)
const [simulating, setSimulating] = useState(false) const [simulating, setSimulating] = useState(false)
const [simError, setSimError] = useState<string | null>(null) const [simError, setSimError] = useState<string | null>(null)
@ -78,9 +109,21 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
const terrain = useTerrainCapture(viewer, simResult) const terrain = useTerrainCapture(viewer, simResult)
// Auto-load a preloaded coaster (viewer mode)
useEffect(() => {
if (preloadCoaster) handleLoad(preloadCoaster)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [preloadCoaster])
// Exit ride mode whenever the sim result changes; clear cursor on exit // Exit ride mode whenever the sim result changes; clear cursor on exit
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) 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.
useEffect(() => {
viewer.useDefaultRenderLoop = !isRideMode
}, [isRideMode, viewer])
// Refs for simulation result entities (cleared on each new run / unmount) // Refs for simulation result entities (cleared on each new run / unmount)
const simEntitiesRef = useRef<Cesium.Entity[]>([]) const simEntitiesRef = useRef<Cesium.Entity[]>([])
const simPrimitivesRef = useRef<Cesium.Primitive[]>([]) const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
@ -203,6 +246,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
})) }))
path.loadAnchors(anchorPoints) path.loadAnchors(anchorPoints)
accel.loadStrips(coaster.acceleration_strips) accel.loadStrips(coaster.acceleration_strips)
setCoasterName(coaster.name || '')
setSimResult(null) setSimResult(null)
} }
@ -219,6 +263,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
} }
}) })
await saveCoaster(challengeId, { await saveCoaster(challengeId, {
name: coasterName.trim(),
anchors: storedAnchors, anchors: storedAnchors,
acceleration_strips: accel.strips, acceleration_strips: accel.strips,
}) })
@ -246,6 +291,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
carto.height, carto.height,
] as [number, number, number] ] as [number, number, number]
}) })
const result = await simulateCoaster({ const result = await simulateCoaster({
path: geoPath, path: geoPath,
params: { initial_velocity: initialVelocity }, params: { initial_velocity: initialVelocity },
@ -284,38 +330,48 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
{/* ── Top bar ─────────────────────────────────────────────────────── */} {/* ── Top bar ─────────────────────────────────────────────────────── */}
<div className={styles.topBar}> <div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => navigate('/')}> <button className={styles.backBtn} onClick={() => navigate(-1)}>
Map Back
</button> </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}> <h1 className={styles.title}>
{challenge ? challenge.title : 'Loading…'} {challenge ? challenge.title : 'Loading…'}
</h1> </h1>
<span className={styles.badge}>Coaster Editor</span> <span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span>
</div> </div>
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */} {/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
{!isRideMode && ( {!isRideMode && (
<div className={styles.toolbar}> <div className={styles.toolbar}>
{!readonly && (
<>
<ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} /> <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="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} /> <ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} />
<div className={styles.divider} /> <div className={styles.divider} />
<button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}> <button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}>
Undo Undo
</button> </button>
<button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}> <button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}>
Clear Clear
</button> </button>
{path.anchors.length > 0 && ( {path.anchors.length > 0 && (
<span className={styles.countBadge}> <span className={styles.countBadge}>
{path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''}
</span> </span>
)} )}
<div className={styles.divider} /> <div className={styles.divider} />
</>
)}
<button <button
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`} className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
@ -344,6 +400,8 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
> >
Strips Strips
</button> </button>
{!readonly && (
<button <button
className={styles.ghostBtn} className={styles.ghostBtn}
onClick={handleSave} onClick={handleSave}
@ -351,6 +409,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
> >
Save Save
</button> </button>
)}
{simResult && ( {simResult && (
<> <>
@ -402,7 +461,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
)} )}
{/* ── Selected-point panel ─────────────────────────────────────────── */} {/* ── Selected-point panel ─────────────────────────────────────────── */}
{selected && ( {selected && !readonly && (
<div className={styles.selectedPanel}> <div className={styles.selectedPanel}>
<p className={styles.panelHeading}> <p className={styles.panelHeading}>
Point {selectedIndex + 1} of {path.anchors.length} Point {selectedIndex + 1} of {path.anchors.length}

View File

@ -1,7 +1,7 @@
.panel { .panel {
position: fixed; position: fixed;
right: 16px; right: 16px;
top: 60px; top: 112px; /* 52px header + ~44px topBar + 16px gap */
z-index: 200; z-index: 200;
width: 240px; width: 240px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
@ -56,9 +56,26 @@
border-bottom: none; border-bottom: none;
} }
.username { .rowInfo {
flex: 1; flex: 1;
color: rgba(255, 255, 255, 0.55); display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.rowName {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.username {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@ -39,9 +39,12 @@ export function CoasterListPanel({ challengeId, currentUsername, onLoad, refresh
const isOwn = c.creator_username === currentUsername const isOwn = c.creator_username === currentUsername
return ( return (
<div key={c.id} className={styles.row}> <div key={c.id} className={styles.row}>
<div className={styles.rowInfo}>
<span className={styles.rowName}>{c.name || 'Unnamed coaster'}</span>
<span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}> <span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}>
@{c.creator_username} @{c.creator_username}
</span> </span>
</div>
<button className={styles.loadBtn} onClick={() => onLoad(c)}> <button className={styles.loadBtn} onClick={() => onLoad(c)}>
Load Load
</button> </button>

View File

@ -4,13 +4,29 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 500; z-index: 500;
/* pointer events pass through — the ride control bar handles interaction */ /* control bar is z-index 501 (above canvas 500) so it handles its own clicks */
pointer-events: none; pointer-events: auto;
display: block; display: block;
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
/* ── FPS counter ──────────────────────────────────────────────────────────────── */
.fpsCounter {
position: fixed;
top: 10px;
left: 10px;
z-index: 502;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #4ade80;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
pointer-events: none;
min-width: 56px;
}
/* ── Ride control bar (same visual design as CoasterEditorPage rideBar) ─────── */ /* ── Ride control bar (same visual design as CoasterEditorPage rideBar) ─────── */
.rideBar { .rideBar {

View File

@ -12,7 +12,7 @@ type CameraMode = 'first' | 'third'
interface Props { interface Props {
simResult: CoasterSimulationResult simResult: CoasterSimulationResult
captureData: TerrainCaptureData captureData: TerrainCaptureData[]
onStop: () => void onStop: () => void
/** Called at ~4 Hz with the current track-fraction position [0,1]. */ /** Called at ~4 Hz with the current track-fraction position [0,1]. */
onRideProgress?: (sFrac: number) => void onRideProgress?: (sFrac: number) => void
@ -75,19 +75,32 @@ function buildRideData(simResult: CoasterSimulationResult): RideData {
totalDuration: timeArray[n - 1], count: n } totalDuration: timeArray[n - 1], count: n }
} }
// ── Camera target computation ───────────────────────────────────────────────── // ── Camera target computation (zero allocation per call) ──────────────────────
//
interface CameraTarget { // All scratch objects are pre-allocated at module level so the hot rAF path
pos: THREE.Vector3 // never triggers the garbage collector.
quat: THREE.Quaternion
}
const _mat = new THREE.Matrix4() const _mat = new THREE.Matrix4()
// PerspectiveCamera.lookAt uses camera convention: Z axis toward target. const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → Z toward target
// A plain Object3D.lookAt points +Z toward target (opposite), which would
// make the camera look backward.
const _dummy = new THREE.PerspectiveCamera()
// 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( function computeCameraTarget(
t: number, t: number,
data: RideData, data: RideData,
@ -102,73 +115,50 @@ function computeCameraTarget(
? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i]) ? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i])
: 0 : 0
const pos = centerline[i].clone().lerp(centerline[i1], alpha) _scPos.lerpVectors(centerline[i], centerline[i1], alpha)
const r1i = rail1[i].clone().lerp(rail1[i1], alpha) _scR1.lerpVectors(rail1[i], rail1[i1], alpha)
const r2i = rail2[i].clone().lerp(rail2[i1], alpha) _scR2.lerpVectors(rail2[i], rail2[i1], alpha)
// Wider stencil for smoother forward tangent // Wider stencil for smoother forward tangent
const pi = Math.max(0, i - 2) const pi = Math.max(0, i - 2)
const pj = Math.min(count - 1, i1 + 2) const pj = Math.min(count - 1, i1 + 2)
const tang = centerline[pj].clone().sub(centerline[pi]).normalize() _scTang.subVectors(centerline[pj], centerline[pi]).normalize()
// Track-local "up": cross(tangent, rail1→rail2) gives the binormal // Track-local "up": cross(tangent, rail1→rail2) → binormal
const side = r1i.clone().sub(r2i).normalize() _scSide.subVectors(_scR1, _scR2).normalize()
const trackUp = tang.clone().cross(side).normalize() _scTUp.crossVectors(_scTang, _scSide).normalize()
if (trackUp.dot(UP) < 0) trackUp.negate() if (_scTUp.dot(UP) < 0) _scTUp.negate()
let camPos: THREE.Vector3
let lookTarget: THREE.Vector3
let upVec: THREE.Vector3
if (mode === 'first') { if (mode === 'first') {
// Sit 1.5 m above centreline, look 10 m ahead from the seat // Sit 1.5 m above centreline, look 10 m ahead
camPos = pos.clone().addScaledVector(UP, 1.5) _outPos.copy(_scPos).addScaledVector(UP, 1.5)
lookTarget = camPos.clone().addScaledVector(tang, 10) _scLook.copy(_outPos).addScaledVector(_scTang, 10)
upVec = trackUp _dummy.up.copy(_scTUp)
} else { } else {
// 40 m behind + 12 m above, looking at the car // 40 m behind + 12 m above, looking at the car
camPos = pos.clone() _outPos.copy(_scPos).addScaledVector(_scTang, -40).addScaledVector(UP, 12)
.addScaledVector(tang, -40) _scLook.copy(_scPos)
.addScaledVector(UP, 12) _dummy.up.copy(UP)
lookTarget = pos
upVec = UP
} }
// Build quaternion from look-at matrix (avoids gimbal lock and is slerp-able) _dummy.position.copy(_outPos)
_dummy.position.copy(camPos) _dummy.lookAt(_scLook)
_dummy.up.copy(upVec)
_dummy.lookAt(lookTarget)
_dummy.updateMatrixWorld(true) _dummy.updateMatrixWorld(true)
// _dummy.matrixWorld = translation * rotation; extract rotation quaternion
_mat.extractRotation(_dummy.matrixWorld) _mat.extractRotation(_dummy.matrixWorld)
const quat = new THREE.Quaternion().setFromRotationMatrix(_mat) _outQuat.setFromRotationMatrix(_mat)
return { pos: camPos, quat } return { pos: _outPos, quat: _outQuat }
} }
// ── Three.js scene builder ───────────────────────────────────────────────────── // ── Terrain mesh builder (one per patch) ──────────────────────────────────────
function buildScene(captureData: TerrainCaptureData, rideData: RideData) { function buildTerrainMesh(
const scene = new THREE.Scene() patch: TerrainCaptureData,
scene.background = new THREE.Color(0x87ceeb) renderer: THREE.WebGLRenderer,
visible: boolean,
// Lighting ): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
scene.add(new THREE.AmbientLight(0xffffff, 0.7)) const GRID = patch.gridSize
const sun = new THREE.DirectionalLight(0xfffbe6, 0.85) const verts = patch.terrainVertices
sun.position.set(200, 500, -300)
scene.add(sun)
const geos: THREE.BufferGeometry[] = []
const mats: THREE.Material[] = []
const texes: THREE.Texture[] = []
// ── Terrain mesh ────────────────────────────────────────────────────────────
const GRID = captureData.gridSize // 64
const verts = captureData.terrainVertices // GRID×GRID, row-major: idx = j*GRID+i
// j=0 → south (lat = tileBbox[1])
// j=GRID-1 → north (lat = tileBbox[3])
// i=0 → west (lon = tileBbox[0])
// i=GRID-1 → east (lon = tileBbox[2])
const posArr = new Float32Array(GRID * GRID * 3) const posArr = new Float32Array(GRID * GRID * 3)
const uvArr = new Float32Array(GRID * GRID * 2) const uvArr = new Float32Array(GRID * GRID * 2)
@ -178,63 +168,79 @@ function buildScene(captureData: TerrainCaptureData, rideData: RideData) {
const idx = j * GRID + i const idx = j * GRID + i
const v = verts[idx] const v = verts[idx]
posArr[idx * 3] = v.x posArr[idx * 3] = v.x
posArr[idx * 3 + 1] = v.y - 0.5 // shift terrain 0.5 m down so tracks at height 0 sit above it posArr[idx * 3 + 1] = v.y - 0.5
posArr[idx * 3 + 2] = v.z posArr[idx * 3 + 2] = v.z
uvArr[idx * 2] = i / (GRID - 1)
// The stitched canvas has north at pixel row 0 (tj=0 = northernmost tile). uvArr[idx * 2 + 1] = j / (GRID - 1)
// THREE.CanvasTexture has flipY=true by default, which means:
// texture V=0 → canvas bottom row → south latitude
// texture V=1 → canvas top row → north latitude
// So: south (j=0) → V=0, north (j=GRID-1) → V=1
uvArr[idx * 2] = i / (GRID - 1) // U: west→east = 0→1
uvArr[idx * 2 + 1] = j / (GRID - 1) // V: south→north = 0→1 (flipY corrects canvas)
} }
} }
// Indices — winding must produce upward normals (+Y) when viewed from above.
// Quad corners: a=SW, b=SE, c=NE, d=NW (j+1=north, i+1=east)
// CCW from above: a→b→d and b→c→d
const idxArr: number[] = [] const idxArr: number[] = []
for (let j = 0; j < GRID - 1; j++) { for (let j = 0; j < GRID - 1; j++) {
for (let i = 0; i < GRID - 1; i++) { for (let i = 0; i < GRID - 1; i++) {
const a = j * GRID + i // SW const a = j * GRID + i, b = j * GRID + i + 1
const b = j * GRID + i + 1 // SE const c = (j + 1) * GRID + i + 1, d = (j + 1) * GRID + i
const c = (j + 1) * GRID + i + 1 // NE idxArr.push(a, b, d, b, c, d)
const d = (j + 1) * GRID + i // NW
idxArr.push(a, b, d) // SW→SE→NW (CCW from +Y)
idxArr.push(b, c, d) // SE→NE→NW (CCW from +Y)
} }
} }
const terrainGeo = new THREE.BufferGeometry() const geo = new THREE.BufferGeometry()
terrainGeo.setAttribute('position', new THREE.BufferAttribute(posArr, 3)) geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3))
terrainGeo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2)) geo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2))
terrainGeo.setIndex(idxArr) geo.setIndex(idxArr)
terrainGeo.computeVertexNormals() geo.computeVertexNormals()
geos.push(terrainGeo)
// Draw ImageBitmap onto a real HTMLCanvasElement so CanvasTexture works reliably const tex = new THREE.Texture(patch.imageBitmap)
const texCanvas = document.createElement('canvas') tex.colorSpace = THREE.SRGBColorSpace
texCanvas.width = captureData.imageBitmap.width tex.anisotropy = renderer.capabilities.getMaxAnisotropy()
texCanvas.height = captureData.imageBitmap.height tex.minFilter = THREE.LinearMipmapLinearFilter
texCanvas.getContext('2d')!.drawImage(captureData.imageBitmap, 0, 0) tex.magFilter = THREE.LinearFilter
tex.needsUpdate = true
const terrainTex = new THREE.CanvasTexture(texCanvas) const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
terrainTex.colorSpace = THREE.SRGBColorSpace const mesh = new THREE.Mesh(geo, mat)
texes.push(terrainTex) mesh.visible = visible
return { mesh, geo, mat, tex }
}
const terrainMat = new THREE.MeshLambertMaterial({ map: terrainTex, side: THREE.FrontSide }) // ── Three.js scene builder ─────────────────────────────────────────────────────
mats.push(terrainMat)
scene.add(new THREE.Mesh(terrainGeo, terrainMat))
// ── Coaster rails ─────────────────────────────────────────────────────────── 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[]) { function addRail(pts: THREE.Vector3[]) {
// Decimate: CatmullRomCurve3 doesn't need every sim point const step = Math.max(1, Math.floor(pts.length / 200))
const step = Math.max(1, Math.floor(pts.length / 500))
const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1) const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
const curve = new THREE.CatmullRomCurve3(dpts) const curve = new THREE.CatmullRomCurve3(dpts)
// TubeGeometry radius: 0.25 m (matches Cesium's ~35 cm shape, slightly smaller for 3D) const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 3, 600), 0.075, 6, false)
const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 4, 2000), 0.25, 10, false)
const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 }) const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 })
geos.push(geo) geos.push(geo)
mats.push(mat) mats.push(mat)
@ -245,6 +251,7 @@ function buildScene(captureData: TerrainCaptureData, rideData: RideData) {
return { return {
scene, scene,
setActivePatch,
disposeAll: () => { disposeAll: () => {
geos.forEach(g => g.dispose()) geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose()) mats.forEach(m => m.dispose())
@ -261,45 +268,64 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000)) const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000))
const sceneRef = useRef<THREE.Scene | null>(null) const sceneRef = useRef<THREE.Scene | null>(null)
const rafRef = useRef<number | null>(null) const rafRef = useRef<number | null>(null)
const fpsElRef = useRef<HTMLDivElement | null>(null)
// Ride playback refs (mutable, used inside rAF loop) // Ride playback refs (mutable, used inside rAF loop)
const rideDataRef = useRef<RideData | null>(null) const rideDataRef = useRef<RideData | null>(null)
const isPlayingRef = useRef(false) const isPlayingRef = useRef(false)
const rideTimeRef = useRef(0) const rideTimeRef = useRef(0)
const startWallRef = useRef(0) const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz)
const lastUiUpdateRef = useRef(-1) const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive)
const cameraModeRef = useRef<CameraMode>('third') const cameraModeRef = useRef<CameraMode>('first')
const prevWallRef = useRef(0) // for frame deltaTime const prevWallRef = useRef(0)
// Smooth camera state (lerped/slerped each frame) // 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 smoothPosRef = useRef(new THREE.Vector3())
const smoothQuatRef = useRef(new THREE.Quaternion()) const smoothQuatRef = useRef(new THREE.Quaternion())
const needsInitRef = useRef(true) // skip lerp on first frame after play 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) // React state (updated ~4 Hz)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [rideTime, setRideTime] = useState(0) const [rideTime, setRideTime] = useState(0)
const [totalDuration, setTotalDuration] = useState(0) const [totalDuration, setTotalDuration] = useState(0)
const [cameraMode, setCameraMode] = useState<CameraMode>('third') const [cameraMode, setCameraMode] = useState<CameraMode>('first')
useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode]) useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode])
// ── Build Three.js scene ────────────────────────────────────────────────── // ── Build Three.js scene ────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) // antialias: false — significant GPU cost saving; no pixelRatio scaling on retina
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) const renderer = new THREE.WebGLRenderer({ canvas, antialias: false })
renderer.setPixelRatio(1)
rendererRef.current = renderer rendererRef.current = renderer
const rideData = buildRideData(simResult) const rideData = buildRideData(simResult)
rideDataRef.current = rideData rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration) setTotalDuration(rideData.totalDuration)
const { scene, disposeAll } = buildScene(captureData, rideData) const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene sceneRef.current = scene
setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0
function onResize() { function onResize() {
const w = window.innerWidth, h = window.innerHeight const w = window.innerWidth, h = window.innerHeight
@ -310,9 +336,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
onResize() onResize()
// Position camera at track start looking forward
if (rideData.count > 1) { if (rideData.count > 1) {
const t0 = computeCameraTarget(0, rideData, 'third') const t0 = computeCameraTarget(0, rideData, 'first')
smoothPosRef.current.copy(t0.pos) smoothPosRef.current.copy(t0.pos)
smoothQuatRef.current.copy(t0.quat) smoothQuatRef.current.copy(t0.quat)
cameraRef.current.position.copy(t0.pos) cameraRef.current.position.copy(t0.pos)
@ -332,11 +357,11 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
} }
}, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps }, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps
// ── rAF render loop ─────────────────────────────────────────────────────── // ── rAF render loop ──────────────────────────────────────────────────────────
// Smoothing time constants (seconds) — larger = smoother / more lag // Smoothing time constants (seconds)
const POS_TAU = 0.20 // position lag const POS_TAU = 0.05
const ROT_TAU = 0.35 // rotation lag const ROT_TAU = 0.08
function startLoop() { function startLoop() {
function tick(wallMs: number) { function tick(wallMs: number) {
@ -347,19 +372,34 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1) const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1)
prevWallRef.current = wallMs 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 let rideT = rideTimeRef.current
if (isPlayingRef.current) { if (isPlayingRef.current) {
rideT = (wallMs - startWallRef.current) / 1000 rideTimeRef.current += dt
rideTimeRef.current = rideT rideT = rideTimeRef.current
if (rideT - lastUiUpdateRef.current > 0.25) { // Ride time display: ~4 Hz (cheap — only updates RideRenderer itself)
if (rideT - lastTimeUpdateRef.current > 0.25) {
setRideTime(rideT) setRideTime(rideT)
lastUiUpdateRef.current = rideT lastTimeUpdateRef.current = rideT
}
// Emit current s_frac for the plot cursor // 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 const d = rideDataRef.current
if (d && onRideProgress) { if (d) {
const ci = bisect(d.timeArray, rideT) const ci = bisect(d.timeArray, rideT)
const ci1 = Math.min(ci + 1, d.count - 1) const ci1 = Math.min(ci + 1, d.count - 1)
const ca = (d.timeArray[ci1] > d.timeArray[ci]) const ca = (d.timeArray[ci1] > d.timeArray[ci])
@ -371,21 +411,54 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
const data = rideDataRef.current const data = rideDataRef.current
if (data && rideT >= data.totalDuration) { if (data && rideT >= data.totalDuration) {
rideTimeRef.current = data.totalDuration
rideT = data.totalDuration rideT = data.totalDuration
rideTimeRef.current = rideT
isPlayingRef.current = false isPlayingRef.current = false
setIsPlaying(false) setIsPlaying(false)
setRideTime(rideT) setRideTime(rideT)
} }
} }
// Compute target camera state for current ride time // ── 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 const data = rideDataRef.current
if (data) { if (data) {
const target = computeCameraTarget(rideT, data, cameraModeRef.current) const target = computeCameraTarget(rideT, data, cameraModeRef.current)
if (needsInitRef.current) { // First-person drag rotation: lerp yaw/pitch back to 0 when not dragging,
// Snap to target on first frame after play to avoid lerping from wrong pos // 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) smoothPosRef.current.copy(target.pos)
smoothQuatRef.current.copy(target.quat) smoothQuatRef.current.copy(target.quat)
needsInitRef.current = false needsInitRef.current = false
@ -406,19 +479,31 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
} }
prevWallRef.current = performance.now() prevWallRef.current = performance.now()
fpsLastRef.current = performance.now()
rafRef.current = requestAnimationFrame(tick) rafRef.current = requestAnimationFrame(tick)
} }
// ── Playback controls ───────────────────────────────────────────────────── // ── Playback controls ─────────────────────────────────────────────────────
const play = useCallback(() => { const play = useCallback(() => {
if (!rideDataRef.current) return if (!rideDataRef.current) return
startWallRef.current = performance.now() - rideTimeRef.current * 1000 // Restart from beginning if ride has finished
lastUiUpdateRef.current = -1 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 isPlayingRef.current = true
needsInitRef.current = false // don't re-snap if resuming from pause
setIsPlaying(true) setIsPlaying(true)
}, []) }, [onRideProgress])
const pause = useCallback(() => { const pause = useCallback(() => {
isPlayingRef.current = false isPlayingRef.current = false
@ -433,15 +518,76 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
onStop() onStop()
}, [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 // Start render loop on mount; auto-play immediately
useEffect(() => { useEffect(() => {
needsInitRef.current = true needsInitRef.current = true
startLoop() startLoop()
// Auto-play
const data = rideDataRef.current const data = rideDataRef.current
if (data) { if (data) {
startWallRef.current = performance.now() lastTimeUpdateRef.current = -1
lastUiUpdateRef.current = -1 lastCursorUpdateRef.current = -1
isPlayingRef.current = true isPlayingRef.current = true
setIsPlaying(true) setIsPlaying(true)
} }
@ -452,14 +598,16 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
// ── UI ──────────────────────────────────────────────────────────────────── // ── UI ────────────────────────────────────────────────────────────────────
const isRideDone = !isPlaying && totalDuration > 0 && rideTime >= totalDuration - 0.05
const controls = ( const controls = (
<div className={styles.rideBar}> <div className={styles.rideBar}>
<button <button
className={styles.ridePlayBtn} className={styles.ridePlayBtn}
onClick={isPlaying ? pause : play} onClick={isPlaying ? pause : play}
title={isPlaying ? 'Pause' : 'Resume'} title={isPlaying ? 'Pause' : isRideDone ? 'Restart' : 'Resume'}
> >
{isPlaying ? '⏸' : '▶'} {isPlaying ? '⏸' : isRideDone ? '↺' : '▶'}
</button> </button>
<button className={styles.rideStopBtn} onClick={stop} title="Stop ride"> <button className={styles.rideStopBtn} onClick={stop} title="Stop ride">
@ -494,6 +642,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
return ( return (
<> <>
<canvas ref={canvasRef} className={styles.canvas} /> <canvas ref={canvasRef} className={styles.canvas} />
<div ref={fpsElRef} className={styles.fpsCounter} />
{createPortal(controls, document.body)} {createPortal(controls, document.body)}
</> </>
) )

View File

@ -291,7 +291,7 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
const pos = pickTerrain(e.position) const pos = pickTerrain(e.position)
if (!pos) return if (!pos) return
const id = genId() const id = genId()
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 0 }]) setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }])
setSelectedId(null) setSelectedId(null)
} else if (modeRef.current === 'select') { } else if (modeRef.current === 'select') {
setSelectedId(null) setSelectedId(null)

View File

@ -13,31 +13,23 @@ export interface TerrainCaptureData {
/** 64×64 grid of ENU positions (X=East, Y=Up, Z=North) with terrain heights */ /** 64×64 grid of ENU positions (X=East, Y=Up, Z=North) with terrain heights */
terrainVertices: THREE.Vector3[] terrainVertices: THREE.Vector3[]
gridSize: 64 gridSize: 64
/** Geodetic origin used for ENU conversions */ /** Geodetic origin used for ENU conversions (shared across all patches) */
origin: [number, number, number] origin: [number, number, number]
/** Where along the track this patch is centred [0, 1] */
trackFrac: number
} }
export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error' export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error'
// ── Tile math (Web Mercator) ─────────────────────────────────────────────────── // ── Patch sizing ───────────────────────────────────────────────────────────────
// Each patch covers PATCH_RADIUS_M metres in each direction from the track centre.
// Patches are spaced PATCH_INTERVAL_M apart along the track arc-length.
// At z19 a 1 km × 1 km bbox fits in 4×4 tiles → ~0.85 m/px (vs ~9 m/px for an
// 8 km bbox at the z15 forced by the old single-capture approach).
// Patches overlap by ~150 m at the midpoint between centres so swaps are seamless.
function lonLatToTile(lon: number, lat: number, z: number) { const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch
const x = Math.floor((lon + 180) / 360 * 2 ** z) const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track
const r = lat * Math.PI / 180
const y = Math.floor(
(1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * 2 ** z,
)
return { x, y }
}
function tileToLon(x: number, z: number) {
return x / 2 ** z * 360 - 180
}
function tileToLat(y: number, z: number) {
const n = Math.PI - 2 * Math.PI * y / 2 ** z
return Math.atan(Math.sinh(n)) * 180 / Math.PI
}
// ── Geo → Three.js ENU (X=East, Y=Up, Z=North) ─────────────────────────────── // ── Geo → Three.js ENU (X=East, Y=Up, Z=North) ───────────────────────────────
@ -54,68 +46,64 @@ export function geoToEnu(
return new THREE.Vector3(enu.x, enu.z, -enu.y) return new THREE.Vector3(enu.x, enu.z, -enu.y)
} }
// ── Core capture logic ──────────────────────────────────────────────────────── // ── Single-patch capture ───────────────────────────────────────────────────────
const ESRI_TILE = (z: number, y: number, x: number) => async function captureTerrainPatch(
`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` viewer: Cesium.Viewer,
origin: [number, number, number],
async function captureTerrainData( minLon: number,
terrainProvider: Cesium.TerrainProvider, maxLon: number,
simResult: CoasterSimulationResult, minLat: number,
maxLat: number,
trackFrac: number,
): Promise<TerrainCaptureData> { ): Promise<TerrainCaptureData> {
const origin = simResult.origin // ── Use Cesium's imagery provider (same tiles the viewer already shows) ───
const provider = viewer.imageryLayers.get(0).imageryProvider
const tilingScheme = provider.tilingScheme
const maxLevel = Math.min((provider as { maximumLevel?: number }).maximumLevel ?? 19, 21)
// ── Compute bounding box from both rails ────────────────────────────────── // ── Find the highest zoom where tile count stays ≤ 25 ────────────────────
const allPts = [...simResult.rail_1, ...simResult.rail_2] let level = maxLevel
let minLon = Infinity, maxLon = -Infinity while (level > 5) {
let minLat = Infinity, maxLat = -Infinity const sw = tilingScheme.positionToTileXY(
for (const [lon, lat] of allPts) { Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
if (lon < minLon) minLon = lon )
if (lon > maxLon) maxLon = lon const ne = tilingScheme.positionToTileXY(
if (lat < minLat) minLat = lat Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
if (lat > maxLat) maxLat = lat )
} if (!sw || !ne) { level--; continue }
// 10% padding const nx = ne.x - sw.x + 1
const dLon = (maxLon - minLon) * 0.1 const ny = sw.y - ne.y + 1
const dLat = (maxLat - minLat) * 0.1 if (nx >= 1 && ny >= 1 && nx * ny <= 25) break
minLon -= dLon; maxLon += dLon level--
minLat -= dLat; maxLat += dLat
// ── Pick zoom level so tile count stays ≤ 16 ─────────────────────────────
let zoom = 17
while (zoom > 10) {
const tMin = lonLatToTile(minLon, maxLat, zoom)
const tMax = lonLatToTile(maxLon, minLat, zoom)
const nx = tMax.x - tMin.x + 1
const ny = tMax.y - tMin.y + 1
if (nx * ny <= 16) break
zoom--
} }
const tMin = lonLatToTile(minLon, maxLat, zoom) // NW corner → smallest tile y const swTile = tilingScheme.positionToTileXY(
const tMax = lonLatToTile(maxLon, minLat, zoom) // SE corner → largest tile y Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
)!
const neTile = tilingScheme.positionToTileXY(
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
)!
// Exact geographic extent of the stitched tile grid const tileXMin = swTile.x
const tileBbox: [number, number, number, number] = [ const tileXMax = neTile.x
tileToLon(tMin.x, zoom), // west const tileYMin = neTile.y
tileToLat(tMax.y + 1, zoom), // south const tileYMax = swTile.y
tileToLon(tMax.x + 1, zoom), // east const nx = tileXMax - tileXMin + 1
tileToLat(tMin.y, zoom), // north const ny = tileYMax - tileYMin + 1
] const TILE_PX = provider.tileWidth
const nx = tMax.x - tMin.x + 1 // ── Fetch tiles via the provider ─────────────────────────────────────────
const ny = tMax.y - tMin.y + 1
const TILE_PX = 256
// ── Fetch satellite tiles in parallel ────────────────────────────────────
const tileImages = await Promise.all( const tileImages = await Promise.all(
Array.from({ length: ny }, (_, tj) => Array.from({ length: ny }, (_, tj) =>
Array.from({ length: nx }, (_, ti) => Array.from({ length: nx }, (_, ti) => {
fetch(ESRI_TILE(zoom, tMin.y + tj, tMin.x + ti)) const x = tileXMin + ti
.then(r => r.blob()) const y = tileYMin + tj
.then(b => createImageBitmap(b)) const result = provider.requestImage(x, y, level)
.then(img => ({ ti, tj, img })), const p: Promise<Cesium.ImageryTypes | undefined> =
), result instanceof Promise ? result : Promise.resolve(result ?? undefined)
return p.then(img => ({ ti, tj, img: img ?? null }))
}),
).flat(), ).flat(),
) )
@ -123,11 +111,21 @@ async function captureTerrainData(
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX) const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
for (const { ti, tj, img } of tileImages) { for (const { ti, tj, img } of tileImages) {
ctx.drawImage(img, ti * TILE_PX, tj * TILE_PX) if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX)
} }
const imageBitmap = await createImageBitmap(canvas) const imageBitmap = await createImageBitmap(canvas)
// ── Sample terrain heights on a 64×64 grid over the tile extent ────────── // ── Derive geographic bbox from actual tile edges ─────────────────────────
const nwRect = tilingScheme.tileXYToRectangle(tileXMin, tileYMin, level, new Cesium.Rectangle())
const seRect = tilingScheme.tileXYToRectangle(tileXMax, tileYMax, level, new Cesium.Rectangle())
const tileBbox: [number, number, number, number] = [
Cesium.Math.toDegrees(nwRect.west),
Cesium.Math.toDegrees(seRect.south),
Cesium.Math.toDegrees(seRect.east),
Cesium.Math.toDegrees(nwRect.north),
]
// ── Sample terrain heights on a 64×64 grid ───────────────────────────────
const GRID = 64 const GRID = 64
const cartographics: Cesium.Cartographic[] = [] const cartographics: Cesium.Cartographic[] = []
for (let j = 0; j < GRID; j++) { for (let j = 0; j < GRID; j++) {
@ -137,8 +135,7 @@ async function captureTerrainData(
cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat)) cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat))
} }
} }
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics)
const sampled = await Cesium.sampleTerrainMostDetailed(terrainProvider, cartographics)
// ── Convert to ENU Three.js vectors ────────────────────────────────────── // ── Convert to ENU Three.js vectors ──────────────────────────────────────
const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => { const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => {
@ -147,7 +144,51 @@ async function captureTerrainData(
return geoToEnu(lon, lat, c.height ?? 0, origin) return geoToEnu(lon, lat, c.height ?? 0, origin)
}) })
return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin } return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac }
}
// ── Multi-patch prefetch ───────────────────────────────────────────────────────
async function captureAllPatches(
viewer: Cesium.Viewer,
simResult: CoasterSimulationResult,
): Promise<TerrainCaptureData[]> {
const origin = simResult.origin
const totalLength = simResult.profile.total_length_m
const sFracs = simResult.profile.s_frac as number[]
const r1 = simResult.rail_1 as [number, number, number][]
const r2 = simResult.rail_2 as [number, number, number][]
// ── Sample N patch centres evenly along arc-length ────────────────────────
const N = Math.max(1, Math.round(totalLength / PATCH_INTERVAL_M) + 1)
const patchFracs = Array.from({ length: N }, (_, i) =>
N === 1 ? 0.5 : i / (N - 1),
)
// Geographic midpoint between both rails at a given s_frac
function geoAt(frac: number): [number, number] {
let idx = sFracs.length - 2
for (let j = 0; j < sFracs.length - 1; j++) {
if (sFracs[j + 1] >= frac) { idx = j; break }
}
idx = Math.max(0, Math.min(idx, r1.length - 1))
return [(r1[idx][0] + r2[idx][0]) / 2, (r1[idx][1] + r2[idx][1]) / 2]
}
// ── Fetch all patches in parallel ─────────────────────────────────────────
return Promise.all(
patchFracs.map(frac => {
const [lon, lat] = geoAt(frac)
const rLat = PATCH_RADIUS_M / 111320
const rLon = PATCH_RADIUS_M / (111320 * Math.cos(lat * Math.PI / 180))
return captureTerrainPatch(
viewer, origin,
lon - rLon, lon + rLon,
lat - rLat, lat + rLat,
frac,
)
}),
)
} }
// ── Hook ───────────────────────────────────────────────────────────────────── // ── Hook ─────────────────────────────────────────────────────────────────────
@ -157,13 +198,12 @@ export function useTerrainCapture(
simResult: CoasterSimulationResult | null, simResult: CoasterSimulationResult | null,
) { ) {
const [status, setStatus] = useState<CaptureStatus>('idle') const [status, setStatus] = useState<CaptureStatus>('idle')
const [captureData, setCaptureData] = useState<TerrainCaptureData | null>(null) const [captureData, setCaptureData] = useState<TerrainCaptureData[] | null>(null)
const abortRef = useRef(false) const abortRef = useRef(false)
useEffect(() => { useEffect(() => {
// Reset on new result
setCaptureData(null) setCaptureData(null)
abortRef.current = true // cancel any in-flight capture abortRef.current = true
if (!simResult) { if (!simResult) {
setStatus('idle') setStatus('idle')
@ -173,10 +213,10 @@ export function useTerrainCapture(
abortRef.current = false abortRef.current = false
setStatus('loading') setStatus('loading')
captureTerrainData(viewer.terrainProvider, simResult) captureAllPatches(viewer, simResult)
.then(data => { .then(patches => {
if (abortRef.current) return if (abortRef.current) return
setCaptureData(data) setCaptureData(patches)
setStatus('ready') setStatus('ready')
}) })
.catch(err => { .catch(err => {

19
web/src/store/uiStore.ts Normal file
View File

@ -0,0 +1,19 @@
import { create } from 'zustand'
interface UIState {
showMyChallenges: boolean
showAllChallenges: boolean
showAllCoasters: boolean
setShowMyChallenges: (v: boolean) => void
setShowAllChallenges: (v: boolean) => void
setShowAllCoasters: (v: boolean) => void
}
export const useUIStore = create<UIState>((set) => ({
showMyChallenges: false,
showAllChallenges: false,
showAllCoasters: false,
setShowMyChallenges: (v) => set({ showMyChallenges: v, ...(v ? { showAllChallenges: false, showAllCoasters: false } : {}) }),
setShowAllChallenges: (v) => set({ showAllChallenges: v, ...(v ? { showMyChallenges: false, showAllCoasters: false } : {}) }),
setShowAllCoasters: (v) => set({ showAllCoasters: v, ...(v ? { showMyChallenges: false, showAllChallenges: false } : {}) }),
}))

View File

@ -67,11 +67,12 @@ export interface MineSplat {
// ---------- Challenges ---------- // ---------- Challenges ----------
export interface ChallengeMapProperties { export interface ChallengeMapProperties {
id: string // id lives at the GeoJSON Feature level (f.id), not in properties
title: string title: string
status: ChallengeStatus status: ChallengeStatus
submission_count: number submission_count: number
max_submissions: number | null max_submissions: number | null
coaster_count: number
expires_at: string | null expires_at: string | null
created_at: string created_at: string
} }
@ -94,6 +95,7 @@ export interface ChallengeDetail {
submission_count: number submission_count: number
participant_count: number participant_count: number
is_participating: boolean is_participating: boolean
coaster_count: number
preview_splats: PreviewSplat[] preview_splats: PreviewSplat[]
expires_at: string | null expires_at: string | null
created_at: string created_at: string
@ -130,6 +132,9 @@ export interface SavedCoaster {
name: string name: string
anchors: StoredAnchor[] anchors: StoredAnchor[]
acceleration_strips: Array<{ id: string; startFrac: number; endFrac: number; accel_ms2: number }> acceleration_strips: Array<{ id: string; startFrac: number; endFrac: number; accel_ms2: number }>
rating_avg: number | null
rating_count: number
user_rating: number | null
created_at: string created_at: string
updated_at: string updated_at: string
} }

View File

@ -0,0 +1,122 @@
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap');
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 600;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: rgba(6, 6, 10, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
/* ── Logo ─────────────────────────────────────────────────────────────────── */
.logo {
font-family: 'Bebas Neue', 'Impact', 'Arial Narrow', sans-serif;
font-size: 26px;
letter-spacing: 0.12em;
color: #fff;
text-decoration: none;
line-height: 1;
flex-shrink: 0;
transition: opacity 0.15s;
}
.logo:hover {
opacity: 0.8;
}
.logoR {
color: #f59e0b;
}
/* ── Nav ──────────────────────────────────────────────────────────────────── */
.nav {
display: flex;
align-items: center;
gap: 4px;
}
.divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.1);
margin: 0 6px;
}
/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
padding: 6px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.9);
border-color: rgba(255, 255, 255, 0.08);
}
.btnAccent {
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
}
.btnAccent:hover {
background: rgba(245, 158, 11, 0.16);
color: #fbbf24;
border-color: rgba(245, 158, 11, 0.5);
}
.btnActive {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.4);
color: #f59e0b;
}
.btnActive:hover {
background: rgba(245, 158, 11, 0.2);
}
.btnLogout {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
}
.btnLogout:hover {
color: rgba(255, 100, 80, 0.9);
background: rgba(255, 69, 58, 0.08);
border-color: rgba(255, 69, 58, 0.2);
}
/* ── User link ────────────────────────────────────────────────────────────── */
.userBtn {
padding: 6px 12px;
border-radius: 7px;
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
font-weight: 500;
text-decoration: none;
border: 1px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap;
}
.userBtn:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}

81
web/src/ui/Header.tsx Normal file
View File

@ -0,0 +1,81 @@
import { Link, useLocation } from 'react-router-dom'
import { useChallengeStore } from '../store/challengeStore'
import { useAuthStore } from '../store/authStore'
import { useUIStore } from '../store/uiStore'
import styles from './Header.module.css'
export function Header() {
const location = useLocation()
const isMapPage = location.pathname === '/'
const isCallbackPage = location.pathname.startsWith('/auth/callback')
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
const { user, logout } = useAuthStore()
const { showMyChallenges, showAllChallenges, showAllCoasters,
setShowMyChallenges, setShowAllChallenges, setShowAllCoasters } = useUIStore()
if (isCallbackPage) return null
const username = (user?.profile?.preferred_username as string | undefined)
?? (user?.profile?.email as string | undefined)
?? 'Account'
function handleChallengeToggle() {
if (drawingMode) {
setDrawingMode(false)
setDraftPolygon(null)
} else {
setDrawingMode(true)
}
}
return (
<header className={styles.header}>
<Link to="/" className={styles.logo}>
<span className={styles.logoR}>R</span>CNN
</Link>
<nav className={styles.nav}>
{isMapPage && (
<button
className={`${styles.btn} ${styles.btnAccent} ${drawingMode ? styles.btnActive : ''}`}
onClick={handleChallengeToggle}
>
{drawingMode ? '✕ Cancel' : '+ Challenge'}
</button>
)}
<button
className={`${styles.btn} ${showAllChallenges ? styles.btnActive : ''}`}
onClick={() => setShowAllChallenges(!showAllChallenges)}
>
Challenges
</button>
<button
className={`${styles.btn} ${showAllCoasters ? styles.btnActive : ''}`}
onClick={() => setShowAllCoasters(!showAllCoasters)}
>
Coasters
</button>
<button
className={`${styles.btn} ${showMyChallenges ? styles.btnActive : ''}`}
onClick={() => setShowMyChallenges(!showMyChallenges)}
>
My Challenges
</button>
<div className={styles.divider} />
<Link to="/profile" className={styles.userBtn}>
@{username}
</Link>
<button className={`${styles.btn} ${styles.btnLogout}`} onClick={() => logout()}>
Sign out
</button>
</nav>
</header>
)
}

View File

@ -1,6 +1,6 @@
.toolbar { .toolbar {
position: fixed; position: fixed;
top: 16px; top: 68px; /* 52px header + 16px gap */
left: 16px; left: 16px;
z-index: 20; z-index: 20;
display: flex; display: flex;

View File

@ -1,64 +1,21 @@
import { useState } from 'react'
import { useChallengeStore } from '../store/challengeStore' import { useChallengeStore } from '../store/challengeStore'
import { useAuthStore } from '../store/authStore'
import { MyChallengesPanel } from '../challenges/MyChallengesPanel'
import { SearchBar } from './SearchBar' import { SearchBar } from './SearchBar'
import { OverlayControls } from './OverlayControls' import { OverlayControls } from './OverlayControls'
import styles from './MapOverlay.module.css' import styles from './MapOverlay.module.css'
export function MapOverlay() { export function MapOverlay() {
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore() const { drawingMode } = useChallengeStore()
const { logout } = useAuthStore()
const [showMine, setShowMine] = useState(false)
function handleDrawToggle() {
if (drawingMode) {
setDrawingMode(false)
setDraftPolygon(null)
} else {
setShowMine(false)
setDrawingMode(true)
}
}
return ( return (
<>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<SearchBar /> <SearchBar />
<OverlayControls /> <OverlayControls />
<button
className={`${styles.btn} ${drawingMode ? styles.active : ''}`}
onClick={handleDrawToggle}
title={drawingMode ? 'Cancel drawing' : 'Create challenge (draw region)'}
>
{drawingMode ? '✕ Cancel' : '+ Challenge'}
</button>
<button
className={`${styles.btn} ${showMine ? styles.active : ''}`}
onClick={() => setShowMine((v) => !v)}
title="View my challenges"
>
My Challenges
</button>
{drawingMode && ( {drawingMode && (
<p className={styles.hint}> <p className={styles.hint}>
Click to place vertices · Right-click to close polygon Click to place vertices · Right-click to close polygon
</p> </p>
)} )}
<button
className={`${styles.btn} ${styles.logout}`}
onClick={() => logout()}
title="Sign out"
>
Sign out
</button>
</div> </div>
{showMine && <MyChallengesPanel onClose={() => setShowMine(false)} />}
</>
) )
} }

View File

@ -1,6 +1,6 @@
.panel { .panel {
position: fixed; position: fixed;
top: 0; top: 52px; /* below global header */
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 380px; width: 380px;

View File

@ -0,0 +1,40 @@
.root {
display: inline-flex;
align-items: center;
gap: 2px;
}
.star {
background: none;
border: none;
padding: 0;
line-height: 1;
font-size: 16px;
cursor: default;
transition: transform 0.1s, color 0.1s;
}
.interactive {
cursor: pointer;
}
.interactive:hover {
transform: scale(1.2);
}
.filled {
color: #f59e0b;
}
.empty {
color: rgba(255, 255, 255, 0.18);
}
.count {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
margin-left: 4px;
}
/* Sizes */
.sm .star { font-size: 13px; }
.md .star { font-size: 16px; }

37
web/src/ui/StarRating.tsx Normal file
View File

@ -0,0 +1,37 @@
import { useState } from 'react'
import styles from './StarRating.module.css'
interface Props {
value: number | null // average or selected rating (15)
userRating: number | null
count?: number
onRate?: (rating: number) => void
size?: 'sm' | 'md'
}
export function StarRating({ value, userRating, count, onRate, size = 'md' }: Props) {
const [hover, setHover] = useState<number | null>(null)
const display = hover ?? userRating ?? value ?? 0
return (
<span className={`${styles.root} ${styles[size]}`}>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
className={`${styles.star} ${star <= display ? styles.filled : styles.empty} ${onRate ? styles.interactive : ''}`}
onClick={onRate ? () => onRate(star) : undefined}
onMouseEnter={onRate ? () => setHover(star) : undefined}
onMouseLeave={onRate ? () => setHover(null) : undefined}
aria-label={`Rate ${star} stars`}
disabled={!onRate}
>
</button>
))}
{count !== undefined && (
<span className={styles.count}>({count})</span>
)}
</span>
)
}

View File

@ -0,0 +1,215 @@
.page {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 80px 24px 24px; /* 52px header + 28px */
background: #07070b;
overflow-y: auto;
}
.card {
width: 100%;
max-width: 480px;
background: rgba(14, 14, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Avatar row ───────────────────────────────────────────────────────────── */
.avatarRow {
display: flex;
align-items: center;
gap: 20px;
padding: 32px 28px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(245, 158, 11, 0.04);
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(245, 158, 11, 0.3);
flex-shrink: 0;
}
.avatarFallback {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(245, 158, 11, 0.12);
border: 2px solid rgba(245, 158, 11, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
color: #f59e0b;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.name {
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.handle {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
}
/* ── Section ──────────────────────────────────────────────────────────────── */
.section {
padding: 24px 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.section:last-of-type {
border-bottom: none;
}
.sectionTitle {
margin: 0 0 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.35);
}
/* ── Fields ───────────────────────────────────────────────────────────────── */
.fields {
display: grid;
grid-template-columns: 100px 1fr;
gap: 10px 16px;
margin: 0;
}
.fields dt {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
align-self: center;
}
.fields dd {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
align-self: center;
}
/* ── Danger zone ──────────────────────────────────────────────────────────── */
.dangerNote {
margin: 0 0 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
line-height: 1.5;
}
.deleteBtn {
padding: 9px 18px;
background: rgba(255, 69, 58, 0.08);
border: 1px solid rgba(255, 69, 58, 0.3);
border-radius: 8px;
color: rgba(255, 100, 80, 0.9);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.deleteBtn:hover {
background: rgba(255, 69, 58, 0.16);
border-color: rgba(255, 69, 58, 0.5);
}
.confirmBox {
background: rgba(255, 69, 58, 0.06);
border: 1px solid rgba(255, 69, 58, 0.2);
border-radius: 10px;
padding: 16px;
}
.confirmText {
margin: 0 0 14px;
font-size: 13px;
color: rgba(255, 200, 190, 0.9);
}
.confirmBtns {
display: flex;
gap: 10px;
}
.confirmDeleteBtn {
padding: 8px 16px;
background: rgba(255, 69, 58, 0.22);
border: 1px solid rgba(255, 69, 58, 0.5);
border-radius: 7px;
color: #ff6450;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.confirmDeleteBtn:hover:not(:disabled) {
background: rgba(255, 69, 58, 0.35);
}
.confirmDeleteBtn:disabled {
opacity: 0.5;
cursor: wait;
}
.cancelBtn {
padding: 8px 16px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 7px;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.cancelBtn:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
}
.error {
margin: 12px 0 0;
font-size: 12px;
color: #f87171;
}
/* ── Back button ──────────────────────────────────────────────────────────── */
.backBtn {
display: inline-flex;
margin: 20px 28px 24px;
padding: 8px 16px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
align-self: flex-start;
}
.backBtn:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
}

View File

@ -0,0 +1,96 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { deleteMyAccount } from '../api/users'
import styles from './UserProfilePage.module.css'
export function UserProfilePage() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const profile = user?.profile
const username = (profile?.preferred_username as string | undefined) ?? '—'
const email = (profile?.email as string | undefined) ?? '—'
const name = (profile?.name as string | undefined) ?? username
const avatarUrl = (profile?.picture as string | undefined) ?? null
async function handleDelete() {
setDeleting(true)
setError(null)
try {
await deleteMyAccount()
await logout()
} catch {
setError('Failed to delete account. Please try again.')
setDeleting(false)
setConfirming(false)
}
}
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.avatarRow}>
{avatarUrl ? (
<img src={avatarUrl} alt={name} className={styles.avatar} />
) : (
<div className={styles.avatarFallback}>
{username.slice(0, 2).toUpperCase()}
</div>
)}
<div>
<h1 className={styles.name}>{name}</h1>
<p className={styles.handle}>@{username}</p>
</div>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Profile</h2>
<dl className={styles.fields}>
<dt>Username</dt>
<dd>{username}</dd>
<dt>Email</dt>
<dd>{email}</dd>
</dl>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Account</h2>
<p className={styles.dangerNote}>
Deleting your account is permanent. All your coasters and challenges will be removed.
</p>
{!confirming ? (
<button className={styles.deleteBtn} onClick={() => setConfirming(true)}>
Delete my account
</button>
) : (
<div className={styles.confirmBox}>
<p className={styles.confirmText}>Are you sure? This cannot be undone.</p>
<div className={styles.confirmBtns}>
<button
className={styles.confirmDeleteBtn}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
<button className={styles.cancelBtn} onClick={() => setConfirming(false)}>
Cancel
</button>
</div>
{error && <p className={styles.error}>{error}</p>}
</div>
)}
</div>
<button className={styles.backBtn} onClick={() => navigate(-1)}>
Back
</button>
</div>
</div>
)
}