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.
Used for the map pin list does not include the full region polygon.
"""
coaster_count = serializers.SerializerMethodField()
class Meta:
model = Challenge
@ -38,9 +39,13 @@ class ChallengeMapSerializer(GeoFeatureModelSerializer):
fields = [
"id", "title", "status",
"submission_count", "max_submissions",
"coaster_count",
"expires_at", "created_at",
]
def get_coaster_count(self, obj):
return obj.coasters.count()
class ChallengeSplatPreviewSerializer(serializers.Serializer):
id = serializers.UUIDField()
@ -58,6 +63,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
participant_count = serializers.SerializerMethodField()
is_participating = serializers.SerializerMethodField()
preview_splats = serializers.SerializerMethodField()
coaster_count = serializers.SerializerMethodField()
class Meta:
model = Challenge
@ -67,6 +73,7 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
"region", "region_centroid",
"max_submissions", "submission_count",
"participant_count", "is_participating",
"coaster_count",
"preview_splats",
"expires_at", "created_at", "updated_at",
]
@ -88,6 +95,9 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
return False
return obj.participants.filter(user=request.user).exists()
def get_coaster_count(self, obj):
return obj.coasters.count()
def get_preview_splats(self, obj):
from apps.splats.models import Splat
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):
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(
points_m: np.ndarray,
rail_spacing: float = 1.5,
rail_spacing: float = 0.6,
mass: float = 1000.0,
initial_velocity: float = 1.0,
friction_coeff: float = 0.02,
@ -368,7 +368,7 @@ def generate_rails(
points_m : np.ndarray shape (n, 3)
Centreline waypoints in **metres**, local coordinate frame.
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 of the coaster car in kg, used for friction losses.
initial_velocity : float

View File

@ -4,11 +4,32 @@ from .models import Coaster
class CoasterSerializer(serializers.ModelSerializer):
creator_username = serializers.ReadOnlyField(source='creator.username')
rating_avg = serializers.SerializerMethodField()
rating_count = serializers.SerializerMethodField()
user_rating = serializers.SerializerMethodField()
class Meta:
model = Coaster
fields = [
'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']
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 .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView
from .views import (
CoasterSimulateView,
CoasterListCreateView,
CoasterDeleteView,
CoasterGlobalListView,
CoasterRateView,
)
urlpatterns = [
path("simulate/", CoasterSimulateView.as_view()),
path("coasters/", CoasterGlobalListView.as_view()),
path("challenges/<uuid:challenge_id>/coasters/", CoasterListCreateView.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 ──────────────────────────────────────────────────
_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),
"initial_velocity": (float, 0.0, 100.0, 1.0),
"friction_coeff": (float, 0.0, 1.0, 0.02),
@ -242,9 +242,10 @@ class CoasterListCreateView(APIView):
Coaster.objects
.filter(challenge_id=challenge_id)
.select_related('creator')
.prefetch_related('ratings')
.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):
from .models import Coaster
@ -269,7 +270,7 @@ class CoasterListCreateView(APIView):
},
)
return Response(
CoasterSerializer(coaster).data,
CoasterSerializer(coaster, context={'request': request}).data,
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)
coaster.delete()
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>
<meta charset="UTF-8" />
<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>
/* Cesium viewer requires the host element to have explicit dimensions.
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 { ChallengePanel } from './challenges/ChallengePanel'
import { ChallengeCreator } from './challenges/ChallengeCreator'
import { ChallengesListPanel } from './challenges/ChallengesListPanel'
import { MyChallengesSidePanel } from './challenges/MyChallengesSidePanel'
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() {
return (
@ -30,10 +35,18 @@ export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Header />
{/* Global overlay panels — rendered above everything */}
<ChallengesListPanel />
<MyChallengesSidePanel />
<AllCoastersPanel />
<Routes>
<Route path="/auth/callback" element={<CallbackPage />} />
<Route path="/" element={<MapPage />} />
<Route path="/challenges/:id/coaster" element={<CoasterEditorPage />} />
<Route path="/challenges/:challengeId/coasters/:coasterId" element={<CoasterViewerPage />} />
<Route path="/profile" element={<UserProfilePage />} />
</Routes>
</AuthProvider>
</BrowserRouter>

View File

@ -6,9 +6,36 @@ export const apiClient = axios.create({
})
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) {
config.headers.Authorization = `Bearer ${user.access_token}`
}
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> {
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,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
silent_redirect_uri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
response_type: 'code',
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
automaticSilentRenew: true,
})

View File

@ -41,7 +41,7 @@ export function ChallengeLayer() {
lastBboxRef.current = bbox
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) => {
if (!incoming.has(id)) {
@ -51,7 +51,7 @@ export function ChallengeLayer() {
})
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
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 {
position: fixed;
top: 16px;
top: 68px; /* 52px header + 16px gap */
left: 140px;
z-index: 20;
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 {
position: fixed;
top: 0;
top: 52px; /* below global header */
left: 0;
right: 0;
z-index: 200;
@ -36,6 +36,27 @@
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 {
flex: 1;
margin: 0;

View File

@ -4,7 +4,7 @@ import * as Cesium from 'cesium'
import { CesiumViewer } from '../cesium/CesiumViewer'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { fetchChallengeDetail } from '../api/challenges'
import { simulateCoaster, saveCoaster } from '../api/coaster'
import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster'
import { useCoasterPath } from './useCoasterPath'
import { useAccelerationStrips } from './useAccelerationStrips'
import { useTerrainCapture } from './useTerrainCapture'
@ -17,7 +17,7 @@ import { useAuthStore } from '../store/authStore'
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
import styles from './CoasterEditorPage.module.css'
// ── Route page ───────────────────────────────────────────────────────────────
// ── Route pages ───────────────────────────────────────────────────────────────
export function CoasterEditorPage() {
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 ───────────────────────────────────────────────────────────────────
/** Build a circle cross-section shape for PolylineVolumeGraphics. */
@ -47,22 +75,25 @@ function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] {
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) ────────────────────────────────────────
interface SceneProps {
challengeId: string | undefined
challenge: ChallengeDetail | null
readonly?: boolean
preloadCoaster?: SavedCoaster
}
function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) {
const viewer = useCesiumViewer()
const navigate = useNavigate()
const authUser = useAuthStore(s => s.user)
const currentUsername = authUser?.profile?.preferred_username as string | undefined
const [coasterName, setCoasterName] = useState('')
const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null)
const [simulating, setSimulating] = useState(false)
const [simError, setSimError] = useState<string | null>(null)
@ -78,9 +109,21 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
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
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)
const simEntitiesRef = useRef<Cesium.Entity[]>([])
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
@ -203,6 +246,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
}))
path.loadAnchors(anchorPoints)
accel.loadStrips(coaster.acceleration_strips)
setCoasterName(coaster.name || '')
setSimResult(null)
}
@ -219,6 +263,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
}
})
await saveCoaster(challengeId, {
name: coasterName.trim(),
anchors: storedAnchors,
acceleration_strips: accel.strips,
})
@ -246,6 +291,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
carto.height,
] as [number, number, number]
})
const result = await simulateCoaster({
path: geoPath,
params: { initial_velocity: initialVelocity },
@ -284,38 +330,48 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
{/* ── Top bar ─────────────────────────────────────────────────────── */}
<div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => navigate('/')}>
Map
<button className={styles.backBtn} onClick={() => navigate(-1)}>
Back
</button>
{!readonly && (
<input
className={styles.nameInput}
type="text"
placeholder="Name your coaster…"
value={coasterName}
onChange={(e) => setCoasterName(e.target.value)}
maxLength={255}
/>
)}
<h1 className={styles.title}>
{challenge ? challenge.title : 'Loading…'}
</h1>
<span className={styles.badge}>Coaster Editor</span>
<span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span>
</div>
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
{!isRideMode && (
<div className={styles.toolbar}>
{!readonly && (
<>
<ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} />
<ModeButton label="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} />
<div className={styles.divider} />
<button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}>
Undo
</button>
<button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}>
Clear
</button>
{path.anchors.length > 0 && (
<span className={styles.countBadge}>
{path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''}
</span>
)}
<div className={styles.divider} />
</>
)}
<button
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
@ -344,6 +400,8 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
>
Strips
</button>
{!readonly && (
<button
className={styles.ghostBtn}
onClick={handleSave}
@ -351,6 +409,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
>
Save
</button>
)}
{simResult && (
<>
@ -402,7 +461,7 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
)}
{/* ── Selected-point panel ─────────────────────────────────────────── */}
{selected && (
{selected && !readonly && (
<div className={styles.selectedPanel}>
<p className={styles.panelHeading}>
Point {selectedIndex + 1} of {path.anchors.length}

View File

@ -1,7 +1,7 @@
.panel {
position: fixed;
right: 16px;
top: 60px;
top: 112px; /* 52px header + ~44px topBar + 16px gap */
z-index: 200;
width: 240px;
background: rgba(8, 8, 12, 0.84);
@ -56,9 +56,26 @@
border-bottom: none;
}
.username {
.rowInfo {
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;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -39,9 +39,12 @@ export function CoasterListPanel({ challengeId, currentUsername, onLoad, refresh
const isOwn = c.creator_username === currentUsername
return (
<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}` : ''}`}>
@{c.creator_username}
</span>
</div>
<button className={styles.loadBtn} onClick={() => onLoad(c)}>
Load
</button>

View File

@ -4,13 +4,29 @@
position: fixed;
inset: 0;
z-index: 500;
/* pointer events pass through — the ride control bar handles interaction */
pointer-events: none;
/* control bar is z-index 501 (above canvas 500) so it handles its own clicks */
pointer-events: auto;
display: block;
width: 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) ─────── */
.rideBar {

View File

@ -12,7 +12,7 @@ type CameraMode = 'first' | 'third'
interface Props {
simResult: CoasterSimulationResult
captureData: TerrainCaptureData
captureData: TerrainCaptureData[]
onStop: () => void
/** Called at ~4 Hz with the current track-fraction position [0,1]. */
onRideProgress?: (sFrac: number) => void
@ -75,19 +75,32 @@ function buildRideData(simResult: CoasterSimulationResult): RideData {
totalDuration: timeArray[n - 1], count: n }
}
// ── Camera target computation ─────────────────────────────────────────────────
interface CameraTarget {
pos: THREE.Vector3
quat: THREE.Quaternion
}
// ── Camera target computation (zero allocation per call) ──────────────────────
//
// All scratch objects are pre-allocated at module level so the hot rAF path
// never triggers the garbage collector.
const _mat = new THREE.Matrix4()
// PerspectiveCamera.lookAt uses camera convention: Z axis toward target.
// A plain Object3D.lookAt points +Z toward target (opposite), which would
// make the camera look backward.
const _dummy = new THREE.PerspectiveCamera()
const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → Z toward target
// Scratch vectors reused every frame
const _scPos = new THREE.Vector3()
const _scR1 = new THREE.Vector3()
const _scR2 = new THREE.Vector3()
const _scTang = new THREE.Vector3()
const _scSide = new THREE.Vector3()
const _scTUp = new THREE.Vector3()
const _scLook = new THREE.Vector3()
// Output objects — callers must copy out before the next call
const _outPos = new THREE.Vector3()
const _outQuat = new THREE.Quaternion()
// Scratch for user drag rotation offset (first-person only)
const _userOffsetQuat = new THREE.Quaternion()
const _userOffsetEuler = new THREE.Euler(0, 0, 0, 'YXZ')
interface CameraTarget { pos: THREE.Vector3; quat: THREE.Quaternion }
/** Writes result into _outPos / _outQuat — no heap allocation. */
function computeCameraTarget(
t: number,
data: RideData,
@ -102,73 +115,50 @@ function computeCameraTarget(
? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i])
: 0
const pos = centerline[i].clone().lerp(centerline[i1], alpha)
const r1i = rail1[i].clone().lerp(rail1[i1], alpha)
const r2i = rail2[i].clone().lerp(rail2[i1], alpha)
_scPos.lerpVectors(centerline[i], centerline[i1], alpha)
_scR1.lerpVectors(rail1[i], rail1[i1], alpha)
_scR2.lerpVectors(rail2[i], rail2[i1], alpha)
// Wider stencil for smoother forward tangent
const pi = Math.max(0, i - 2)
const pj = Math.min(count - 1, i1 + 2)
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
const side = r1i.clone().sub(r2i).normalize()
const trackUp = tang.clone().cross(side).normalize()
if (trackUp.dot(UP) < 0) trackUp.negate()
let camPos: THREE.Vector3
let lookTarget: THREE.Vector3
let upVec: THREE.Vector3
// Track-local "up": cross(tangent, rail1→rail2) → binormal
_scSide.subVectors(_scR1, _scR2).normalize()
_scTUp.crossVectors(_scTang, _scSide).normalize()
if (_scTUp.dot(UP) < 0) _scTUp.negate()
if (mode === 'first') {
// Sit 1.5 m above centreline, look 10 m ahead from the seat
camPos = pos.clone().addScaledVector(UP, 1.5)
lookTarget = camPos.clone().addScaledVector(tang, 10)
upVec = trackUp
// Sit 1.5 m above centreline, look 10 m ahead
_outPos.copy(_scPos).addScaledVector(UP, 1.5)
_scLook.copy(_outPos).addScaledVector(_scTang, 10)
_dummy.up.copy(_scTUp)
} else {
// 40 m behind + 12 m above, looking at the car
camPos = pos.clone()
.addScaledVector(tang, -40)
.addScaledVector(UP, 12)
lookTarget = pos
upVec = UP
_outPos.copy(_scPos).addScaledVector(_scTang, -40).addScaledVector(UP, 12)
_scLook.copy(_scPos)
_dummy.up.copy(UP)
}
// Build quaternion from look-at matrix (avoids gimbal lock and is slerp-able)
_dummy.position.copy(camPos)
_dummy.up.copy(upVec)
_dummy.lookAt(lookTarget)
_dummy.position.copy(_outPos)
_dummy.lookAt(_scLook)
_dummy.updateMatrixWorld(true)
// _dummy.matrixWorld = translation * rotation; extract rotation quaternion
_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) {
const scene = new THREE.Scene()
scene.background = new THREE.Color(0x87ceeb)
// Lighting
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 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])
function buildTerrainMesh(
patch: TerrainCaptureData,
renderer: THREE.WebGLRenderer,
visible: boolean,
): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
const GRID = patch.gridSize
const verts = patch.terrainVertices
const posArr = new Float32Array(GRID * GRID * 3)
const uvArr = new Float32Array(GRID * GRID * 2)
@ -178,63 +168,79 @@ function buildScene(captureData: TerrainCaptureData, rideData: RideData) {
const idx = j * GRID + i
const v = verts[idx]
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
// The stitched canvas has north at pixel row 0 (tj=0 = northernmost tile).
// 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)
uvArr[idx * 2] = i / (GRID - 1)
uvArr[idx * 2 + 1] = j / (GRID - 1)
}
}
// 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[] = []
for (let j = 0; j < GRID - 1; j++) {
for (let i = 0; i < GRID - 1; i++) {
const a = j * GRID + i // SW
const b = j * GRID + i + 1 // SE
const c = (j + 1) * GRID + i + 1 // NE
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 a = j * GRID + i, b = j * GRID + i + 1
const c = (j + 1) * GRID + i + 1, d = (j + 1) * GRID + i
idxArr.push(a, b, d, b, c, d)
}
}
const terrainGeo = new THREE.BufferGeometry()
terrainGeo.setAttribute('position', new THREE.BufferAttribute(posArr, 3))
terrainGeo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2))
terrainGeo.setIndex(idxArr)
terrainGeo.computeVertexNormals()
geos.push(terrainGeo)
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3))
geo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2))
geo.setIndex(idxArr)
geo.computeVertexNormals()
// Draw ImageBitmap onto a real HTMLCanvasElement so CanvasTexture works reliably
const texCanvas = document.createElement('canvas')
texCanvas.width = captureData.imageBitmap.width
texCanvas.height = captureData.imageBitmap.height
texCanvas.getContext('2d')!.drawImage(captureData.imageBitmap, 0, 0)
const tex = new THREE.Texture(patch.imageBitmap)
tex.colorSpace = THREE.SRGBColorSpace
tex.anisotropy = renderer.capabilities.getMaxAnisotropy()
tex.minFilter = THREE.LinearMipmapLinearFilter
tex.magFilter = THREE.LinearFilter
tex.needsUpdate = true
const terrainTex = new THREE.CanvasTexture(texCanvas)
terrainTex.colorSpace = THREE.SRGBColorSpace
texes.push(terrainTex)
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
const mesh = new THREE.Mesh(geo, mat)
mesh.visible = visible
return { mesh, geo, mat, tex }
}
const terrainMat = new THREE.MeshLambertMaterial({ map: terrainTex, side: THREE.FrontSide })
mats.push(terrainMat)
scene.add(new THREE.Mesh(terrainGeo, terrainMat))
// ── Three.js scene builder ─────────────────────────────────────────────────────
// ── 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[]) {
// Decimate: CatmullRomCurve3 doesn't need every sim point
const step = Math.max(1, Math.floor(pts.length / 500))
const step = Math.max(1, Math.floor(pts.length / 200))
const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
const curve = new THREE.CatmullRomCurve3(dpts)
// 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 * 4, 2000), 0.25, 10, false)
const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 3, 600), 0.075, 6, false)
const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 })
geos.push(geo)
mats.push(mat)
@ -245,6 +251,7 @@ function buildScene(captureData: TerrainCaptureData, rideData: RideData) {
return {
scene,
setActivePatch,
disposeAll: () => {
geos.forEach(g => g.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 sceneRef = useRef<THREE.Scene | null>(null)
const rafRef = useRef<number | null>(null)
const fpsElRef = useRef<HTMLDivElement | null>(null)
// Ride playback refs (mutable, used inside rAF loop)
const rideDataRef = useRef<RideData | null>(null)
const isPlayingRef = useRef(false)
const rideTimeRef = useRef(0)
const startWallRef = useRef(0)
const lastUiUpdateRef = useRef(-1)
const cameraModeRef = useRef<CameraMode>('third')
const prevWallRef = useRef(0) // for frame deltaTime
const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz)
const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive)
const cameraModeRef = useRef<CameraMode>('first')
const prevWallRef = useRef(0)
// 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 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)
const [isPlaying, setIsPlaying] = useState(false)
const [rideTime, setRideTime] = useState(0)
const [totalDuration, setTotalDuration] = useState(0)
const [cameraMode, setCameraMode] = useState<CameraMode>('third')
const [cameraMode, setCameraMode] = useState<CameraMode>('first')
useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode])
// ── Build Three.js scene ──────────────────────────────────────────────────
// ── Build Three.js scene ────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
// antialias: false — significant GPU cost saving; no pixelRatio scaling on retina
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false })
renderer.setPixelRatio(1)
rendererRef.current = renderer
const rideData = buildRideData(simResult)
rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration)
const { scene, disposeAll } = buildScene(captureData, rideData)
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene
setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0
function onResize() {
const w = window.innerWidth, h = window.innerHeight
@ -310,9 +336,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
window.addEventListener('resize', onResize)
onResize()
// Position camera at track start looking forward
if (rideData.count > 1) {
const t0 = computeCameraTarget(0, rideData, 'third')
const t0 = computeCameraTarget(0, rideData, 'first')
smoothPosRef.current.copy(t0.pos)
smoothQuatRef.current.copy(t0.quat)
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
// ── rAF render loop ───────────────────────────────────────────────────────
// ── rAF render loop ──────────────────────────────────────────────────────────
// Smoothing time constants (seconds) — larger = smoother / more lag
const POS_TAU = 0.20 // position lag
const ROT_TAU = 0.35 // rotation lag
// Smoothing time constants (seconds)
const POS_TAU = 0.05
const ROT_TAU = 0.08
function startLoop() {
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)
prevWallRef.current = wallMs
// ── FPS counter (DOM write, no React) ──────────────────────────────────
fpsCountRef.current++
if (wallMs - fpsLastRef.current >= 500) {
const elapsed = (wallMs - fpsLastRef.current) / 1000
const fps = Math.round(fpsCountRef.current / elapsed)
if (fpsElRef.current) fpsElRef.current.textContent = `${fps} FPS`
fpsCountRef.current = 0
fpsLastRef.current = wallMs
}
// ── Ride time ──────────────────────────────────────────────────────────
let rideT = rideTimeRef.current
if (isPlayingRef.current) {
rideT = (wallMs - startWallRef.current) / 1000
rideTimeRef.current = rideT
rideTimeRef.current += dt
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)
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
if (d && onRideProgress) {
if (d) {
const ci = bisect(d.timeArray, rideT)
const ci1 = Math.min(ci + 1, d.count - 1)
const ca = (d.timeArray[ci1] > d.timeArray[ci])
@ -371,21 +411,54 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
const data = rideDataRef.current
if (data && rideT >= data.totalDuration) {
rideTimeRef.current = data.totalDuration
rideT = data.totalDuration
rideTimeRef.current = rideT
isPlayingRef.current = false
setIsPlaying(false)
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
if (data) {
const target = computeCameraTarget(rideT, data, cameraModeRef.current)
if (needsInitRef.current) {
// Snap to target on first frame after play to avoid lerping from wrong pos
// First-person drag rotation: lerp yaw/pitch back to 0 when not dragging,
// then bake the offset into the target quaternion.
if (cameraModeRef.current === 'first') {
if (!isDraggingRef.current) {
const returnAlpha = 1 - Math.exp(-dt * 2.5)
userYawRef.current *= (1 - returnAlpha)
userPitchRef.current *= (1 - returnAlpha)
if (Math.abs(userYawRef.current) < 0.0001) userYawRef.current = 0
if (Math.abs(userPitchRef.current) < 0.0001) userPitchRef.current = 0
}
_userOffsetEuler.set(userPitchRef.current, userYawRef.current, 0, 'YXZ')
_userOffsetQuat.setFromEuler(_userOffsetEuler)
target.quat.multiply(_userOffsetQuat)
}
if (needsInitRef.current || isDraggingRef.current) {
// Snap instantly: on init, or while dragging so the view tracks the finger
smoothPosRef.current.copy(target.pos)
smoothQuatRef.current.copy(target.quat)
needsInitRef.current = false
@ -406,19 +479,31 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
}
prevWallRef.current = performance.now()
fpsLastRef.current = performance.now()
rafRef.current = requestAnimationFrame(tick)
}
// ── Playback controls ─────────────────────────────────────────────────────
// ── Playback controls ─────────────────────────────────────────────────────
const play = useCallback(() => {
if (!rideDataRef.current) return
startWallRef.current = performance.now() - rideTimeRef.current * 1000
lastUiUpdateRef.current = -1
// Restart from beginning if ride has finished
const restarting = rideTimeRef.current >= rideDataRef.current.totalDuration - 0.05
if (restarting) {
rideTimeRef.current = 0
userYawRef.current = 0
userPitchRef.current = 0
setRideTime(0)
needsInitRef.current = true // snap camera to start position
if (onRideProgress) onRideProgress(0)
} else {
needsInitRef.current = false // resume with smooth camera
}
lastTimeUpdateRef.current = -1
lastCursorUpdateRef.current = -1
isPlayingRef.current = true
needsInitRef.current = false // don't re-snap if resuming from pause
setIsPlaying(true)
}, [])
}, [onRideProgress])
const pause = useCallback(() => {
isPlayingRef.current = false
@ -433,15 +518,76 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
onStop()
}, [onStop])
// ── First-person drag-to-look event listeners ─────────────────────────────
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const DRAG_SENSITIVITY = 0.004
const MAX_PITCH = Math.PI / 2.5 // ±72°
const MAX_YAW = Math.PI // ±180°
function onMouseDown(e: MouseEvent) {
if (cameraModeRef.current !== 'first') return
e.preventDefault() // prevent text-selection flicker during drag
isDraggingRef.current = true
lastDragXRef.current = e.clientX
lastDragYRef.current = e.clientY
}
function onMouseMove(e: MouseEvent) {
if (!isDraggingRef.current) return
const dx = e.clientX - lastDragXRef.current
const dy = e.clientY - lastDragYRef.current
lastDragXRef.current = e.clientX
lastDragYRef.current = e.clientY
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
}
function onMouseUp() { isDraggingRef.current = false }
function onTouchStart(e: TouchEvent) {
if (cameraModeRef.current !== 'first' || e.touches.length === 0) return
isDraggingRef.current = true
lastDragXRef.current = e.touches[0].clientX
lastDragYRef.current = e.touches[0].clientY
}
function onTouchMove(e: TouchEvent) {
if (!isDraggingRef.current || e.touches.length === 0) return
const dx = e.touches[0].clientX - lastDragXRef.current
const dy = e.touches[0].clientY - lastDragYRef.current
lastDragXRef.current = e.touches[0].clientX
lastDragYRef.current = e.touches[0].clientY
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
}
function onTouchEnd() { isDraggingRef.current = false }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
window.addEventListener('touchmove', onTouchMove, { passive: true })
window.addEventListener('touchend', onTouchEnd)
return () => {
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('touchstart', onTouchStart)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
}, [])
// Start render loop on mount; auto-play immediately
useEffect(() => {
needsInitRef.current = true
startLoop()
// Auto-play
const data = rideDataRef.current
if (data) {
startWallRef.current = performance.now()
lastUiUpdateRef.current = -1
lastTimeUpdateRef.current = -1
lastCursorUpdateRef.current = -1
isPlayingRef.current = true
setIsPlaying(true)
}
@ -452,14 +598,16 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
// ── UI ────────────────────────────────────────────────────────────────────
const isRideDone = !isPlaying && totalDuration > 0 && rideTime >= totalDuration - 0.05
const controls = (
<div className={styles.rideBar}>
<button
className={styles.ridePlayBtn}
onClick={isPlaying ? pause : play}
title={isPlaying ? 'Pause' : 'Resume'}
title={isPlaying ? 'Pause' : isRideDone ? 'Restart' : 'Resume'}
>
{isPlaying ? '⏸' : '▶'}
{isPlaying ? '⏸' : isRideDone ? '↺' : '▶'}
</button>
<button className={styles.rideStopBtn} onClick={stop} title="Stop ride">
@ -494,6 +642,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
return (
<>
<canvas ref={canvasRef} className={styles.canvas} />
<div ref={fpsElRef} className={styles.fpsCounter} />
{createPortal(controls, document.body)}
</>
)

View File

@ -291,7 +291,7 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
const pos = pickTerrain(e.position)
if (!pos) return
const id = genId()
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 0 }])
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }])
setSelectedId(null)
} else if (modeRef.current === 'select') {
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 */
terrainVertices: THREE.Vector3[]
gridSize: 64
/** Geodetic origin used for ENU conversions */
/** Geodetic origin used for ENU conversions (shared across all patches) */
origin: [number, number, number]
/** Where along the track this patch is centred [0, 1] */
trackFrac: number
}
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 x = Math.floor((lon + 180) / 360 * 2 ** z)
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
}
const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch
const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track
// ── 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)
}
// ── Core capture logic ────────────────────────────────────────────────────────
// ── Single-patch capture ───────────────────────────────────────────────────────
const ESRI_TILE = (z: number, y: number, x: number) =>
`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`
async function captureTerrainData(
terrainProvider: Cesium.TerrainProvider,
simResult: CoasterSimulationResult,
async function captureTerrainPatch(
viewer: Cesium.Viewer,
origin: [number, number, number],
minLon: number,
maxLon: number,
minLat: number,
maxLat: number,
trackFrac: number,
): 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 ──────────────────────────────────
const allPts = [...simResult.rail_1, ...simResult.rail_2]
let minLon = Infinity, maxLon = -Infinity
let minLat = Infinity, maxLat = -Infinity
for (const [lon, lat] of allPts) {
if (lon < minLon) minLon = lon
if (lon > maxLon) maxLon = lon
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
}
// 10% padding
const dLon = (maxLon - minLon) * 0.1
const dLat = (maxLat - minLat) * 0.1
minLon -= dLon; maxLon += dLon
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--
// ── Find the highest zoom where tile count stays ≤ 25 ────────────────────
let level = maxLevel
while (level > 5) {
const sw = tilingScheme.positionToTileXY(
Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
)
const ne = tilingScheme.positionToTileXY(
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
)
if (!sw || !ne) { level--; continue }
const nx = ne.x - sw.x + 1
const ny = sw.y - ne.y + 1
if (nx >= 1 && ny >= 1 && nx * ny <= 25) break
level--
}
const tMin = lonLatToTile(minLon, maxLat, zoom) // NW corner → smallest tile y
const tMax = lonLatToTile(maxLon, minLat, zoom) // SE corner → largest tile y
const swTile = tilingScheme.positionToTileXY(
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 tileBbox: [number, number, number, number] = [
tileToLon(tMin.x, zoom), // west
tileToLat(tMax.y + 1, zoom), // south
tileToLon(tMax.x + 1, zoom), // east
tileToLat(tMin.y, zoom), // north
]
const tileXMin = swTile.x
const tileXMax = neTile.x
const tileYMin = neTile.y
const tileYMax = swTile.y
const nx = tileXMax - tileXMin + 1
const ny = tileYMax - tileYMin + 1
const TILE_PX = provider.tileWidth
const nx = tMax.x - tMin.x + 1
const ny = tMax.y - tMin.y + 1
const TILE_PX = 256
// ── Fetch satellite tiles in parallel ────────────────────────────────────
// ── Fetch tiles via the provider ─────────────────────────────────────────
const tileImages = await Promise.all(
Array.from({ length: ny }, (_, tj) =>
Array.from({ length: nx }, (_, ti) =>
fetch(ESRI_TILE(zoom, tMin.y + tj, tMin.x + ti))
.then(r => r.blob())
.then(b => createImageBitmap(b))
.then(img => ({ ti, tj, img })),
),
Array.from({ length: nx }, (_, ti) => {
const x = tileXMin + ti
const y = tileYMin + tj
const result = provider.requestImage(x, y, level)
const p: Promise<Cesium.ImageryTypes | undefined> =
result instanceof Promise ? result : Promise.resolve(result ?? undefined)
return p.then(img => ({ ti, tj, img: img ?? null }))
}),
).flat(),
)
@ -123,11 +111,21 @@ async function captureTerrainData(
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
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)
// ── 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 cartographics: Cesium.Cartographic[] = []
for (let j = 0; j < GRID; j++) {
@ -137,8 +135,7 @@ async function captureTerrainData(
cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat))
}
}
const sampled = await Cesium.sampleTerrainMostDetailed(terrainProvider, cartographics)
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics)
// ── Convert to ENU Three.js vectors ──────────────────────────────────────
const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => {
@ -147,7 +144,51 @@ async function captureTerrainData(
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 ─────────────────────────────────────────────────────────────────────
@ -157,13 +198,12 @@ export function useTerrainCapture(
simResult: CoasterSimulationResult | null,
) {
const [status, setStatus] = useState<CaptureStatus>('idle')
const [captureData, setCaptureData] = useState<TerrainCaptureData | null>(null)
const [captureData, setCaptureData] = useState<TerrainCaptureData[] | null>(null)
const abortRef = useRef(false)
useEffect(() => {
// Reset on new result
setCaptureData(null)
abortRef.current = true // cancel any in-flight capture
abortRef.current = true
if (!simResult) {
setStatus('idle')
@ -173,10 +213,10 @@ export function useTerrainCapture(
abortRef.current = false
setStatus('loading')
captureTerrainData(viewer.terrainProvider, simResult)
.then(data => {
captureAllPatches(viewer, simResult)
.then(patches => {
if (abortRef.current) return
setCaptureData(data)
setCaptureData(patches)
setStatus('ready')
})
.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 ----------
export interface ChallengeMapProperties {
id: string
// id lives at the GeoJSON Feature level (f.id), not in properties
title: string
status: ChallengeStatus
submission_count: number
max_submissions: number | null
coaster_count: number
expires_at: string | null
created_at: string
}
@ -94,6 +95,7 @@ export interface ChallengeDetail {
submission_count: number
participant_count: number
is_participating: boolean
coaster_count: number
preview_splats: PreviewSplat[]
expires_at: string | null
created_at: string
@ -130,6 +132,9 @@ export interface SavedCoaster {
name: string
anchors: StoredAnchor[]
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
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 {
position: fixed;
top: 16px;
top: 68px; /* 52px header + 16px gap */
left: 16px;
z-index: 20;
display: flex;

View File

@ -1,64 +1,21 @@
import { useState } from 'react'
import { useChallengeStore } from '../store/challengeStore'
import { useAuthStore } from '../store/authStore'
import { MyChallengesPanel } from '../challenges/MyChallengesPanel'
import { SearchBar } from './SearchBar'
import { OverlayControls } from './OverlayControls'
import styles from './MapOverlay.module.css'
export function MapOverlay() {
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
const { logout } = useAuthStore()
const [showMine, setShowMine] = useState(false)
function handleDrawToggle() {
if (drawingMode) {
setDrawingMode(false)
setDraftPolygon(null)
} else {
setShowMine(false)
setDrawingMode(true)
}
}
const { drawingMode } = useChallengeStore()
return (
<>
<div className={styles.toolbar}>
<SearchBar />
<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 && (
<p className={styles.hint}>
Click to place vertices · Right-click to close polygon
</p>
)}
<button
className={`${styles.btn} ${styles.logout}`}
onClick={() => logout()}
title="Sign out"
>
Sign out
</button>
</div>
{showMine && <MyChallengesPanel onClose={() => setShowMine(false)} />}
</>
)
}

View File

@ -1,6 +1,6 @@
.panel {
position: fixed;
top: 0;
top: 52px; /* below global header */
right: 0;
bottom: 0;
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>
)
}