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:
parent
ff11fe1d2a
commit
42197bfbc9
@ -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 = (
|
||||
|
||||
30
backend/apps/coaster/migrations/0002_coasterrating.py
Normal file
30
backend/apps/coaster/migrations/0002_coasterrating.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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() # 1–5
|
||||
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}★)'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()),
|
||||
]
|
||||
|
||||
@ -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 1–5 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 1–5'},
|
||||
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
|
||||
)
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
11
web/src/api/users.ts
Normal 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/')
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
284
web/src/challenges/ChallengesListPanel.module.css
Normal file
284
web/src/challenges/ChallengesListPanel.module.css
Normal 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;
|
||||
}
|
||||
199
web/src/challenges/ChallengesListPanel.tsx
Normal file
199
web/src/challenges/ChallengesListPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
top: 68px; /* 52px header + 16px gap */
|
||||
left: 140px;
|
||||
z-index: 20;
|
||||
width: 300px;
|
||||
|
||||
75
web/src/challenges/MyChallengesSidePanel.tsx
Normal file
75
web/src/challenges/MyChallengesSidePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
web/src/coaster/AllCoastersPanel.module.css
Normal file
159
web/src/coaster/AllCoastersPanel.module.css
Normal 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);
|
||||
}
|
||||
92
web/src/coaster/AllCoastersPanel.tsx
Normal file
92
web/src/coaster/AllCoastersPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,39 +330,49 @@ 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}>
|
||||
<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>
|
||||
{!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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<button
|
||||
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
|
||||
onClick={handleSimulate}
|
||||
@ -344,13 +400,16 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||
>
|
||||
Strips
|
||||
</button>
|
||||
<button
|
||||
className={styles.ghostBtn}
|
||||
onClick={handleSave}
|
||||
disabled={path.anchors.length < 2}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
{!readonly && (
|
||||
<button
|
||||
className={styles.ghostBtn}
|
||||
onClick={handleSave}
|
||||
disabled={path.anchors.length < 2}
|
||||
>
|
||||
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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}>
|
||||
<span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}>
|
||||
@{c.creator_username}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────
|
||||
// ── 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.
|
||||
|
||||
interface CameraTarget {
|
||||
pos: THREE.Vector3
|
||||
quat: THREE.Quaternion
|
||||
}
|
||||
const _mat = new THREE.Matrix4()
|
||||
const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → −Z toward target
|
||||
|
||||
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()
|
||||
// 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()
|
||||
const pi = Math.max(0, i - 2)
|
||||
const pj = Math.min(count - 1, i1 + 2)
|
||||
_scTang.subVectors(centerline[pj], centerline[pi]).normalize()
|
||||
|
||||
// Track-local "up": cross(tangent, rail1→rail2) 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 rideDataRef = useRef<RideData | null>(null)
|
||||
const isPlayingRef = useRef(false)
|
||||
const rideTimeRef = useRef(0)
|
||||
const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz)
|
||||
const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive)
|
||||
const cameraModeRef = useRef<CameraMode>('first')
|
||||
const prevWallRef = useRef(0)
|
||||
|
||||
// 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)
|
||||
sceneRef.current = scene
|
||||
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) {
|
||||
@ -344,25 +369,40 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
const scene = sceneRef.current
|
||||
if (!renderer || !scene) return
|
||||
|
||||
const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1)
|
||||
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) {
|
||||
const ci = bisect(d.timeArray, rideT)
|
||||
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])
|
||||
const ca = (d.timeArray[ci1] > d.timeArray[ci])
|
||||
? (rideT - d.timeArray[ci]) / (d.timeArray[ci1] - d.timeArray[ci])
|
||||
: 0
|
||||
onRideProgress(d.sFrac[ci] + (d.sFrac[ci1] - d.sFrac[ci]) * ca)
|
||||
@ -371,21 +411,54 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
|
||||
const data = rideDataRef.current
|
||||
if (data && rideT >= data.totalDuration) {
|
||||
rideT = data.totalDuration
|
||||
rideTimeRef.current = rideT
|
||||
rideTimeRef.current = data.totalDuration
|
||||
rideT = data.totalDuration
|
||||
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
|
||||
isPlayingRef.current = true
|
||||
needsInitRef.current = false // don't re-snap if resuming from pause
|
||||
// Restart from beginning if ride has finished
|
||||
const restarting = rideTimeRef.current >= rideDataRef.current.totalDuration - 0.05
|
||||
if (restarting) {
|
||||
rideTimeRef.current = 0
|
||||
userYawRef.current = 0
|
||||
userPitchRef.current = 0
|
||||
setRideTime(0)
|
||||
needsInitRef.current = true // snap camera to start position
|
||||
if (onRideProgress) onRideProgress(0)
|
||||
} else {
|
||||
needsInitRef.current = false // resume with smooth camera
|
||||
}
|
||||
lastTimeUpdateRef.current = -1
|
||||
lastCursorUpdateRef.current = -1
|
||||
isPlayingRef.current = true
|
||||
setIsPlaying(true)
|
||||
}, [])
|
||||
}, [onRideProgress])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
isPlayingRef.current = false
|
||||
@ -433,16 +518,77 @@ 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
|
||||
isPlayingRef.current = true
|
||||
lastTimeUpdateRef.current = -1
|
||||
lastCursorUpdateRef.current = -1
|
||||
isPlayingRef.current = true
|
||||
setIsPlaying(true)
|
||||
}
|
||||
return () => {
|
||||
@ -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)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,80 +46,86 @@ 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(),
|
||||
)
|
||||
|
||||
// ── Stitch into a single OffscreenCanvas ─────────────────────────────────
|
||||
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
|
||||
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
|
||||
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
|
||||
for (const { ti, tj, img } of tileImages) {
|
||||
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,23 +144,66 @@ 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTerrainCapture(
|
||||
viewer: Cesium.Viewer,
|
||||
viewer: Cesium.Viewer,
|
||||
simResult: CoasterSimulationResult | null,
|
||||
) {
|
||||
const [status, setStatus] = useState<CaptureStatus>('idle')
|
||||
const [captureData, setCaptureData] = useState<TerrainCaptureData | null>(null)
|
||||
const [status, setStatus] = useState<CaptureStatus>('idle')
|
||||
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
19
web/src/store/uiStore.ts
Normal 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 } : {}) }),
|
||||
}))
|
||||
@ -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
|
||||
}
|
||||
|
||||
122
web/src/ui/Header.module.css
Normal file
122
web/src/ui/Header.module.css
Normal 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
81
web/src/ui/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
top: 68px; /* 52px header + 16px gap */
|
||||
left: 16px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
|
||||
@ -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 />
|
||||
<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)} />}
|
||||
</>
|
||||
{drawingMode && (
|
||||
<p className={styles.hint}>
|
||||
Click to place vertices · Right-click to close polygon
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: 52px; /* below global header */
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
|
||||
40
web/src/ui/StarRating.module.css
Normal file
40
web/src/ui/StarRating.module.css
Normal 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
37
web/src/ui/StarRating.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import styles from './StarRating.module.css'
|
||||
|
||||
interface Props {
|
||||
value: number | null // average or selected rating (1–5)
|
||||
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>
|
||||
)
|
||||
}
|
||||
215
web/src/users/UserProfilePage.module.css
Normal file
215
web/src/users/UserProfilePage.module.css
Normal 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);
|
||||
}
|
||||
96
web/src/users/UserProfilePage.tsx
Normal file
96
web/src/users/UserProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user