add physics-based coaster editor
This commit is contained in:
parent
d93412cd0d
commit
b38b8be3e3
@ -7,6 +7,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libgeos-dev \
|
libgeos-dev \
|
||||||
libproj-dev \
|
libproj-dev \
|
||||||
binutils \
|
binutils \
|
||||||
|
libgl1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# X11/OpenGL runtime libs required by OCP (OpenCASCADE Python bindings used by build123d)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libxrender1 \
|
||||||
|
libxi6 \
|
||||||
|
libxext6 \
|
||||||
|
libx11-6 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
ENV GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
||||||
|
|||||||
@ -72,10 +72,12 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_region(self, obj):
|
def get_region(self, obj):
|
||||||
return obj.region.geojson if obj.region else None
|
import json
|
||||||
|
return json.loads(obj.region.geojson) if obj.region else None
|
||||||
|
|
||||||
def get_region_centroid(self, obj):
|
def get_region_centroid(self, obj):
|
||||||
return obj.region_centroid.geojson if obj.region_centroid else None
|
import json
|
||||||
|
return json.loads(obj.region_centroid.geojson) if obj.region_centroid else None
|
||||||
|
|
||||||
def get_participant_count(self, obj):
|
def get_participant_count(self, obj):
|
||||||
return obj.participants.count()
|
return obj.participants.count()
|
||||||
|
|||||||
@ -28,7 +28,14 @@ def _parse_bbox(bbox_str):
|
|||||||
|
|
||||||
class ChallengeListCreateView(APIView):
|
class ChallengeListCreateView(APIView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
qs = Challenge.objects.all()
|
qs = Challenge.objects.select_related("creator")
|
||||||
|
|
||||||
|
# ?mine=true — return the authenticated user's own challenges, all statuses
|
||||||
|
if request.query_params.get("mine") == "true":
|
||||||
|
qs = qs.filter(creator=request.user)
|
||||||
|
return Response(
|
||||||
|
ChallengeDetailSerializer(qs, many=True, context={"request": request}).data
|
||||||
|
)
|
||||||
|
|
||||||
status_filter = request.query_params.get("status", "active")
|
status_filter = request.query_params.get("status", "active")
|
||||||
qs = qs.filter(status=status_filter)
|
qs = qs.filter(status=status_filter)
|
||||||
|
|||||||
1
backend/apps/coaster/__init__.py
Normal file
1
backend/apps/coaster/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
default_app_config = 'apps.coaster.apps.CoasterConfig'
|
||||||
6
backend/apps/coaster/apps.py
Normal file
6
backend/apps/coaster/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoasterConfig(AppConfig):
|
||||||
|
name = 'apps.coaster'
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
97
backend/apps/coaster/mesh.py
Normal file
97
backend/apps/coaster/mesh.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
GLB 3D model builder for roller coaster rails.
|
||||||
|
|
||||||
|
Builds a tube mesh along each rail path using pure numpy + trimesh (no extra
|
||||||
|
deps). build123d is installed for future complex solid geometry work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _tube_mesh(path: np.ndarray, radius: float, segments: int = 8):
|
||||||
|
"""
|
||||||
|
Create a trimesh.Trimesh tube swept along a 3-D polyline path.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
path : (n, 3) float path centreline in metres
|
||||||
|
radius : float tube radius in metres
|
||||||
|
segments : int number of cross-section vertices
|
||||||
|
"""
|
||||||
|
import trimesh # type: ignore
|
||||||
|
|
||||||
|
n = len(path)
|
||||||
|
angles = np.linspace(0, 2 * np.pi, segments, endpoint=False)
|
||||||
|
circle_xy = np.column_stack([np.cos(angles), np.sin(angles)]) # (seg, 2)
|
||||||
|
|
||||||
|
rings = []
|
||||||
|
for i in range(n):
|
||||||
|
# Tangent vector
|
||||||
|
if i == 0:
|
||||||
|
t = path[1] - path[0]
|
||||||
|
elif i == n - 1:
|
||||||
|
t = path[-1] - path[-2]
|
||||||
|
else:
|
||||||
|
t = path[i + 1] - path[i - 1]
|
||||||
|
t = t / (np.linalg.norm(t) + 1e-12)
|
||||||
|
|
||||||
|
# Build an orthonormal frame (right, up2) perpendicular to t
|
||||||
|
world_up = np.array([0.0, 0.0, 1.0])
|
||||||
|
if abs(np.dot(t, world_up)) > 0.9:
|
||||||
|
world_up = np.array([1.0, 0.0, 0.0])
|
||||||
|
right = np.cross(t, world_up)
|
||||||
|
right /= np.linalg.norm(right) + 1e-12
|
||||||
|
up2 = np.cross(right, t)
|
||||||
|
|
||||||
|
# Place the circle ring at path[i]
|
||||||
|
ring = path[i] + (circle_xy[:, 0:1] * right + circle_xy[:, 1:2] * up2) * radius
|
||||||
|
rings.append(ring)
|
||||||
|
|
||||||
|
verts = np.array(rings).reshape(-1, 3) # (n * segments, 3)
|
||||||
|
|
||||||
|
# Stitch adjacent rings into quads (two triangles each)
|
||||||
|
faces = []
|
||||||
|
for i in range(n - 1):
|
||||||
|
for j in range(segments):
|
||||||
|
j1 = (j + 1) % segments
|
||||||
|
a = i * segments + j
|
||||||
|
b = i * segments + j1
|
||||||
|
c = (i + 1) * segments + j
|
||||||
|
d = (i + 1) * segments + j1
|
||||||
|
faces.append([a, b, d])
|
||||||
|
faces.append([a, d, c])
|
||||||
|
|
||||||
|
return trimesh.Trimesh(vertices=verts, faces=np.array(faces), process=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_glb(rail_1_pts: np.ndarray, rail_2_pts: np.ndarray, radius_m: float = 0.05) -> bytes:
|
||||||
|
"""
|
||||||
|
Build a GLB model containing two solid rail tubes.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
rail_1_pts, rail_2_pts : np.ndarray shape (n, 3) metres, local ENU frame
|
||||||
|
radius_m : float tube radius in metres
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bytes GLB binary data
|
||||||
|
"""
|
||||||
|
import trimesh # type: ignore
|
||||||
|
|
||||||
|
logger.info("Building rail tubes (radius=%.3f m)…", radius_m)
|
||||||
|
mesh_1 = _tube_mesh(rail_1_pts, radius_m)
|
||||||
|
mesh_2 = _tube_mesh(rail_2_pts, radius_m)
|
||||||
|
|
||||||
|
# Steel-grey colour
|
||||||
|
for mesh in (mesh_1, mesh_2):
|
||||||
|
mesh.visual.face_colors = [180, 185, 192, 255]
|
||||||
|
|
||||||
|
scene = trimesh.Scene([mesh_1, mesh_2])
|
||||||
|
glb_bytes = scene.export(file_type="glb")
|
||||||
|
logger.info("GLB size: %d bytes", len(glb_bytes))
|
||||||
|
return glb_bytes
|
||||||
36
backend/apps/coaster/migrations/0001_initial.py
Normal file
36
backend/apps/coaster/migrations/0001_initial.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2026-04-21 02:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('challenges', '0002_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Coaster',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('anchors', models.JSONField(default=list)),
|
||||||
|
('acceleration_strips', models.JSONField(default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coasters', to='challenges.challenge')),
|
||||||
|
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coasters', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
'unique_together': {('creator', 'challenge')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/coaster/migrations/__init__.py
Normal file
0
backend/apps/coaster/migrations/__init__.py
Normal file
31
backend/apps/coaster/models.py
Normal file
31
backend/apps/coaster/models.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Coaster(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
creator = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='coasters',
|
||||||
|
)
|
||||||
|
challenge = models.ForeignKey(
|
||||||
|
'challenges.Challenge',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='coasters',
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
# Each anchor: {id, lon, lat, terrainAlt, heightOffset}
|
||||||
|
anchors = models.JSONField(default=list)
|
||||||
|
# Each strip: {id, startFrac, endFrac, accel_ms2}
|
||||||
|
acceleration_strips = models.JSONField(default=list)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [('creator', 'challenge')]
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.creator.username} / {self.challenge_id}'
|
||||||
427
backend/apps/coaster/physics.py
Normal file
427
backend/apps/coaster/physics.py
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Physics-Based Roller Coaster Rail Generator
|
||||||
|
============================================
|
||||||
|
Refactored from the project root for backend use.
|
||||||
|
|
||||||
|
Removed: build123d, ocp_vscode, sample_wire(), __main__ block.
|
||||||
|
All core mathematics is pure numpy.
|
||||||
|
|
||||||
|
Public API
|
||||||
|
----------
|
||||||
|
generate_rails(points_m, **kwargs) -> (rail_1_pts, rail_2_pts)
|
||||||
|
Takes a (n, 3) numpy array of centreline points in metres (local frame)
|
||||||
|
and returns two (m, 3) numpy arrays of rail points in metres.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
GRAVITY = 9.81 # m/s²
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utilities ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize(v):
|
||||||
|
mag = np.linalg.norm(v)
|
||||||
|
if mag < 1e-12:
|
||||||
|
return np.zeros_like(v) if hasattr(v, "__len__") else 0
|
||||||
|
return v / mag
|
||||||
|
|
||||||
|
|
||||||
|
def arc_length(points):
|
||||||
|
n = len(points)
|
||||||
|
s = np.zeros(n)
|
||||||
|
for i in range(1, n):
|
||||||
|
s[i] = s[i - 1] + np.linalg.norm(points[i] - points[i - 1])
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_spline_and_sample(pts: np.ndarray, n: int):
|
||||||
|
"""
|
||||||
|
Fit a cubic B-spline through *pts* (n_ctrl × 3, in metres) and sample *n*
|
||||||
|
evenly-spaced points along it.
|
||||||
|
|
||||||
|
Returns (positions, arc_lengths, unit_tangents, curvatures, curvature_vectors)
|
||||||
|
where derivatives are evaluated **analytically** from the spline — not via
|
||||||
|
finite differences on a piecewise-linear path. This avoids the curvature
|
||||||
|
spikes that occur at polyline vertices and are the primary source of jitter
|
||||||
|
in the binormal / rail computation.
|
||||||
|
|
||||||
|
The spline is clamped at both ends: the tangent direction at the first and
|
||||||
|
last sampled points matches the direction of the first and last input
|
||||||
|
segments respectively.
|
||||||
|
"""
|
||||||
|
from scipy.interpolate import make_interp_spline
|
||||||
|
|
||||||
|
s_raw = arc_length(pts)
|
||||||
|
if s_raw[-1] < 1e-9:
|
||||||
|
raise ValueError("Path has zero length")
|
||||||
|
|
||||||
|
# Normalise to [0, 1] and remove any duplicate parameter values.
|
||||||
|
u_raw = s_raw / s_raw[-1]
|
||||||
|
mask = np.concatenate([[True], np.diff(u_raw) > 1e-12])
|
||||||
|
pts_c = pts[mask]
|
||||||
|
u_c = u_raw[mask]
|
||||||
|
|
||||||
|
k_order = min(3, len(pts_c) - 1) # cubic when ≥ 4 pts, else lower
|
||||||
|
|
||||||
|
# Clamped boundary conditions: fix dr/du at both endpoints so the spline
|
||||||
|
# leaves / arrives in the direction of the first / last input segment.
|
||||||
|
if k_order == 3 and len(pts_c) >= 4:
|
||||||
|
tang_start = (pts_c[1] - pts_c[0]) / (u_c[1] - u_c[0])
|
||||||
|
tang_end = (pts_c[-1] - pts_c[-2]) / (u_c[-1] - u_c[-2])
|
||||||
|
bc_type = ([(1, tang_start)], [(1, tang_end)])
|
||||||
|
else:
|
||||||
|
bc_type = None
|
||||||
|
|
||||||
|
bsp = make_interp_spline(u_c, pts_c, k=k_order, bc_type=bc_type)
|
||||||
|
|
||||||
|
u_new = np.linspace(0, 1, n)
|
||||||
|
r0 = bsp(u_new) # positions, shape (n, 3)
|
||||||
|
r1 = bsp(u_new, 1) # dr/du, shape (n, 3)
|
||||||
|
r2 = bsp(u_new, 2) # d²r/du², shape (n, 3)
|
||||||
|
|
||||||
|
s_new = arc_length(r0)
|
||||||
|
|
||||||
|
# Unit tangent T = r1 / |r1|
|
||||||
|
r1_mag = np.linalg.norm(r1, axis=1, keepdims=True)
|
||||||
|
r1_mag = np.where(r1_mag < 1e-12, 1e-12, r1_mag)
|
||||||
|
T = r1 / r1_mag
|
||||||
|
|
||||||
|
# Curvature vector (w.r.t. arc-length parameter s):
|
||||||
|
# κ_vec = (r2 − (r2 · T) T) / |r1|²
|
||||||
|
r2_along_T = np.sum(r2 * T, axis=1, keepdims=True)
|
||||||
|
k_vector = (r2 - r2_along_T * T) / (r1_mag ** 2)
|
||||||
|
k = np.linalg.norm(k_vector, axis=1)
|
||||||
|
|
||||||
|
return r0, s_new, T, k, k_vector
|
||||||
|
|
||||||
|
|
||||||
|
# ── Differential geometry ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compute_geometry(points, s):
|
||||||
|
"""Return T, k, k_vector, dk along the path."""
|
||||||
|
n = len(points)
|
||||||
|
|
||||||
|
T = np.zeros_like(points)
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
ds = s[i + 1] - s[i - 1]
|
||||||
|
if ds > 1e-12:
|
||||||
|
T[i] = (points[i + 1] - points[i - 1]) / ds
|
||||||
|
T[0], T[-1] = T[1], T[-2]
|
||||||
|
T = np.array([normalize(t) for t in T])
|
||||||
|
|
||||||
|
k_vector = np.zeros_like(points)
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
ds1 = s[i] - s[i - 1]
|
||||||
|
ds2 = s[i + 1] - s[i]
|
||||||
|
if ds1 > 1e-12 and ds2 > 1e-12:
|
||||||
|
k_vector[i] = (
|
||||||
|
2 * ((points[i + 1] - points[i]) / ds2 - (points[i] - points[i - 1]) / ds1)
|
||||||
|
/ (ds1 + ds2)
|
||||||
|
)
|
||||||
|
k_vector[0], k_vector[-1] = k_vector[1], k_vector[-2]
|
||||||
|
|
||||||
|
k = np.linalg.norm(k_vector, axis=1)
|
||||||
|
dk = np.gradient(k, s)
|
||||||
|
|
||||||
|
return T, k, k_vector, dk
|
||||||
|
|
||||||
|
|
||||||
|
# ── Junction detection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_junctions(k, dk, threshold_percentile=88.0, min_separation=50):
|
||||||
|
abs_dk = np.abs(dk)
|
||||||
|
d2k = np.abs(np.gradient(dk))
|
||||||
|
severity = abs_dk + 2 * d2k
|
||||||
|
threshold = np.percentile(severity, threshold_percentile)
|
||||||
|
|
||||||
|
junctions = []
|
||||||
|
for i in range(10, len(severity) - 10):
|
||||||
|
if severity[i] > threshold and severity[i] > severity[i - 1] and severity[i] > severity[i + 1]:
|
||||||
|
junctions.append((i, severity[i]))
|
||||||
|
|
||||||
|
if junctions:
|
||||||
|
junctions.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
merged = [junctions[0][0]]
|
||||||
|
for j_idx, _ in junctions[1:]:
|
||||||
|
if all(abs(j_idx - m) > min_separation for m in merged):
|
||||||
|
merged.append(j_idx)
|
||||||
|
junctions = sorted(merged)
|
||||||
|
|
||||||
|
return junctions
|
||||||
|
|
||||||
|
|
||||||
|
# ── G2-continuous transitions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def quintic_hermite_blend(t, p0, v0, a0, p1, v1, a1):
|
||||||
|
t2, t3, t4, t5 = t**2, t**3, t**4, t**5
|
||||||
|
h0 = 1 - 10*t3 + 15*t4 - 6*t5
|
||||||
|
h1 = t - 6*t3 + 8*t4 - 3*t5
|
||||||
|
h2 = 0.5*t2 - 1.5*t3 + 1.5*t4 - 0.5*t5
|
||||||
|
h3 = 10*t3 - 15*t4 + 6*t5
|
||||||
|
h4 = -4*t3 + 7*t4 - 3*t5
|
||||||
|
h5 = 0.5*t3 - t4 + 0.5*t5
|
||||||
|
return h0*p0 + h1*v0 + h2*a0 + h3*p1 + h4*v1 + h5*a1
|
||||||
|
|
||||||
|
|
||||||
|
def create_g2_transition(p_start, T_start, k_vector_start, p_end, T_end, k_vector_end, n_points=100):
|
||||||
|
L = np.linalg.norm(p_end - p_start)
|
||||||
|
t = np.linspace(0, 1, n_points)
|
||||||
|
v0, v1 = T_start * L, T_end * L
|
||||||
|
a0, a1 = k_vector_start * L**2, k_vector_end * L**2
|
||||||
|
pts = np.zeros((n_points, 3))
|
||||||
|
for i, ti in enumerate(t):
|
||||||
|
pts[i] = quintic_hermite_blend(ti, p_start, v0, a0, p_end, v1, a1)
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def replace_junctions_with_g2_transitions(points, s, junctions, window_length=0.025):
|
||||||
|
if not junctions:
|
||||||
|
return points
|
||||||
|
|
||||||
|
T, k, k_vector, dk = compute_geometry(points, s)
|
||||||
|
pts_new = points.copy()
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
for jidx in junctions:
|
||||||
|
hw = window_length / 2
|
||||||
|
si = np.searchsorted(s, s[jidx] - hw)
|
||||||
|
ei = np.searchsorted(s, s[jidx] + hw)
|
||||||
|
si = max(5, si)
|
||||||
|
ei = min(len(points) - 5, ei)
|
||||||
|
if ei - si >= 10:
|
||||||
|
windows.append((jidx, si, ei))
|
||||||
|
|
||||||
|
replaced = []
|
||||||
|
for jidx, si, ei in windows:
|
||||||
|
if any(not (ei < rs or re < si) for rs, re in replaced):
|
||||||
|
continue
|
||||||
|
n_t = ei - si + 1
|
||||||
|
transition = create_g2_transition(
|
||||||
|
pts_new[si], T[si], k_vector[si],
|
||||||
|
pts_new[ei], T[ei], k_vector[ei],
|
||||||
|
n_points=n_t,
|
||||||
|
)
|
||||||
|
pts_new[si:ei + 1] = transition
|
||||||
|
replaced.append((si, ei))
|
||||||
|
|
||||||
|
return pts_new
|
||||||
|
|
||||||
|
|
||||||
|
# ── Physics simulation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compute_velocity_profile(points, s, mass, v0, friction_coeff, g_scaled,
|
||||||
|
acceleration_strips=None):
|
||||||
|
n = len(points)
|
||||||
|
s_total = s[-1] if s[-1] > 0 else 1.0
|
||||||
|
v = np.zeros(n)
|
||||||
|
v[0] = v0
|
||||||
|
for i in range(1, n):
|
||||||
|
d = np.linalg.norm(points[i] - points[i - 1])
|
||||||
|
dh = points[i - 1][2] - points[i][2]
|
||||||
|
friction_work = friction_coeff * mass * g_scaled * d
|
||||||
|
strip_accel = 0.0
|
||||||
|
if acceleration_strips:
|
||||||
|
s_frac_i = s[i] / s_total
|
||||||
|
for strip in acceleration_strips:
|
||||||
|
if strip['start_frac'] <= s_frac_i <= strip['end_frac']:
|
||||||
|
strip_accel += strip['accel_ms2']
|
||||||
|
v2 = v[i - 1]**2 + 2 * g_scaled * dh - 2 * friction_work / mass + 2 * strip_accel * d
|
||||||
|
v[i] = np.sqrt(max(v2, 0))
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def compute_hanging_binormals(points, velocities, T, k, k_vector, g_scaled):
|
||||||
|
n = len(points)
|
||||||
|
gravity = np.array([0.0, 0.0, -g_scaled])
|
||||||
|
B = np.zeros((n, 3))
|
||||||
|
|
||||||
|
def _apparent(i):
|
||||||
|
T_i = normalize(T[i])
|
||||||
|
if k[i] < 1e-9:
|
||||||
|
f = gravity
|
||||||
|
else:
|
||||||
|
R = 1 / k[i]
|
||||||
|
N_in = normalize(k_vector[i])
|
||||||
|
f = gravity + (velocities[i]**2 / R) * (-N_in)
|
||||||
|
perp = f - np.dot(f, T_i) * T_i
|
||||||
|
return normalize(perp)
|
||||||
|
|
||||||
|
B[0] = _apparent(0)
|
||||||
|
if B[0][2] > 0:
|
||||||
|
B[0] = -B[0]
|
||||||
|
|
||||||
|
for i in range(1, n):
|
||||||
|
b = _apparent(i)
|
||||||
|
if np.dot(b, B[i - 1]) < 0:
|
||||||
|
b = -b
|
||||||
|
B[i] = b
|
||||||
|
|
||||||
|
return B
|
||||||
|
|
||||||
|
|
||||||
|
def smooth_binormals(B, tangents, iterations=5):
|
||||||
|
B_s = B.copy()
|
||||||
|
n = len(B)
|
||||||
|
for _ in range(iterations):
|
||||||
|
B_new = B_s.copy()
|
||||||
|
for i in range(2, n - 2):
|
||||||
|
avg = (B_s[i - 1] + 2 * B_s[i] + B_s[i + 1]) / 4
|
||||||
|
T_i = normalize(tangents[i])
|
||||||
|
avg_perp = avg - np.dot(avg, T_i) * T_i
|
||||||
|
B_new[i] = normalize(avg_perp)
|
||||||
|
B_s = B_new
|
||||||
|
return B_s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Diagnostics ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compute_diagnostics(points, velocities, k, B) -> dict:
|
||||||
|
"""Return a dict of ride-quality metrics (no side effects)."""
|
||||||
|
g_forces = []
|
||||||
|
for i in range(len(points)):
|
||||||
|
if k[i] > 1e-6:
|
||||||
|
R = 1 / k[i]
|
||||||
|
if R > 0.002:
|
||||||
|
g_forces.append(velocities[i]**2 / R / GRAVITY)
|
||||||
|
|
||||||
|
stall_idx = np.where(velocities < 0.01)[0]
|
||||||
|
binormal_changes = np.array([np.linalg.norm(B[i] - B[i - 1]) for i in range(1, len(B))])
|
||||||
|
down_pct = 100 * np.sum(B[:, 2] < 0) / len(B)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"height_range_m": [float(np.min(points[:, 2])), float(np.max(points[:, 2]))],
|
||||||
|
"velocity_range_ms": [float(np.min(velocities)), float(np.max(velocities))],
|
||||||
|
"g_force_range": [float(min(g_forces)), float(max(g_forces))] if g_forces else None,
|
||||||
|
"stall_at_pct": float(100 * stall_idx[0] / len(points)) if len(stall_idx) else None,
|
||||||
|
"binormal_variation_max": float(np.max(binormal_changes)),
|
||||||
|
"binormal_downward_pct": float(down_pct),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Profile arrays ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_profile_arrays(s, velocities, k, downsample_factor) -> dict:
|
||||||
|
"""Return downsampled per-point arrays for frontend charting."""
|
||||||
|
dvds = np.gradient(velocities, s)
|
||||||
|
accel = velocities * dvds # dv/dt = v * dv/ds (m/s²)
|
||||||
|
gf = (velocities ** 2 * k) / GRAVITY
|
||||||
|
|
||||||
|
idx = np.arange(0, len(s), downsample_factor)
|
||||||
|
s_ds = s[idx]
|
||||||
|
s_frac = (s_ds / s_ds[-1]).tolist() if s_ds[-1] > 0 else s_ds.tolist()
|
||||||
|
|
||||||
|
# Total arc length and ride duration (integrate dt = ds/v).
|
||||||
|
# Floor at 1 m/s so that isolated transient-zero points (coaster just
|
||||||
|
# barely crests a hill, sqrt(max(v2,0)) == 0 for one step) each
|
||||||
|
# contribute at most ~2 s instead of ~1400 s to the integral.
|
||||||
|
total_length_m = float(s[-1])
|
||||||
|
safe_v = np.maximum(velocities, 1.0)
|
||||||
|
total_duration_s = float(np.trapz(1.0 / safe_v, s))
|
||||||
|
|
||||||
|
return {
|
||||||
|
's_frac': s_frac,
|
||||||
|
'velocity_ms': velocities[idx].tolist(),
|
||||||
|
'accel_ms2': accel[idx].tolist(),
|
||||||
|
'g_force': gf[idx].tolist(),
|
||||||
|
'total_length_m': total_length_m,
|
||||||
|
'total_duration_s': total_duration_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_rails(
|
||||||
|
points_m: np.ndarray,
|
||||||
|
rail_spacing: float = 1.5,
|
||||||
|
mass: float = 1000.0,
|
||||||
|
initial_velocity: float = 1.0,
|
||||||
|
friction_coeff: float = 0.02,
|
||||||
|
junction_window_length: float = 5.0,
|
||||||
|
junction_threshold: float = 88.0,
|
||||||
|
binormal_smooth_iterations: int = 5,
|
||||||
|
downsample_factor: int = 10,
|
||||||
|
internal_steps: int = 5000,
|
||||||
|
acceleration_strips: list | None = None,
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Generate physics-based roller coaster rails from a centreline path.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
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).
|
||||||
|
mass : float
|
||||||
|
Mass of the coaster car in kg, used for friction losses.
|
||||||
|
initial_velocity : float
|
||||||
|
Speed at the start of the track in m/s.
|
||||||
|
friction_coeff : float
|
||||||
|
Rolling resistance coefficient (steel-on-steel ≈ 0.02).
|
||||||
|
junction_window_length : float
|
||||||
|
Arc-length of G2 transition windows in metres.
|
||||||
|
junction_threshold : float
|
||||||
|
Percentile threshold for curvature-discontinuity detection (85–95).
|
||||||
|
binormal_smooth_iterations : int
|
||||||
|
Number of binormal smoothing passes.
|
||||||
|
downsample_factor : int
|
||||||
|
Output rail points = internal_steps / downsample_factor.
|
||||||
|
internal_steps : int
|
||||||
|
Number of points used internally for the simulation.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
rail_1_pts, rail_2_pts : np.ndarray shape (m, 3)
|
||||||
|
Left and right rail positions in metres (same local frame as input).
|
||||||
|
diagnostics : dict
|
||||||
|
Ride-quality metrics.
|
||||||
|
"""
|
||||||
|
if len(points_m) < 2:
|
||||||
|
raise ValueError("Need at least 2 path points")
|
||||||
|
|
||||||
|
# Scale resolution with path length: 1 000 steps per km (≈ 1 per metre).
|
||||||
|
# The caller-supplied internal_steps acts as a lower bound.
|
||||||
|
approx_length = float(arc_length(points_m)[-1])
|
||||||
|
internal_steps = max(internal_steps, int(approx_length))
|
||||||
|
|
||||||
|
# Fit a cubic B-spline through the control points and evaluate positions
|
||||||
|
# plus analytical tangents/curvatures at internal_steps evenly-spaced samples.
|
||||||
|
# This eliminates the curvature spikes that arise from computing finite
|
||||||
|
# differences on a piecewise-linear polyline approximation.
|
||||||
|
pts, s, T, k, k_vector = _fit_spline_and_sample(points_m, internal_steps)
|
||||||
|
dk = np.gradient(k, s)
|
||||||
|
|
||||||
|
# Junction detection + G2 smoothing (handles any remaining curvature
|
||||||
|
# discontinuities, e.g. from anchors placed very close together).
|
||||||
|
avg_spacing = s[-1] / len(pts)
|
||||||
|
min_sep = int((junction_window_length * 1.2) / avg_spacing)
|
||||||
|
junctions = detect_junctions(k, dk, threshold_percentile=junction_threshold, min_separation=min_sep)
|
||||||
|
|
||||||
|
if junctions:
|
||||||
|
pts = replace_junctions_with_g2_transitions(pts, s, junctions, window_length=junction_window_length)
|
||||||
|
s = arc_length(pts)
|
||||||
|
# Recompute geometry via finite differences only after the path has been
|
||||||
|
# modified; for the common (no-junction) path the spline values are used.
|
||||||
|
T, k, k_vector, dk = compute_geometry(pts, s)
|
||||||
|
|
||||||
|
# Physics
|
||||||
|
velocities = compute_velocity_profile(pts, s, mass, initial_velocity, friction_coeff, GRAVITY,
|
||||||
|
acceleration_strips=acceleration_strips)
|
||||||
|
B = compute_hanging_binormals(pts, velocities, T, k, k_vector, GRAVITY)
|
||||||
|
if binormal_smooth_iterations > 0:
|
||||||
|
B = smooth_binormals(B, T, iterations=binormal_smooth_iterations)
|
||||||
|
|
||||||
|
diag = compute_diagnostics(pts, velocities, k, B)
|
||||||
|
profile = build_profile_arrays(s, velocities, k, downsample_factor)
|
||||||
|
|
||||||
|
# Rail positions: offset perpendicular to tangent within the binormal plane
|
||||||
|
crosses = np.cross(B, T) # (n, 3)
|
||||||
|
rail_1 = pts + rail_spacing * crosses
|
||||||
|
rail_2 = pts - rail_spacing * crosses
|
||||||
|
|
||||||
|
# Downsample
|
||||||
|
rail_1 = rail_1[::downsample_factor]
|
||||||
|
rail_2 = rail_2[::downsample_factor]
|
||||||
|
|
||||||
|
return rail_1, rail_2, diag, profile
|
||||||
14
backend/apps/coaster/serializers.py
Normal file
14
backend/apps/coaster/serializers.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Coaster
|
||||||
|
|
||||||
|
|
||||||
|
class CoasterSerializer(serializers.ModelSerializer):
|
||||||
|
creator_username = serializers.ReadOnlyField(source='creator.username')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Coaster
|
||||||
|
fields = [
|
||||||
|
'id', 'creator_username', 'challenge', 'name',
|
||||||
|
'anchors', 'acceleration_strips', 'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'creator_username', 'created_at', 'updated_at']
|
||||||
8
backend/apps/coaster/urls.py
Normal file
8
backend/apps/coaster/urls.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("simulate/", CoasterSimulateView.as_view()),
|
||||||
|
path("challenges/<uuid:challenge_id>/coasters/", CoasterListCreateView.as_view()),
|
||||||
|
path("<uuid:pk>/", CoasterDeleteView.as_view()),
|
||||||
|
]
|
||||||
290
backend/apps/coaster/views.py
Normal file
290
backend/apps/coaster/views.py
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
POST /api/v1/coaster/simulate/
|
||||||
|
|
||||||
|
Accepts a list of geographic coordinates, runs the physics-based rail
|
||||||
|
generator, optionally builds a GLB model, and returns the rail point arrays
|
||||||
|
plus a presigned S3 URL for the model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
# numpy and physics deps are imported lazily inside the view so the server
|
||||||
|
# can start (and serve all other endpoints) even before the container has been
|
||||||
|
# rebuilt with the new requirements.
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Coordinate helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_R_EARTH = 6_371_000.0 # metres
|
||||||
|
|
||||||
|
|
||||||
|
def _to_enu(coords: list) -> tuple:
|
||||||
|
"""
|
||||||
|
Convert [[lon, lat, alt], …] to a local ENU numpy array (metres).
|
||||||
|
|
||||||
|
Uses a flat-Earth approximation; accurate to < 0.01 % for areas < 10 km.
|
||||||
|
Returns (points_m, origin) where origin = (lon0, lat0, alt0).
|
||||||
|
"""
|
||||||
|
lon0, lat0, alt0 = coords[0]
|
||||||
|
cos_lat = math.cos(math.radians(lat0))
|
||||||
|
pts = []
|
||||||
|
for lon, lat, alt in coords:
|
||||||
|
x = (lon - lon0) * math.radians(1) * cos_lat * _R_EARTH # East
|
||||||
|
y = (lat - lat0) * math.radians(1) * _R_EARTH # North
|
||||||
|
z = alt - alt0 # Up
|
||||||
|
pts.append([x, y, z])
|
||||||
|
import numpy as np # lazy
|
||||||
|
return np.array(pts), (lon0, lat0, alt0)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_enu(pts_m, origin: tuple) -> list:
|
||||||
|
"""Convert local ENU numpy array back to [[lon, lat, alt], …]."""
|
||||||
|
lon0, lat0, alt0 = origin
|
||||||
|
cos_lat = math.cos(math.radians(lat0))
|
||||||
|
result = []
|
||||||
|
for x, y, z in pts_m:
|
||||||
|
lon = lon0 + x / (cos_lat * _R_EARTH) * math.degrees(1)
|
||||||
|
lat = lat0 + y / _R_EARTH * math.degrees(1)
|
||||||
|
alt = alt0 + z
|
||||||
|
result.append([lon, lat, alt])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── S3 upload ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _upload_glb(glb_bytes: bytes) -> str | None:
|
||||||
|
"""
|
||||||
|
Upload GLB bytes to S3 and return a presigned GET URL (1 h).
|
||||||
|
Returns None in development (no S3 configured).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from apps.utils.storage import _is_s3_storage, _get_client
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
if not _is_s3_storage():
|
||||||
|
logger.info("S3 not configured — skipping GLB upload")
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = f"coaster/models/{uuid.uuid4()}.glb"
|
||||||
|
client = _get_client()
|
||||||
|
client.put_object(
|
||||||
|
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
Body=glb_bytes,
|
||||||
|
ContentType="model/gltf-binary",
|
||||||
|
)
|
||||||
|
return client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": key},
|
||||||
|
ExpiresIn=3600,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("GLB upload failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Allowed simulation params ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_PARAM_SCHEMA = {
|
||||||
|
"rail_spacing": (float, 0.5, 10.0, 1.5),
|
||||||
|
"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),
|
||||||
|
"junction_window_length": (float, 0.5, 50.0, 5.0),
|
||||||
|
"junction_threshold": (float, 50.0, 99.0, 88.0),
|
||||||
|
"binormal_smooth_iterations": (int, 0, 50, 5),
|
||||||
|
"downsample_factor": (int, 1, 100, 10),
|
||||||
|
"internal_steps": (int, 500, 20000, 5000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_strips(raw) -> list:
|
||||||
|
"""
|
||||||
|
Validate and sanitize acceleration strip list from request body.
|
||||||
|
Each strip: { start_frac: float, end_frac: float, accel_ms2: float }
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sf = float(item['start_frac'])
|
||||||
|
ef = float(item['end_frac'])
|
||||||
|
ac = float(item['accel_ms2'])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
sf = max(0.0, min(1.0, sf))
|
||||||
|
ef = max(0.0, min(1.0, ef))
|
||||||
|
ac = max(-50.0, min(50.0, ac))
|
||||||
|
if sf >= ef:
|
||||||
|
continue
|
||||||
|
result.append({'start_frac': sf, 'end_frac': ef, 'accel_ms2': ac})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_params(raw: dict) -> dict:
|
||||||
|
out = {}
|
||||||
|
for name, (typ, lo, hi, default) in _PARAM_SCHEMA.items():
|
||||||
|
val = raw.get(name, default)
|
||||||
|
try:
|
||||||
|
val = typ(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
val = default
|
||||||
|
out[name] = max(lo, min(hi, val))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── View ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CoasterSimulateView(APIView):
|
||||||
|
"""
|
||||||
|
POST /api/v1/coaster/simulate/
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"path": [[lon, lat, alt_m], …], // ≥ 2 points
|
||||||
|
"params": { … } // optional overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
{
|
||||||
|
"rail_1": [[lon, lat, alt], …],
|
||||||
|
"rail_2": [[lon, lat, alt], …],
|
||||||
|
"origin": [lon0, lat0, alt0],
|
||||||
|
"model_url": "<presigned URL or null>",
|
||||||
|
"diagnostics": { … }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
path = request.data.get("path")
|
||||||
|
if not isinstance(path, list) or len(path) < 2:
|
||||||
|
return Response(
|
||||||
|
{"error": "invalid_path", "detail": "path must be a list of ≥ 2 [lon, lat, alt] coordinates."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate each coordinate
|
||||||
|
try:
|
||||||
|
path = [[float(v) for v in pt] for pt in path]
|
||||||
|
for pt in path:
|
||||||
|
if len(pt) != 3:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response(
|
||||||
|
{"error": "invalid_path", "detail": "Each point must be [lon, lat, alt_m] (three numbers)."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
params = _parse_params(request.data.get("params") or {})
|
||||||
|
strips = _parse_strips(request.data.get("acceleration_strips") or [])
|
||||||
|
|
||||||
|
# Convert to local ENU
|
||||||
|
pts_enu, origin = _to_enu(path)
|
||||||
|
|
||||||
|
# Run physics
|
||||||
|
try:
|
||||||
|
from .physics import generate_rails
|
||||||
|
rail_1, rail_2, diag, profile = generate_rails(
|
||||||
|
pts_enu, **params, acceleration_strips=strips
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Physics simulation failed")
|
||||||
|
return Response(
|
||||||
|
{"error": "simulation_failed", "detail": str(exc)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build GLB (build123d + trimesh) — gracefully skip on failure
|
||||||
|
model_url = None
|
||||||
|
try:
|
||||||
|
from .mesh import build_glb
|
||||||
|
glb_bytes = build_glb(rail_1, rail_2)
|
||||||
|
model_url = _upload_glb(glb_bytes)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("GLB generation failed — returning coordinates only")
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"rail_1": _from_enu(rail_1, origin),
|
||||||
|
"rail_2": _from_enu(rail_2, origin),
|
||||||
|
"origin": list(origin),
|
||||||
|
"model_url": model_url,
|
||||||
|
"diagnostics": diag,
|
||||||
|
"profile": profile,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Coaster persistence ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
from rest_framework.permissions import IsAuthenticated # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class CoasterListCreateView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/v1/coaster/challenges/{challenge_id}/coasters/ — list all coasters
|
||||||
|
POST /api/v1/coaster/challenges/{challenge_id}/coasters/ — save (upsert)
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, challenge_id):
|
||||||
|
from .models import Coaster
|
||||||
|
from .serializers import CoasterSerializer
|
||||||
|
coasters = (
|
||||||
|
Coaster.objects
|
||||||
|
.filter(challenge_id=challenge_id)
|
||||||
|
.select_related('creator')
|
||||||
|
.order_by('-updated_at')
|
||||||
|
)
|
||||||
|
return Response(CoasterSerializer(coasters, many=True).data)
|
||||||
|
|
||||||
|
def post(self, request, challenge_id):
|
||||||
|
from .models import Coaster
|
||||||
|
from .serializers import CoasterSerializer
|
||||||
|
from apps.challenges.models import Challenge
|
||||||
|
|
||||||
|
try:
|
||||||
|
challenge = Challenge.objects.get(id=challenge_id)
|
||||||
|
except Challenge.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "challenge_not_found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
coaster, created = Coaster.objects.update_or_create(
|
||||||
|
creator=request.user,
|
||||||
|
challenge=challenge,
|
||||||
|
defaults={
|
||||||
|
"name": request.data.get("name", ""),
|
||||||
|
"anchors": request.data.get("anchors", []),
|
||||||
|
"acceleration_strips": request.data.get("acceleration_strips", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
CoasterSerializer(coaster).data,
|
||||||
|
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CoasterDeleteView(APIView):
|
||||||
|
"""DELETE /api/v1/coaster/{pk}/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def delete(self, request, pk):
|
||||||
|
from .models import Coaster
|
||||||
|
try:
|
||||||
|
coaster = Coaster.objects.get(id=pk)
|
||||||
|
except Coaster.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
if coaster.creator != request.user:
|
||||||
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||||
|
coaster.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -5,4 +5,5 @@ urlpatterns = [
|
|||||||
path("splats/", include("apps.splats.urls")),
|
path("splats/", include("apps.splats.urls")),
|
||||||
path("challenges/", include("apps.challenges.urls")),
|
path("challenges/", include("apps.challenges.urls")),
|
||||||
path("jobs/", include("apps.jobs.urls")),
|
path("jobs/", include("apps.jobs.urls")),
|
||||||
|
path("coaster/", include("apps.coaster.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -26,6 +26,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.splats",
|
"apps.splats",
|
||||||
"apps.challenges",
|
"apps.challenges",
|
||||||
"apps.jobs",
|
"apps.jobs",
|
||||||
|
"apps.coaster",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -77,13 +78,16 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Authentik OIDC
|
# Authentik OIDC
|
||||||
_OIDC_BASE = os.environ.get("OIDC_OP_BASE_URL", "")
|
# OIDC_OP_BASE_URL is the per-application URL, e.g. https://auth.example.com/application/o/rcnn
|
||||||
|
# Global endpoints (token, userinfo) live one level up at /application/o/
|
||||||
|
_OIDC_APP_BASE = os.environ.get("OIDC_OP_BASE_URL", "")
|
||||||
|
_OIDC_GLOBAL_BASE = _OIDC_APP_BASE.rsplit("/", 1)[0] # strips the app slug
|
||||||
OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
|
OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
|
||||||
OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
|
OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT = f"{_OIDC_BASE}/authorize/"
|
OIDC_OP_AUTHORIZATION_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/authorize/"
|
||||||
OIDC_OP_TOKEN_ENDPOINT = f"{_OIDC_BASE}/token/"
|
OIDC_OP_TOKEN_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/token/"
|
||||||
OIDC_OP_USER_ENDPOINT = f"{_OIDC_BASE}/userinfo/"
|
OIDC_OP_USER_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/userinfo/"
|
||||||
OIDC_OP_JWKS_ENDPOINT = f"{_OIDC_BASE}/jwks/"
|
OIDC_OP_JWKS_ENDPOINT = f"{_OIDC_APP_BASE}/jwks/"
|
||||||
OIDC_RP_SIGN_ALGO = "RS256"
|
OIDC_RP_SIGN_ALGO = "RS256"
|
||||||
OIDC_STORE_ACCESS_TOKEN = True
|
OIDC_STORE_ACCESS_TOKEN = True
|
||||||
OIDC_STORE_ID_TOKEN = True
|
OIDC_STORE_ID_TOKEN = True
|
||||||
|
|||||||
@ -19,3 +19,12 @@ MEDIA_ROOT = BASE_DIR / "media"
|
|||||||
|
|
||||||
# Log Celery tasks to console
|
# Log Celery tasks to console
|
||||||
CELERY_TASK_ALWAYS_EAGER = False # set True to run tasks synchronously for debugging
|
CELERY_TASK_ALWAYS_EAGER = False # set True to run tasks synchronously for debugging
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {"console": {"class": "logging.StreamHandler"}},
|
||||||
|
"loggers": {
|
||||||
|
"mozilla_django_oidc": {"handlers": ["console"], "level": "DEBUG"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
Django==5.1.4
|
Django==5.1.4
|
||||||
|
numpy==2.2.5
|
||||||
|
scipy==1.15.3
|
||||||
|
build123d==0.9.0
|
||||||
|
trimesh==4.6.10
|
||||||
djangorestframework==3.15.2
|
djangorestframework==3.15.2
|
||||||
djangorestframework-gis==1.1
|
djangorestframework-gis==1.1
|
||||||
django-cors-headers==4.6.0
|
django-cors-headers==4.6.0
|
||||||
|
|||||||
417
web/package-lock.json
generated
417
web/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"three": "^0.171.0",
|
"three": "^0.171.0",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@ -60,7 +61,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -889,6 +889,42 @@
|
|||||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@ -1270,6 +1306,18 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tweenjs/tween.js": {
|
"node_modules/@tweenjs/tween.js": {
|
||||||
"version": "23.1.3",
|
"version": "23.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
@ -1322,6 +1370,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -1351,7 +1462,6 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@ -1395,6 +1505,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@ -1519,7 +1635,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@ -1573,7 +1688,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.140.0.tgz",
|
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.140.0.tgz",
|
||||||
"integrity": "sha512-3RvW0rvZWuXiS6regtNE5u9vt0uXohgpsRBIo6Qc922IIIamkitYiEdr4fg+u4qX4EoK9xS3BosCza7iPOExEQ==",
|
"integrity": "sha512-3RvW0rvZWuXiS6regtNE5u9vt0uXohgpsRBIo6Qc922IIIamkitYiEdr4fg+u4qX4EoK9xS3BosCza7iPOExEQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/engine",
|
"packages/engine",
|
||||||
"packages/widgets",
|
"packages/widgets",
|
||||||
@ -1587,6 +1701,15 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@ -1632,6 +1755,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@ -1650,6 +1894,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@ -1784,6 +2034,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1857,6 +2117,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fflate": {
|
"node_modules/fflate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
@ -2082,6 +2348,16 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@ -2089,6 +2365,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
@ -2455,7 +2740,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -2465,7 +2749,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -2473,6 +2756,36 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||||
|
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@ -2521,6 +2834,57 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@ -2685,8 +3049,13 @@
|
|||||||
"version": "0.171.0",
|
"version": "0.171.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz",
|
||||||
"integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==",
|
"integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@ -2785,13 +3154,43 @@
|
|||||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"three": "^0.171.0",
|
"three": "^0.171.0",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { ChallengeLayer } from './challenges/ChallengeLayer'
|
|||||||
import { ChallengePanel } from './challenges/ChallengePanel'
|
import { ChallengePanel } from './challenges/ChallengePanel'
|
||||||
import { ChallengeCreator } from './challenges/ChallengeCreator'
|
import { ChallengeCreator } from './challenges/ChallengeCreator'
|
||||||
import { MapOverlay } from './ui/MapOverlay'
|
import { MapOverlay } from './ui/MapOverlay'
|
||||||
|
import { CoasterEditorPage } from './coaster/CoasterEditorPage'
|
||||||
|
|
||||||
function MapPage() {
|
function MapPage() {
|
||||||
return (
|
return (
|
||||||
@ -32,6 +33,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth/callback" element={<CallbackPage />} />
|
<Route path="/auth/callback" element={<CallbackPage />} />
|
||||||
<Route path="/" element={<MapPage />} />
|
<Route path="/" element={<MapPage />} />
|
||||||
|
<Route path="/challenges/:id/coaster" element={<CoasterEditorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -22,6 +22,11 @@ export async function fetchChallenges(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchMyChallenges(): Promise<ChallengeDetail[]> {
|
||||||
|
const { data } = await apiClient.get('/challenges/', { params: { mine: 'true' } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChallengeDetail(id: string): Promise<ChallengeDetail> {
|
export async function fetchChallengeDetail(id: string): Promise<ChallengeDetail> {
|
||||||
const { data } = await apiClient.get(`/challenges/${id}/`)
|
const { data } = await apiClient.get(`/challenges/${id}/`)
|
||||||
return data
|
return data
|
||||||
|
|||||||
39
web/src/api/coaster.ts
Normal file
39
web/src/api/coaster.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { apiClient } from './client'
|
||||||
|
import type {
|
||||||
|
CoasterSimulateBody,
|
||||||
|
CoasterSimulationResult,
|
||||||
|
SavedCoaster,
|
||||||
|
StoredAnchor,
|
||||||
|
AccelerationStrip,
|
||||||
|
} from '../types/api'
|
||||||
|
|
||||||
|
export async function simulateCoaster(body: CoasterSimulateBody): Promise<CoasterSimulationResult> {
|
||||||
|
const res = await apiClient.post<CoasterSimulationResult>('/coaster/simulate/', body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCoasters(challengeId: string): Promise<SavedCoaster[]> {
|
||||||
|
const res = await apiClient.get<SavedCoaster[]>(
|
||||||
|
`/coaster/challenges/${challengeId}/coasters/`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCoaster(
|
||||||
|
challengeId: string,
|
||||||
|
body: {
|
||||||
|
name?: string
|
||||||
|
anchors: StoredAnchor[]
|
||||||
|
acceleration_strips: Array<Pick<AccelerationStrip, 'id' | 'startFrac' | 'endFrac' | 'accel_ms2'>>
|
||||||
|
},
|
||||||
|
): Promise<SavedCoaster> {
|
||||||
|
const res = await apiClient.post<SavedCoaster>(
|
||||||
|
`/coaster/challenges/${challengeId}/coasters/`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCoaster(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/coaster/${id}/`)
|
||||||
|
}
|
||||||
@ -16,9 +16,7 @@ export function CallbackPage() {
|
|||||||
navigate(state?.returnTo ?? '/', { replace: true })
|
navigate(state?.returnTo ?? '/', { replace: true })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// If the callback fails (e.g. page refreshed on the callback URL),
|
navigate('/', { replace: true })
|
||||||
// kick off a fresh login instead of showing a blank screen.
|
|
||||||
userManager.signinRedirect()
|
|
||||||
})
|
})
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,8 @@ export const userManager = new UserManager({
|
|||||||
authority: import.meta.env.VITE_OIDC_AUTHORITY,
|
authority: import.meta.env.VITE_OIDC_AUTHORITY,
|
||||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
|
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
|
||||||
redirect_uri: `${window.location.origin}/auth/callback`,
|
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
silent_redirect_uri: `${window.location.origin}/auth/silent-callback.html`,
|
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
automaticSilentRenew: true,
|
automaticSilentRenew: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -38,9 +38,12 @@ export function CesiumViewer({ children }: Props) {
|
|||||||
})
|
})
|
||||||
.catch(() => {/* non-fatal: fall back to ellipsoid */})
|
.catch(() => {/* non-fatal: fall back to ellipsoid */})
|
||||||
|
|
||||||
// Hide Cesium's own credit container — we'll add our own if needed
|
// Async: switch base imagery to Bing Aerial with Labels
|
||||||
const creditContainer = v.cesiumWidget.creditContainer as HTMLElement
|
Cesium.createWorldImageryAsync({ style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS })
|
||||||
creditContainer.style.display = 'none'
|
.then((ip) => {
|
||||||
|
if (!v.isDestroyed()) v.imageryLayers.get(0).imageryProvider = ip
|
||||||
|
})
|
||||||
|
.catch(() => {/* non-fatal: keep default imagery */})
|
||||||
|
|
||||||
setViewer(v)
|
setViewer(v)
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export function ChallengeLayer() {
|
|||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||||
entityMapRef.current.clear()
|
entityMapRef.current.clear()
|
||||||
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)
|
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)
|
||||||
|
|||||||
@ -14,6 +14,31 @@
|
|||||||
.meta dt { color: rgba(255,255,255,0.5); }
|
.meta dt { color: rgba(255,255,255,0.5); }
|
||||||
.meta dd { margin: 0; }
|
.meta dd { margin: 0; }
|
||||||
|
|
||||||
|
.centerBtn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.centerBtn:hover { background: rgba(255,255,255,0.13); color: #fff; }
|
||||||
|
|
||||||
|
.coasterBtn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(245,158,11,0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245,158,11,0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.coasterBtn:hover { background: rgba(245,158,11,0.18); border-color: rgba(245,158,11,0.55); }
|
||||||
|
|
||||||
.participateBtn {
|
.participateBtn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Panel } from '../ui/Panel'
|
import { Panel } from '../ui/Panel'
|
||||||
|
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||||
import { useChallengeStore } from '../store/challengeStore'
|
import { useChallengeStore } from '../store/challengeStore'
|
||||||
import { fetchChallengeDetail, participateInChallenge } from '../api/challenges'
|
import { fetchChallengeDetail, participateInChallenge } from '../api/challenges'
|
||||||
import type { ChallengeDetail } from '../types/api'
|
import type { ChallengeDetail } from '../types/api'
|
||||||
import styles from './ChallengePanel.module.css'
|
import styles from './ChallengePanel.module.css'
|
||||||
|
|
||||||
export function ChallengePanel() {
|
export function ChallengePanel() {
|
||||||
|
const viewer = useCesiumViewer()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
|
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
|
||||||
const [detail, setDetail] = useState<ChallengeDetail | null>(null)
|
const [detail, setDetail] = useState<ChallengeDetail | null>(null)
|
||||||
const [participating, setParticipating] = useState(false)
|
const [participating, setParticipating] = useState(false)
|
||||||
@ -23,6 +28,15 @@ export function ChallengePanel() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [selectedChallengeId])
|
}, [selectedChallengeId])
|
||||||
|
|
||||||
|
function handleCenterMap() {
|
||||||
|
if (!detail?.region_centroid) return
|
||||||
|
const [lon, lat] = detail.region_centroid.coordinates
|
||||||
|
viewer.camera.flyTo({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000),
|
||||||
|
duration: 1.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleParticipate() {
|
async function handleParticipate() {
|
||||||
if (!selectedChallengeId) return
|
if (!selectedChallengeId) return
|
||||||
await participateInChallenge(selectedChallengeId)
|
await participateInChallenge(selectedChallengeId)
|
||||||
@ -59,6 +73,17 @@ export function ChallengePanel() {
|
|||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<button className={styles.centerBtn} onClick={handleCenterMap}>
|
||||||
|
Center map
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.coasterBtn}
|
||||||
|
onClick={() => navigate(`/challenges/${selectedChallengeId}/coaster`)}
|
||||||
|
>
|
||||||
|
Plan coaster route
|
||||||
|
</button>
|
||||||
|
|
||||||
{detail.status === 'active' && !participating && (
|
{detail.status === 'active' && !participating && (
|
||||||
<button className={styles.participateBtn} onClick={handleParticipate}>
|
<button className={styles.participateBtn} onClick={handleParticipate}>
|
||||||
Accept challenge
|
Accept challenge
|
||||||
|
|||||||
103
web/src/challenges/MyChallengesPanel.module.css
Normal file
103
web/src/challenges/MyChallengesPanel.module.css
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 140px;
|
||||||
|
z-index: 20;
|
||||||
|
width: 300px;
|
||||||
|
max-height: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(15, 15, 20, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.close:hover { color: #fff; }
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.item:last-child { border-bottom: none; }
|
||||||
|
.item:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
|
||||||
|
.itemTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.active { background: rgba(34,197,94,0.2); color: #4ade80; }
|
||||||
|
.closed { background: rgba(239,68,68,0.2); color: #f87171; }
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
55
web/src/challenges/MyChallengesPanel.tsx
Normal file
55
web/src/challenges/MyChallengesPanel.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchMyChallenges } from '../api/challenges'
|
||||||
|
import { useChallengeStore } from '../store/challengeStore'
|
||||||
|
import type { ChallengeDetail } from '../types/api'
|
||||||
|
import styles from './MyChallengesPanel.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyChallengesPanel({ onClose }: Props) {
|
||||||
|
const { setSelectedChallengeId } = useChallengeStore()
|
||||||
|
const [challenges, setChallenges] = useState<ChallengeDetail[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyChallenges()
|
||||||
|
.then(setChallenges)
|
||||||
|
.catch(() => setError('Failed to load challenges.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSelect(id: string) {
|
||||||
|
setSelectedChallengeId(id)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 className={styles.title}>My Challenges</h3>
|
||||||
|
<button className={styles.close} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className={styles.state}>Loading…</p>}
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
{!loading && !error && challenges.length === 0 && (
|
||||||
|
<p className={styles.state}>No challenges yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{challenges.map((c) => (
|
||||||
|
<li key={c.id} className={styles.item} onClick={() => handleSelect(c.id)}>
|
||||||
|
<div className={styles.itemTitle}>{c.title}</div>
|
||||||
|
<div className={styles.itemMeta}>
|
||||||
|
<span className={`${styles.badge} ${styles[c.status]}`}>{c.status}</span>
|
||||||
|
<span className={styles.count}>{c.submission_count} submission{c.submission_count !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,87 +3,77 @@ import * as Cesium from 'cesium'
|
|||||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||||
import { useChallengeStore } from '../store/challengeStore'
|
import { useChallengeStore } from '../store/challengeStore'
|
||||||
|
|
||||||
|
function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon {
|
||||||
|
const coords: [number, number][] = verts.map((v) => {
|
||||||
|
const c = Cesium.Cartographic.fromCartesian(v)
|
||||||
|
return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)]
|
||||||
|
})
|
||||||
|
return { type: 'Polygon', coordinates: [[...coords, coords[0]]] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] {
|
||||||
|
const ring = polygon.coordinates[0]
|
||||||
|
// Drop the closing duplicate point
|
||||||
|
return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGlobe(
|
||||||
|
viewer: Cesium.Viewer,
|
||||||
|
windowPos: Cesium.Cartesian2,
|
||||||
|
): Cesium.Cartesian3 | null {
|
||||||
|
const ray = viewer.camera.getPickRay(windowPos)
|
||||||
|
if (!ray) return null
|
||||||
|
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When drawingMode is true, intercepts Cesium mouse events to let the user
|
* Manages two phases of polygon interaction:
|
||||||
* draw a polygon by clicking vertices on the globe.
|
|
||||||
*
|
*
|
||||||
* LEFT_CLICK → add vertex
|
* Drawing (drawingMode=true)
|
||||||
* RIGHT_CLICK → close polygon and write GeoJSON Polygon to challengeStore
|
* LEFT_CLICK → place vertex
|
||||||
|
* MOUSE_MOVE → rubber-band line from last vertex to cursor
|
||||||
|
* RIGHT_CLICK → close polygon (≥3 verts), enter edit phase
|
||||||
|
*
|
||||||
|
* Editing (drawingMode=false, draftPolygon set)
|
||||||
|
* Drag vertex handles to reposition them.
|
||||||
|
* Changes are written back to the store on mouse-up so the submit
|
||||||
|
* form always reads the latest geometry.
|
||||||
*/
|
*/
|
||||||
export function usePolygonDraw() {
|
export function usePolygonDraw() {
|
||||||
const viewer = useCesiumViewer()
|
const viewer = useCesiumViewer()
|
||||||
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
|
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
|
||||||
const verticesRef = useRef<Cesium.Cartesian3[]>([])
|
useChallengeStore()
|
||||||
const previewEntityRef = useRef<Cesium.Entity | null>(null)
|
|
||||||
|
|
||||||
|
// Persists vertex positions across edit-phase effect re-runs that are
|
||||||
|
// triggered by setDraftPolygon being called after each drag.
|
||||||
|
const editVertsRef = useRef<Cesium.Cartesian3[]>([])
|
||||||
|
|
||||||
|
// ── Drawing phase ──────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!drawingMode) {
|
if (!drawingMode) return
|
||||||
cleanup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verticesRef.current = []
|
const verts: Cesium.Cartesian3[] = []
|
||||||
|
const vertPointEntities: Cesium.Entity[] = []
|
||||||
|
let outlineEntity: Cesium.Entity | null = null
|
||||||
|
let rubberBandEntity: Cesium.Entity | null = null
|
||||||
|
|
||||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
const canvas = viewer.scene.canvas
|
||||||
|
|
||||||
handler.setInputAction((event: { position: Cesium.Cartesian2 }) => {
|
// Prevent the browser context menu so right-click can close the polygon.
|
||||||
const ray = viewer.camera.getPickRay(event.position)
|
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
|
||||||
if (!ray) return
|
canvas.addEventListener('contextmenu', suppressContextMenu)
|
||||||
const intersection = viewer.scene.globe.pick(ray, viewer.scene)
|
|
||||||
if (!intersection) return
|
|
||||||
|
|
||||||
verticesRef.current.push(intersection.clone())
|
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
|
||||||
updatePreview()
|
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
|
||||||
|
|
||||||
handler.setInputAction(() => {
|
function refreshOutline() {
|
||||||
const verts = verticesRef.current
|
if (outlineEntity) {
|
||||||
if (verts.length < 3) return
|
viewer.entities.remove(outlineEntity)
|
||||||
|
outlineEntity = null
|
||||||
// Convert Cartesian3 vertices to [lon, lat] degree pairs
|
|
||||||
const coords: [number, number][] = verts.map((v) => {
|
|
||||||
const carto = Cesium.Cartographic.fromCartesian(v)
|
|
||||||
return [
|
|
||||||
Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
// Close the ring
|
|
||||||
coords.push(coords[0])
|
|
||||||
|
|
||||||
const polygon: GeoJSON.Polygon = {
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [coords],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup()
|
|
||||||
setDraftPolygon(polygon)
|
|
||||||
setDrawingMode(false)
|
|
||||||
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
handler.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
verticesRef.current = []
|
|
||||||
if (previewEntityRef.current) {
|
|
||||||
viewer.entities.remove(previewEntityRef.current)
|
|
||||||
previewEntityRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreview() {
|
|
||||||
if (previewEntityRef.current) {
|
|
||||||
viewer.entities.remove(previewEntityRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const verts = verticesRef.current
|
|
||||||
if (verts.length < 2) return
|
if (verts.length < 2) return
|
||||||
|
outlineEntity = viewer.entities.add({
|
||||||
previewEntityRef.current = viewer.entities.add({
|
|
||||||
polyline: {
|
polyline: {
|
||||||
positions: [...verts, verts[0]], // close the preview ring
|
positions: [...verts, verts[0]],
|
||||||
width: 2,
|
width: 2,
|
||||||
material: new Cesium.ColorMaterialProperty(
|
material: new Cesium.ColorMaterialProperty(
|
||||||
Cesium.Color.YELLOW.withAlpha(0.9),
|
Cesium.Color.YELLOW.withAlpha(0.9),
|
||||||
@ -92,5 +82,199 @@ export function usePolygonDraw() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshRubberBand(mousePos: Cesium.Cartesian3) {
|
||||||
|
if (rubberBandEntity) {
|
||||||
|
viewer.entities.remove(rubberBandEntity)
|
||||||
|
rubberBandEntity = null
|
||||||
|
}
|
||||||
|
if (verts.length === 0) return
|
||||||
|
rubberBandEntity = viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions: [verts[verts.length - 1], mousePos],
|
||||||
|
width: 1.5,
|
||||||
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
Cesium.Color.WHITE.withAlpha(0.5),
|
||||||
|
),
|
||||||
|
clampToGround: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
|
||||||
|
const pos = pickGlobe(viewer, e.endPosition)
|
||||||
|
if (pos) refreshRubberBand(pos)
|
||||||
|
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
|
||||||
|
|
||||||
|
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
|
||||||
|
const pos = pickGlobe(viewer, e.position)
|
||||||
|
if (!pos) return
|
||||||
|
verts.push(pos.clone())
|
||||||
|
vertPointEntities.push(
|
||||||
|
viewer.entities.add({
|
||||||
|
position: pos,
|
||||||
|
point: {
|
||||||
|
pixelSize: 8,
|
||||||
|
color: Cesium.Color.YELLOW,
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 1,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
refreshOutline()
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
||||||
|
|
||||||
|
handler.setInputAction(() => {
|
||||||
|
if (verts.length < 3) return
|
||||||
|
const polygon = vertsToGeoJson(verts)
|
||||||
|
cleanup()
|
||||||
|
setDraftPolygon(polygon)
|
||||||
|
setDrawingMode(false)
|
||||||
|
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
vertPointEntities.forEach((e) => viewer.entities.remove(e))
|
||||||
|
vertPointEntities.length = 0
|
||||||
|
if (outlineEntity) {
|
||||||
|
viewer.entities.remove(outlineEntity)
|
||||||
|
outlineEntity = null
|
||||||
|
}
|
||||||
|
if (rubberBandEntity) {
|
||||||
|
viewer.entities.remove(rubberBandEntity)
|
||||||
|
rubberBandEntity = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handler.destroy()
|
||||||
|
canvas.removeEventListener('contextmenu', suppressContextMenu)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
|
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
|
||||||
|
|
||||||
|
// ── Edit phase ─────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawingMode || !draftPolygon) {
|
||||||
|
editVertsRef.current = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only initialise from the store on the first entry into edit mode.
|
||||||
|
// Subsequent runs (triggered by setDraftPolygon after each drag) reuse
|
||||||
|
// the already-mutated ref so vertex positions are not reset.
|
||||||
|
if (editVertsRef.current.length === 0) {
|
||||||
|
editVertsRef.current = geoJsonToVerts(draftPolygon)
|
||||||
|
}
|
||||||
|
|
||||||
|
const verts = editVertsRef.current
|
||||||
|
let draggingIndex = -1
|
||||||
|
const entities: Cesium.Entity[] = []
|
||||||
|
|
||||||
|
const canvas = viewer.scene.canvas
|
||||||
|
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
|
||||||
|
canvas.addEventListener('contextmenu', suppressContextMenu)
|
||||||
|
|
||||||
|
// Filled polygon
|
||||||
|
entities.push(
|
||||||
|
viewer.entities.add({
|
||||||
|
polygon: {
|
||||||
|
hierarchy: new Cesium.CallbackProperty(
|
||||||
|
() => new Cesium.PolygonHierarchy(verts),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
material: Cesium.Color.YELLOW.withAlpha(0.15),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outline
|
||||||
|
entities.push(
|
||||||
|
viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions: new Cesium.CallbackProperty(
|
||||||
|
() => [...verts, verts[0]],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
width: 2,
|
||||||
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
Cesium.Color.YELLOW.withAlpha(0.9),
|
||||||
|
),
|
||||||
|
clampToGround: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vertex handles — one point entity per vertex, driven by CallbackProperty
|
||||||
|
// so they track the mutable verts array without entity recreation.
|
||||||
|
const vertEntities: Cesium.Entity[] = verts.map((_, i) => {
|
||||||
|
const e = viewer.entities.add({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
position: new Cesium.CallbackProperty(() => verts[i], false) as any,
|
||||||
|
point: {
|
||||||
|
pixelSize: 10,
|
||||||
|
color: Cesium.Color.WHITE,
|
||||||
|
outlineColor: Cesium.Color.YELLOW,
|
||||||
|
outlineWidth: 2,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
entities.push(e)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
|
||||||
|
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
|
||||||
|
|
||||||
|
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
|
||||||
|
if (draggingIndex !== -1) {
|
||||||
|
// Update dragged vertex
|
||||||
|
const pos = pickGlobe(viewer, e.endPosition)
|
||||||
|
if (pos) verts[draggingIndex] = pos
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cursor feedback
|
||||||
|
const picked = viewer.scene.pick(e.endPosition)
|
||||||
|
const overVertex =
|
||||||
|
picked?.id instanceof Cesium.Entity &&
|
||||||
|
vertEntities.includes(picked.id)
|
||||||
|
canvas.style.cursor = overVertex ? 'grab' : 'default'
|
||||||
|
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
|
||||||
|
|
||||||
|
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
|
||||||
|
const picked = viewer.scene.pick(e.position)
|
||||||
|
if (!(picked?.id instanceof Cesium.Entity)) return
|
||||||
|
const idx = vertEntities.indexOf(picked.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
draggingIndex = idx
|
||||||
|
canvas.style.cursor = 'grabbing'
|
||||||
|
viewer.scene.screenSpaceCameraController.enableRotate = false
|
||||||
|
viewer.scene.screenSpaceCameraController.enableTranslate = false
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
|
||||||
|
|
||||||
|
handler.setInputAction(() => {
|
||||||
|
if (draggingIndex === -1) return
|
||||||
|
draggingIndex = -1
|
||||||
|
canvas.style.cursor = 'default'
|
||||||
|
viewer.scene.screenSpaceCameraController.enableRotate = true
|
||||||
|
viewer.scene.screenSpaceCameraController.enableTranslate = true
|
||||||
|
// Sync updated geometry back to the store for the submit form.
|
||||||
|
setDraftPolygon(vertsToGeoJson(verts))
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_UP)
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
entities.forEach((e) => viewer.entities.remove(e))
|
||||||
|
entities.length = 0
|
||||||
|
canvas.style.cursor = 'default'
|
||||||
|
viewer.scene.screenSpaceCameraController.enableRotate = true
|
||||||
|
viewer.scene.screenSpaceCameraController.enableTranslate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handler.destroy()
|
||||||
|
canvas.removeEventListener('contextmenu', suppressContextMenu)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [drawingMode, draftPolygon, viewer, setDraftPolygon])
|
||||||
}
|
}
|
||||||
|
|||||||
385
web/src/coaster/CoasterEditorPage.module.css
Normal file
385
web/src/coaster/CoasterEditorPage.module.css
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
/* ── Top bar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: rgba(8, 8, 12, 0.75);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.backBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom toolbar ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(8, 8, 12, 0.80);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolBtn {
|
||||||
|
padding: 7px 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.toolBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.toolBtn.active {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
margin: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulateBtn {
|
||||||
|
padding: 7px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #f59e0b;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.simulateBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
.simulateBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.simulateBtn.simulating {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostBtn {
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.ghostBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearBtn {
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 69, 58, 0.25);
|
||||||
|
background: rgba(255, 69, 58, 0.08);
|
||||||
|
color: rgba(255, 100, 80, 0.85);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.clearBtn:hover {
|
||||||
|
background: rgba(255, 69, 58, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countBadge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selected-point panel ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.selectedPanel {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 90px;
|
||||||
|
z-index: 200;
|
||||||
|
width: 230px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(8, 8, 12, 0.82);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeading {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heightRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heightLabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heightValue {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f59e0b;
|
||||||
|
min-width: 46px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stepBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepGroup {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepGroupBtn {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.stepGroupBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.stepGroupBtn.up {
|
||||||
|
border-color: rgba(100, 220, 130, 0.2);
|
||||||
|
color: rgba(100, 220, 130, 0.8);
|
||||||
|
}
|
||||||
|
.stepGroupBtn.up:hover {
|
||||||
|
background: rgba(100, 220, 130, 0.1);
|
||||||
|
}
|
||||||
|
.stepGroupBtn.down {
|
||||||
|
border-color: rgba(255, 130, 100, 0.2);
|
||||||
|
color: rgba(255, 130, 100, 0.8);
|
||||||
|
}
|
||||||
|
.stepGroupBtn.down:hover {
|
||||||
|
background: rgba(255, 130, 100, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocityInput {
|
||||||
|
width: 52px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.velocityInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 69, 58, 0.25);
|
||||||
|
background: rgba(255, 69, 58, 0.08);
|
||||||
|
color: rgba(255, 100, 80, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.removeBtn:hover {
|
||||||
|
background: rgba(255, 69, 58, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Simulation error ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.simError {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: rgba(220, 38, 38, 0.18);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 160, 140, 0.95);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diagnostics strip ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.diagStrip {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 7px 18px;
|
||||||
|
background: rgba(8, 8, 12, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagWarn {
|
||||||
|
color: rgba(251, 191, 36, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hint strip ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: rgba(8, 8, 12, 0.65);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 15px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
435
web/src/coaster/CoasterEditorPage.tsx
Normal file
435
web/src/coaster/CoasterEditorPage.tsx
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
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 { useCoasterPath } from './useCoasterPath'
|
||||||
|
import { useAccelerationStrips } from './useAccelerationStrips'
|
||||||
|
import { SimulationPlots } from './SimulationPlots'
|
||||||
|
import { CoasterListPanel } from './CoasterListPanel'
|
||||||
|
import { effectivePosition } from './bezierUtils'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
|
||||||
|
import styles from './CoasterEditorPage.module.css'
|
||||||
|
|
||||||
|
// ── Route page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CoasterEditorPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
fetchChallengeDetail(id).then(setChallenge).catch(console.error)
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CesiumViewer>
|
||||||
|
<CoasterEditorScene challengeId={id} challenge={challenge} />
|
||||||
|
</CesiumViewer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build a circle cross-section shape for PolylineVolumeGraphics. */
|
||||||
|
function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] {
|
||||||
|
const pts: Cesium.Cartesian2[] = []
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / segments
|
||||||
|
pts.push(new Cesium.Cartesian2(Math.cos(angle) * radius, Math.sin(angle) * radius))
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
const RAIL_SHAPE = buildCircleShape(0.35, 8) // 35 cm radius tube
|
||||||
|
|
||||||
|
// ── Inner scene (needs viewer context) ────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SceneProps {
|
||||||
|
challengeId: string | undefined
|
||||||
|
challenge: ChallengeDetail | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||||
|
const viewer = useCesiumViewer()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const authUser = useAuthStore(s => s.user)
|
||||||
|
const currentUsername = authUser?.profile?.preferred_username as string | undefined
|
||||||
|
|
||||||
|
const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null)
|
||||||
|
const [simulating, setSimulating] = useState(false)
|
||||||
|
const [simError, setSimError] = useState<string | null>(null)
|
||||||
|
const [initialVelocity, setInitialVelocity] = useState(1.0)
|
||||||
|
const [showPath, setShowPath] = useState(true)
|
||||||
|
const [showAnchors, setShowAnchors] = useState(true)
|
||||||
|
const [showStrips, setShowStrips] = useState(true)
|
||||||
|
const [coasterListKey, setCoasterListKey] = useState(0)
|
||||||
|
|
||||||
|
const path = useCoasterPath(viewer, showPath, showAnchors)
|
||||||
|
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
|
||||||
|
|
||||||
|
// Refs for simulation result entities (cleared on each new run / unmount)
|
||||||
|
const simEntitiesRef = useRef<Cesium.Entity[]>([])
|
||||||
|
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
|
||||||
|
|
||||||
|
// ── Fly to challenge region ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!challenge) return
|
||||||
|
const coords = challenge.region.coordinates[0]
|
||||||
|
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
||||||
|
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
|
||||||
|
sphere.radius = Math.max(sphere.radius + 20, 50)
|
||||||
|
|
||||||
|
viewer.camera.flyToBoundingSphere(sphere, {
|
||||||
|
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
|
||||||
|
duration: 1.2,
|
||||||
|
})
|
||||||
|
}, [challenge, viewer])
|
||||||
|
|
||||||
|
// ── Challenge boundary polygon ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const regionEntityRef = useRef<Cesium.Entity | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (regionEntityRef.current) {
|
||||||
|
viewer.entities.remove(regionEntityRef.current)
|
||||||
|
regionEntityRef.current = null
|
||||||
|
}
|
||||||
|
if (!challenge) return
|
||||||
|
|
||||||
|
const coords = challenge.region.coordinates[0]
|
||||||
|
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
||||||
|
|
||||||
|
regionEntityRef.current = viewer.entities.add({
|
||||||
|
polygon: {
|
||||||
|
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||||
|
material: Cesium.Color.CYAN.withAlpha(0.04),
|
||||||
|
outline: true,
|
||||||
|
outlineColor: Cesium.Color.CYAN.withAlpha(0.45),
|
||||||
|
outlineWidth: 2,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (regionEntityRef.current && !viewer.isDestroyed()) {
|
||||||
|
viewer.entities.remove(regionEntityRef.current)
|
||||||
|
regionEntityRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [challenge, viewer])
|
||||||
|
|
||||||
|
// ── Render simulation result rails ────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear previous simulation entities
|
||||||
|
if (!viewer.isDestroyed()) {
|
||||||
|
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
|
||||||
|
}
|
||||||
|
simEntitiesRef.current = []
|
||||||
|
simPrimitivesRef.current = []
|
||||||
|
|
||||||
|
if (!simResult) return
|
||||||
|
|
||||||
|
const toC3 = ([lon, lat, alt]: [number, number, number]) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(lon, lat, alt)
|
||||||
|
|
||||||
|
const r1Pts = simResult.rail_1.map(toC3)
|
||||||
|
const r2Pts = simResult.rail_2.map(toC3)
|
||||||
|
|
||||||
|
const rail1 = viewer.entities.add({
|
||||||
|
polylineVolume: {
|
||||||
|
positions: r1Pts,
|
||||||
|
shape: RAIL_SHAPE,
|
||||||
|
material: Cesium.Color.fromCssColorString('#ef4444'),
|
||||||
|
cornerType: Cesium.CornerType.ROUNDED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const rail2 = viewer.entities.add({
|
||||||
|
polylineVolume: {
|
||||||
|
positions: r2Pts,
|
||||||
|
shape: RAIL_SHAPE,
|
||||||
|
material: Cesium.Color.fromCssColorString('#ef4444'),
|
||||||
|
cornerType: Cesium.CornerType.ROUNDED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
simEntitiesRef.current = [rail1, rail2]
|
||||||
|
|
||||||
|
// Load GLB model if available
|
||||||
|
if (simResult.model_url) {
|
||||||
|
const [lon0, lat0, alt0] = simResult.origin
|
||||||
|
const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||||
|
Cesium.Cartesian3.fromDegrees(lon0, lat0, alt0),
|
||||||
|
)
|
||||||
|
Cesium.Model.fromGltfAsync({ url: simResult.model_url, modelMatrix })
|
||||||
|
.then(model => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
viewer.scene.primitives.add(model)
|
||||||
|
simPrimitivesRef.current.push(model as unknown as Cesium.Primitive)
|
||||||
|
})
|
||||||
|
.catch(err => console.error('GLB model load failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
|
||||||
|
simEntitiesRef.current = []
|
||||||
|
simPrimitivesRef.current = []
|
||||||
|
}
|
||||||
|
}, [simResult, viewer])
|
||||||
|
|
||||||
|
// ── Load / Save handlers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleLoad(coaster: SavedCoaster) {
|
||||||
|
const anchorPoints = coaster.anchors.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt),
|
||||||
|
heightOffset: a.heightOffset,
|
||||||
|
}))
|
||||||
|
path.loadAnchors(anchorPoints)
|
||||||
|
accel.loadStrips(coaster.acceleration_strips)
|
||||||
|
setSimResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!challengeId || path.anchors.length < 2) return
|
||||||
|
const storedAnchors = path.anchors.map(a => {
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a))
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
terrainAlt: carto.height,
|
||||||
|
heightOffset: a.heightOffset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await saveCoaster(challengeId, {
|
||||||
|
anchors: storedAnchors,
|
||||||
|
acceleration_strips: accel.strips,
|
||||||
|
})
|
||||||
|
setCoasterListKey(k => k + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulate handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleSimulate() {
|
||||||
|
if (path.anchors.length < 2) return
|
||||||
|
setSimulating(true)
|
||||||
|
setSimError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send only the anchor control points (not the pre-sampled Bezier
|
||||||
|
// polyline). The backend fits its own smooth B-spline with analytical
|
||||||
|
// derivatives, which avoids the curvature spikes that arise from
|
||||||
|
// finite-differencing a piecewise-linear polyline approximation.
|
||||||
|
const geoPath = path.anchors.map(anchor => {
|
||||||
|
const pos = effectivePosition(anchor)
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(pos)
|
||||||
|
return [
|
||||||
|
Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
carto.height,
|
||||||
|
] as [number, number, number]
|
||||||
|
})
|
||||||
|
const result = await simulateCoaster({
|
||||||
|
path: geoPath,
|
||||||
|
params: { initial_velocity: initialVelocity },
|
||||||
|
acceleration_strips: accel.strips.map(s => ({
|
||||||
|
start_frac: s.startFrac,
|
||||||
|
end_frac: s.endFrac,
|
||||||
|
accel_ms2: s.accel_ms2,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
setSimResult(result)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Simulation failed'
|
||||||
|
setSimError(msg)
|
||||||
|
} finally {
|
||||||
|
setSimulating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived UI state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selected = path.anchors.find(a => a.id === path.selectedId)
|
||||||
|
const selectedIndex = path.anchors.findIndex(a => a.id === path.selectedId)
|
||||||
|
const diag = simResult?.diagnostics
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Top bar ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className={styles.topBar}>
|
||||||
|
<button className={styles.backBtn} onClick={() => navigate('/')}>
|
||||||
|
← Map
|
||||||
|
</button>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
{challenge ? challenge.title : 'Loading…'}
|
||||||
|
</h1>
|
||||||
|
<span className={styles.badge}>Coaster Editor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mode toolbar ────────────────────────────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
|
||||||
|
onClick={handleSimulate}
|
||||||
|
disabled={path.anchors.length < 2 || simulating}
|
||||||
|
>
|
||||||
|
{simulating ? 'Simulating…' : 'Simulate'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<button
|
||||||
|
className={`${styles.toolBtn}${showPath ? ` ${styles.active}` : ''}`}
|
||||||
|
onClick={() => setShowPath(p => !p)}
|
||||||
|
>
|
||||||
|
Path
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.toolBtn}${showAnchors ? ` ${styles.active}` : ''}`}
|
||||||
|
onClick={() => setShowAnchors(a => !a)}
|
||||||
|
>
|
||||||
|
Anchors
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.toolBtn}${showStrips ? ` ${styles.active}` : ''}`}
|
||||||
|
onClick={() => setShowStrips(s => !s)}
|
||||||
|
>
|
||||||
|
Strips
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.ghostBtn}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={path.anchors.length < 2}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Simulation error ─────────────────────────────────────────────── */}
|
||||||
|
{simError && (
|
||||||
|
<div className={styles.simError}>{simError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Diagnostics strip ────────────────────────────────────────────── */}
|
||||||
|
{diag && !simError && (
|
||||||
|
<div className={styles.diagStrip}>
|
||||||
|
<span>H: {diag.height_range_m[0].toFixed(0)}–{diag.height_range_m[1].toFixed(0)} m</span>
|
||||||
|
<span>V: {diag.velocity_range_ms[0].toFixed(1)}–{diag.velocity_range_ms[1].toFixed(1)} m/s</span>
|
||||||
|
{diag.g_force_range && (
|
||||||
|
<span>G: {diag.g_force_range[0].toFixed(2)}–{diag.g_force_range[1].toFixed(2)} g</span>
|
||||||
|
)}
|
||||||
|
{diag.stall_at_pct !== null && (
|
||||||
|
<span className={styles.diagWarn}>⚠ stall at {diag.stall_at_pct.toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Hint strip ──────────────────────────────────────────────────── */}
|
||||||
|
{!selected && !diag && (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
{path.mode === 'add'
|
||||||
|
? 'Left-click terrain to place track points · Right-click to undo'
|
||||||
|
: path.mode === 'strip'
|
||||||
|
? 'Click path start of strip · Click path end · Right-click to cancel'
|
||||||
|
: 'Left-click a point to select · Drag to reposition · Right-click to delete'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Selected-point panel ─────────────────────────────────────────── */}
|
||||||
|
{selected && (
|
||||||
|
<div className={styles.selectedPanel}>
|
||||||
|
<p className={styles.panelHeading}>
|
||||||
|
Point {selectedIndex + 1} of {path.anchors.length}
|
||||||
|
</p>
|
||||||
|
<div className={styles.heightRow}>
|
||||||
|
<span className={styles.heightLabel}>Height offset</span>
|
||||||
|
<span className={styles.heightValue}>{selected.heightOffset.toFixed(1)} m</span>
|
||||||
|
</div>
|
||||||
|
{selectedIndex === 0 && (
|
||||||
|
<div className={styles.heightRow}>
|
||||||
|
<span className={styles.heightLabel}>Start velocity</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0} max={100} step={0.5}
|
||||||
|
value={initialVelocity}
|
||||||
|
onChange={e => setInitialVelocity(Math.max(0, Math.min(100, Number(e.target.value))))}
|
||||||
|
className={styles.velocityInput}
|
||||||
|
/>
|
||||||
|
<span className={styles.heightLabel}>m/s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.stepGroup}>
|
||||||
|
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 5)}>+5 m</button>
|
||||||
|
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 1)}>+1 m</button>
|
||||||
|
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -1)}>−1 m</button>
|
||||||
|
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -5)}>−5 m</button>
|
||||||
|
</div>
|
||||||
|
<button className={styles.removeBtn} onClick={() => path.removeAnchor(selected.id)}>
|
||||||
|
Remove point
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Simulation plots + strip list (left panel) ──────────────────── */}
|
||||||
|
{(accel.strips.length > 0 || simResult?.profile) && (
|
||||||
|
<SimulationPlots
|
||||||
|
profile={simResult?.profile ?? null}
|
||||||
|
strips={accel.strips}
|
||||||
|
onRemoveStrip={accel.removeStrip}
|
||||||
|
onUpdateStrip={accel.updateStrip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
|
||||||
|
{challengeId && (
|
||||||
|
<CoasterListPanel
|
||||||
|
challengeId={challengeId}
|
||||||
|
currentUsername={currentUsername}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
refreshKey={coasterListKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Loading overlay ──────────────────────────────────────────────── */}
|
||||||
|
{!challenge && <div className={styles.loading}>Loading challenge…</div>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small helper component ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ModeButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={`${styles.toolBtn}${active ? ` ${styles.active}` : ''}`} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
web/src/coaster/CoasterListPanel.module.css
Normal file
107
web/src/coaster/CoasterListPanel.module.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 200;
|
||||||
|
width: 240px;
|
||||||
|
background: rgba(8, 8, 12, 0.84);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.toggle:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleArrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.toggleArrow.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 4px 12px 10px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
flex: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.you {
|
||||||
|
color: rgba(245, 158, 11, 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadBtn {
|
||||||
|
padding: 3px 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.loadBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.13);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
padding: 3px 7px;
|
||||||
|
background: rgba(255, 69, 58, 0.08);
|
||||||
|
border: 1px solid rgba(255, 69, 58, 0.25);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: rgba(255, 100, 80, 0.85);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.deleteBtn:hover {
|
||||||
|
background: rgba(255, 69, 58, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
padding: 6px 0 2px;
|
||||||
|
}
|
||||||
61
web/src/coaster/CoasterListPanel.tsx
Normal file
61
web/src/coaster/CoasterListPanel.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { listCoasters, deleteCoaster } from '../api/coaster'
|
||||||
|
import type { SavedCoaster } from '../types/api'
|
||||||
|
import styles from './CoasterListPanel.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
challengeId: string
|
||||||
|
currentUsername: string | undefined
|
||||||
|
onLoad: (coaster: SavedCoaster) => void
|
||||||
|
refreshKey: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [coasters, setCoasters] = useState<SavedCoaster[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listCoasters(challengeId).then(setCoasters).catch(console.error)
|
||||||
|
}, [challengeId, refreshKey])
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await deleteCoaster(id)
|
||||||
|
setCoasters(prev => prev.filter(c => c.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<button className={styles.toggle} onClick={() => setOpen(o => !o)}>
|
||||||
|
<span>Coasters ({coasters.length})</span>
|
||||||
|
<span className={`${styles.toggleArrow}${open ? ` ${styles.open}` : ''}`}>▶</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.body}>
|
||||||
|
{coasters.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No coasters yet.</p>
|
||||||
|
) : (
|
||||||
|
coasters.map(c => {
|
||||||
|
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>
|
||||||
|
<button className={styles.loadBtn} onClick={() => onLoad(c)}>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
{isOwn && (
|
||||||
|
<button className={styles.deleteBtn} onClick={() => handleDelete(c.id)}>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
web/src/coaster/SimulationPlots.module.css
Normal file
196
web/src/coaster/SimulationPlots.module.css
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
.plotsPanel {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 200;
|
||||||
|
width: 340px;
|
||||||
|
background: rgba(8, 8, 12, 0.84);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotsToggle {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.plotsToggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-style: normal;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.arrowOpen {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotsBody {
|
||||||
|
padding: 4px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Strip list ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripIndex {
|
||||||
|
color: rgba(245, 158, 11, 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripRange {
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accelInput {
|
||||||
|
width: 50px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 5px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.accelInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripUnit {
|
||||||
|
color: rgba(255, 255, 255, 0.28);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripDelete {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 69, 58, 0.25);
|
||||||
|
background: rgba(255, 69, 58, 0.08);
|
||||||
|
color: rgba(255, 100, 80, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.stripDelete:hover {
|
||||||
|
background: rgba(255, 69, 58, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noStrips {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Charts ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
margin: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noProfile {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ride stats (duration / length) ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rideStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rideStat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rideStatLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rideStatValue {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rideStatDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
margin: 0 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
217
web/src/coaster/SimulationPlots.tsx
Normal file
217
web/src/coaster/SimulationPlots.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, ReferenceArea, ReferenceLine,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { SimulationProfile, AccelerationStrip } from '../types/api'
|
||||||
|
import styles from './SimulationPlots.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: SimulationProfile | null
|
||||||
|
strips: AccelerationStrip[]
|
||||||
|
onRemoveStrip: (id: string) => void
|
||||||
|
onUpdateStrip: (id: string, accel_ms2: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared axis / tooltip styles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const axisStyle = { fill: 'rgba(255,255,255,0.35)', fontSize: 10 }
|
||||||
|
const gridStyle = { stroke: 'rgba(255,255,255,0.07)' }
|
||||||
|
const tooltipStyle = {
|
||||||
|
contentStyle: {
|
||||||
|
background: 'rgba(8,8,12,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '4px 8px',
|
||||||
|
},
|
||||||
|
labelStyle: { color: 'rgba(255,255,255,0.45)', marginBottom: 2 },
|
||||||
|
itemStyle: { color: 'rgba(255,255,255,0.85)', padding: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
|
||||||
|
|
||||||
|
function formatDuration(s: number) {
|
||||||
|
if (s < 60) return `${s.toFixed(0)} s`
|
||||||
|
let m = Math.floor(s / 60)
|
||||||
|
let sec = Math.round(s % 60)
|
||||||
|
if (sec === 60) { m++; sec = 0 }
|
||||||
|
return `${m}m ${sec.toString().padStart(2, '0')}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strip ReferenceAreas (shared across all charts) ───────────────────────────
|
||||||
|
|
||||||
|
function StripAreas({ strips }: { strips: AccelerationStrip[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{strips.map(s => (
|
||||||
|
<ReferenceArea
|
||||||
|
key={s.id}
|
||||||
|
x1={s.startFrac}
|
||||||
|
x2={s.endFrac}
|
||||||
|
fill="rgba(245,158,11,0.10)"
|
||||||
|
stroke="rgba(245,158,11,0.28)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual chart ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ChartProps {
|
||||||
|
data: object[]
|
||||||
|
dataKey: string
|
||||||
|
color: string
|
||||||
|
unit: string
|
||||||
|
strips: AccelerationStrip[]
|
||||||
|
showZero?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartProps) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={108}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 6, bottom: 0, left: 28 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" {...gridStyle} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="s"
|
||||||
|
type="number"
|
||||||
|
domain={[0, 1]}
|
||||||
|
tickFormatter={pct}
|
||||||
|
tick={axisStyle}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={axisStyle}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={28}
|
||||||
|
tickFormatter={(v: number) => v.toFixed(1)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
{...tooltipStyle}
|
||||||
|
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]}
|
||||||
|
labelFormatter={(l: number) => `s = ${pct(l)}`}
|
||||||
|
/>
|
||||||
|
<StripAreas strips={strips} />
|
||||||
|
{showZero && (
|
||||||
|
<ReferenceLine y={0} stroke="rgba(255,255,255,0.18)" strokeDasharray="4 3" />
|
||||||
|
)}
|
||||||
|
<Line
|
||||||
|
dataKey={dataKey}
|
||||||
|
dot={false}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// Open the panel when a profile arrives; never auto-close it
|
||||||
|
useEffect(() => { if (profile) setOpen(true) }, [profile])
|
||||||
|
|
||||||
|
const data = profile
|
||||||
|
? profile.s_frac.map((s, i) => ({
|
||||||
|
s,
|
||||||
|
velocity: profile.velocity_ms[i] * 3.6, // m/s → km/h
|
||||||
|
acceleration: profile.accel_ms2[i],
|
||||||
|
gForce: profile.g_force[i],
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.plotsPanel}>
|
||||||
|
<button className={styles.plotsToggle} onClick={() => setOpen(o => !o)}>
|
||||||
|
<i className={`${styles.arrow}${open ? ` ${styles.arrowOpen}` : ''}`}>▶</i>
|
||||||
|
Simulation Profile
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.plotsBody}>
|
||||||
|
|
||||||
|
{/* ── Strip list ─────────────────────────────────────────────────── */}
|
||||||
|
<p className={styles.sectionLabel}>Acceleration Strips</p>
|
||||||
|
{strips.length === 0
|
||||||
|
? <p className={styles.noStrips}>No strips placed. Switch to Strip mode to add.</p>
|
||||||
|
: strips.map((s, i) => (
|
||||||
|
<div key={s.id} className={styles.stripRow}>
|
||||||
|
<span className={styles.stripIndex}>Strip {i + 1}</span>
|
||||||
|
<span className={styles.stripRange}>
|
||||||
|
{pct(s.startFrac)}–{pct(s.endFrac)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.5}
|
||||||
|
min={-50}
|
||||||
|
max={50}
|
||||||
|
value={s.accel_ms2}
|
||||||
|
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
|
||||||
|
className={styles.accelInput}
|
||||||
|
/>
|
||||||
|
<span className={styles.stripUnit}>m/s²</span>
|
||||||
|
<button
|
||||||
|
className={styles.stripDelete}
|
||||||
|
onClick={() => onRemoveStrip(s.id)}
|
||||||
|
title="Remove strip"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* ── Charts ─────────────────────────────────────────────────────── */}
|
||||||
|
{data ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.rideStats}>
|
||||||
|
<span className={styles.rideStat}>
|
||||||
|
<span className={styles.rideStatLabel}>Length</span>
|
||||||
|
<span className={styles.rideStatValue}>{(profile!.total_length_m / 1000).toFixed(2)} km</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.rideStatDivider} />
|
||||||
|
<span className={styles.rideStat}>
|
||||||
|
<span className={styles.rideStatLabel}>Duration</span>
|
||||||
|
<span className={styles.rideStatValue}>{formatDuration(profile!.total_duration_s)}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.rideStatDivider} />
|
||||||
|
<span className={styles.rideStat}>
|
||||||
|
<span className={styles.rideStatLabel}>Start speed</span>
|
||||||
|
<span className={styles.rideStatValue}>{(profile!.velocity_ms[0] * 3.6).toFixed(1)} km/h</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<p className={styles.chartLabel}>Velocity (km/h)</p>
|
||||||
|
<ProfileChart
|
||||||
|
data={data} dataKey="velocity" color="#4ade80"
|
||||||
|
unit="km/h" strips={strips}
|
||||||
|
/>
|
||||||
|
<p className={styles.chartLabel}>Acceleration (m/s²)</p>
|
||||||
|
<ProfileChart
|
||||||
|
data={data} dataKey="acceleration" color="#60a5fa"
|
||||||
|
unit="m/s²" strips={strips} showZero
|
||||||
|
/>
|
||||||
|
<p className={styles.chartLabel}>G-force (g)</p>
|
||||||
|
<ProfileChart
|
||||||
|
data={data} dataKey="gForce" color="#f87171"
|
||||||
|
unit="g" strips={strips} showZero
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className={styles.noProfile}>Run a simulation to see profile charts.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
web/src/coaster/bezierUtils.ts
Normal file
136
web/src/coaster/bezierUtils.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import * as Cesium from 'cesium'
|
||||||
|
|
||||||
|
export interface AnchorPoint {
|
||||||
|
id: string
|
||||||
|
position: Cesium.Cartesian3 // base position on terrain
|
||||||
|
heightOffset: number // meters above terrain surface
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function v3add(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
|
||||||
|
return Cesium.Cartesian3.add(a, b, new Cesium.Cartesian3())
|
||||||
|
}
|
||||||
|
function v3sub(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
|
||||||
|
return Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3())
|
||||||
|
}
|
||||||
|
function v3scale(v: Cesium.Cartesian3, s: number): Cesium.Cartesian3 {
|
||||||
|
return Cesium.Cartesian3.multiplyByScalar(v, s, new Cesium.Cartesian3())
|
||||||
|
}
|
||||||
|
function v3norm(v: Cesium.Cartesian3): Cesium.Cartesian3 {
|
||||||
|
return Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3())
|
||||||
|
}
|
||||||
|
function v3cross(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
|
||||||
|
return Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the effective 3-D position of an anchor including its height offset. */
|
||||||
|
export function effectivePosition(anchor: AnchorPoint): Cesium.Cartesian3 {
|
||||||
|
if (anchor.heightOffset === 0) return anchor.position.clone()
|
||||||
|
const up = v3norm(anchor.position)
|
||||||
|
return v3add(anchor.position, v3scale(up, anchor.heightOffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Catmull-Rom → cubic Bézier ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
p0: Cesium.Cartesian3
|
||||||
|
c1: Cesium.Cartesian3
|
||||||
|
c2: Cesium.Cartesian3
|
||||||
|
p1: Cesium.Cartesian3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert anchor points to cubic Bézier segments via Catmull-Rom.
|
||||||
|
* The curve passes through every anchor point with C1 continuity.
|
||||||
|
*/
|
||||||
|
export function computeSegments(anchors: AnchorPoint[]): Segment[] {
|
||||||
|
if (anchors.length < 2) return []
|
||||||
|
const pts = anchors.map(effectivePosition)
|
||||||
|
const n = pts.length
|
||||||
|
// phantom end-points so the curve starts/ends at the first/last anchor
|
||||||
|
const ext = [pts[0], ...pts, pts[n - 1]]
|
||||||
|
|
||||||
|
const segments: Segment[] = []
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const pm1 = ext[i]
|
||||||
|
const p0 = ext[i + 1]
|
||||||
|
const p1 = ext[i + 2]
|
||||||
|
const p2 = ext[i + 3]
|
||||||
|
// Catmull-Rom tangent handles converted to cubic Bézier control points
|
||||||
|
const c1 = v3add(p0, v3scale(v3sub(p1, pm1), 1 / 6))
|
||||||
|
const c2 = v3add(p1, v3scale(v3sub(p0, p2), 1 / 6))
|
||||||
|
segments.push({ p0, c1, c2, p1 })
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 {
|
||||||
|
const { p0, c1, c2, p1 } = seg
|
||||||
|
const mt = 1 - t
|
||||||
|
return new Cesium.Cartesian3(
|
||||||
|
mt ** 3 * p0.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * p1.x,
|
||||||
|
mt ** 3 * p0.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * p1.y,
|
||||||
|
mt ** 3 * p0.z + 3 * mt ** 2 * t * c1.z + 3 * mt * t ** 2 * c2.z + t ** 3 * p1.z,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample the full spline as a polyline (all segments concatenated). */
|
||||||
|
export function samplePath(
|
||||||
|
anchors: AnchorPoint[],
|
||||||
|
samplesPerSegment = 40,
|
||||||
|
): Cesium.Cartesian3[] {
|
||||||
|
const segs = computeSegments(anchors)
|
||||||
|
if (segs.length === 0) return anchors.map(effectivePosition)
|
||||||
|
|
||||||
|
const pts: Cesium.Cartesian3[] = []
|
||||||
|
segs.forEach((seg, i) => {
|
||||||
|
const from = i === 0 ? 0 : 1
|
||||||
|
for (let s = from; s <= samplesPerSegment; s++) {
|
||||||
|
pts.push(evalBezier(seg, s / samplesPerSegment))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rail geometry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RailPositions {
|
||||||
|
left: Cesium.Cartesian3[]
|
||||||
|
right: Cesium.Cartesian3[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a centre-line path compute parallel left/right rail positions.
|
||||||
|
* @param gauge distance between rails in metres (default 2.5 for visual clarity)
|
||||||
|
*/
|
||||||
|
export function computeRails(
|
||||||
|
path: Cesium.Cartesian3[],
|
||||||
|
gauge = 2.5,
|
||||||
|
): RailPositions {
|
||||||
|
const left: Cesium.Cartesian3[] = []
|
||||||
|
const right: Cesium.Cartesian3[] = []
|
||||||
|
const half = gauge / 2
|
||||||
|
const n = path.length
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const pt = path[i]
|
||||||
|
|
||||||
|
// Finite-difference tangent along the curve
|
||||||
|
let tangent: Cesium.Cartesian3
|
||||||
|
if (i === 0) tangent = v3sub(path[1], path[0])
|
||||||
|
else if (i === n - 1) tangent = v3sub(path[n - 1], path[n - 2])
|
||||||
|
else tangent = v3sub(path[i + 1], path[i - 1])
|
||||||
|
tangent = v3norm(tangent)
|
||||||
|
|
||||||
|
// Local up = radially outward from Earth centre
|
||||||
|
const up = v3norm(pt)
|
||||||
|
// Track-right = tangent × up (right-hand rule → points right when facing forward)
|
||||||
|
const rightDir = v3norm(v3cross(tangent, up))
|
||||||
|
|
||||||
|
left.push(v3add(pt, v3scale(rightDir, -half)))
|
||||||
|
right.push(v3add(pt, v3scale(rightDir, half)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, right }
|
||||||
|
}
|
||||||
240
web/src/coaster/useAccelerationStrips.ts
Normal file
240
web/src/coaster/useAccelerationStrips.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import type { AccelerationStrip } from '../types/api'
|
||||||
|
|
||||||
|
// ── Arc-length utilities ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeArcLengths(pts: Cesium.Cartesian3[]): number[] {
|
||||||
|
const s = [0]
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
s.push(s[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i]))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToPath(
|
||||||
|
pos: Cesium.Cartesian3,
|
||||||
|
pts: Cesium.Cartesian3[],
|
||||||
|
arcs: number[],
|
||||||
|
): { frac: number; pt: Cesium.Cartesian3 } {
|
||||||
|
let minDist = Infinity
|
||||||
|
let best = 0
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const d = Cesium.Cartesian3.distance(pos, pts[i])
|
||||||
|
if (d < minDist) { minDist = d; best = i }
|
||||||
|
}
|
||||||
|
const total = arcs[arcs.length - 1]
|
||||||
|
return { frac: total > 0 ? arcs[best] / total : 0, pt: pts[best] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fracToIndex(frac: number, arcs: number[]): number {
|
||||||
|
const target = frac * arcs[arcs.length - 1]
|
||||||
|
let best = 0, bestDiff = Infinity
|
||||||
|
for (let i = 0; i < arcs.length; i++) {
|
||||||
|
const diff = Math.abs(arcs[i] - target)
|
||||||
|
if (diff < bestDiff) { bestDiff = diff; best = i }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AccelerationStripsHandle {
|
||||||
|
strips: AccelerationStrip[]
|
||||||
|
removeStrip: (id: string) => void
|
||||||
|
updateStrip: (id: string, accel_ms2: number) => void
|
||||||
|
clearStrips: () => void
|
||||||
|
loadStrips: (strips: AccelerationStrip[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingStrip {
|
||||||
|
startFrac: number
|
||||||
|
marker: Cesium.Entity
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccelerationStrips(
|
||||||
|
viewer: Cesium.Viewer,
|
||||||
|
pathPts: Cesium.Cartesian3[],
|
||||||
|
isActive: boolean,
|
||||||
|
showStrips = true,
|
||||||
|
): AccelerationStripsHandle {
|
||||||
|
const [strips, setStrips] = useState<AccelerationStrip[]>([])
|
||||||
|
|
||||||
|
const pendingRef = useRef<PendingStrip | null>(null)
|
||||||
|
const stripEntities = useRef<Map<string, Cesium.Entity>>(new Map())
|
||||||
|
const pathPtsRef = useRef(pathPts)
|
||||||
|
pathPtsRef.current = pathPts
|
||||||
|
|
||||||
|
// ── Public callbacks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const removeStrip = useCallback((id: string) => {
|
||||||
|
setStrips(prev => prev.filter(s => s.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateStrip = useCallback((id: string, accel_ms2: number) => {
|
||||||
|
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearStrips = useCallback(() => {
|
||||||
|
setStrips([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadStrips = useCallback((newStrips: AccelerationStrip[]) => {
|
||||||
|
setStrips(newStrips)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Click handler for strip placement ─────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
||||||
|
|
||||||
|
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
|
||||||
|
const ray = viewer.camera.getPickRay(pos)
|
||||||
|
if (!ray) return null
|
||||||
|
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
|
||||||
|
if (!isActive) return
|
||||||
|
const pts = pathPtsRef.current
|
||||||
|
if (pts.length < 2) return
|
||||||
|
|
||||||
|
const worldPos = pickTerrain(e.position)
|
||||||
|
if (!worldPos) return
|
||||||
|
|
||||||
|
const arcs = computeArcLengths(pts)
|
||||||
|
const { frac, pt } = snapToPath(worldPos, pts, arcs)
|
||||||
|
|
||||||
|
if (!pendingRef.current) {
|
||||||
|
// First click — place start marker
|
||||||
|
const marker = viewer.entities.add({
|
||||||
|
position: new Cesium.ConstantPositionProperty(pt),
|
||||||
|
point: {
|
||||||
|
pixelSize: 14,
|
||||||
|
color: Cesium.Color.fromCssColorString('#f59e0b'),
|
||||||
|
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
|
||||||
|
outlineWidth: 2,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
text: 'Strip start',
|
||||||
|
font: '12px sans-serif',
|
||||||
|
fillColor: Cesium.Color.fromCssColorString('#f59e0b'),
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||||
|
pixelOffset: new Cesium.Cartesian2(0, -22),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pendingRef.current = { startFrac: frac, marker }
|
||||||
|
} else {
|
||||||
|
// Second click — complete the strip
|
||||||
|
const { startFrac, marker } = pendingRef.current
|
||||||
|
viewer.entities.remove(marker)
|
||||||
|
pendingRef.current = null
|
||||||
|
|
||||||
|
let sf = startFrac, ef = frac
|
||||||
|
if (sf > ef) [sf, ef] = [ef, sf]
|
||||||
|
if (sf === ef) return // degenerate — same point
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }])
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
||||||
|
|
||||||
|
// Right-click: cancel pending
|
||||||
|
handler.setInputAction(() => {
|
||||||
|
if (!isActive || !pendingRef.current) return
|
||||||
|
viewer.entities.remove(pendingRef.current.marker)
|
||||||
|
pendingRef.current = null
|
||||||
|
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!handler.isDestroyed()) handler.destroy()
|
||||||
|
}
|
||||||
|
}, [viewer, isActive]) // pathPts via ref — intentional
|
||||||
|
|
||||||
|
// ── Cesium polyline entity sync ────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
|
||||||
|
const currentIds = new Set(strips.map(s => s.id))
|
||||||
|
|
||||||
|
// Remove entities for deleted strips
|
||||||
|
stripEntities.current.forEach((entity, id) => {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
viewer.entities.remove(entity)
|
||||||
|
stripEntities.current.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pathPts.length < 2) return
|
||||||
|
|
||||||
|
const arcs = computeArcLengths(pathPts)
|
||||||
|
|
||||||
|
// Remove all existing strip entities and rebuild — pathPts may have changed
|
||||||
|
stripEntities.current.forEach(entity => viewer.entities.remove(entity))
|
||||||
|
stripEntities.current.clear()
|
||||||
|
|
||||||
|
for (const strip of strips) {
|
||||||
|
const si = fracToIndex(strip.startFrac, arcs)
|
||||||
|
const ei = fracToIndex(strip.endFrac, arcs)
|
||||||
|
const sliced = pathPts.slice(Math.min(si, ei), Math.max(si, ei) + 1)
|
||||||
|
if (sliced.length < 2) continue
|
||||||
|
|
||||||
|
const entity = viewer.entities.add({
|
||||||
|
id: `accel-strip-${strip.id}`,
|
||||||
|
polyline: {
|
||||||
|
positions: sliced,
|
||||||
|
width: 7,
|
||||||
|
material: Cesium.Color.fromCssColorString('#f59e0b'),
|
||||||
|
arcType: Cesium.ArcType.NONE,
|
||||||
|
clampToGround: false,
|
||||||
|
},
|
||||||
|
properties: { stripId: strip.id },
|
||||||
|
})
|
||||||
|
stripEntities.current.set(strip.id, entity)
|
||||||
|
}
|
||||||
|
}, [strips, pathPts, viewer])
|
||||||
|
|
||||||
|
// ── Right-click on strip entity to delete ─────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
||||||
|
|
||||||
|
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
|
||||||
|
if (isActive) return // handled by placement handler above
|
||||||
|
const picked = viewer.scene.pick(e.position)
|
||||||
|
if (!Cesium.defined(picked)) return
|
||||||
|
const entity = picked.id as Cesium.Entity | undefined
|
||||||
|
if (!(entity instanceof Cesium.Entity)) return
|
||||||
|
const stripId = entity.properties?.stripId?.getValue() as string | undefined
|
||||||
|
if (stripId) removeStrip(stripId)
|
||||||
|
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!handler.isDestroyed()) handler.destroy()
|
||||||
|
}
|
||||||
|
}, [viewer, isActive, removeStrip])
|
||||||
|
|
||||||
|
// ── Strip visibility toggle ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stripEntities.current.forEach(e => { e.show = showStrips })
|
||||||
|
}, [showStrips])
|
||||||
|
|
||||||
|
// ── Cleanup on unmount ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
if (pendingRef.current) viewer.entities.remove(pendingRef.current.marker)
|
||||||
|
stripEntities.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
stripEntities.current.clear()
|
||||||
|
}
|
||||||
|
}, [viewer])
|
||||||
|
|
||||||
|
return { strips, removeStrip, updateStrip, clearStrips, loadStrips }
|
||||||
|
}
|
||||||
338
web/src/coaster/useCoasterPath.ts
Normal file
338
web/src/coaster/useCoasterPath.ts
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import type { AnchorPoint } from './bezierUtils'
|
||||||
|
import { effectivePosition, samplePath, computeRails } from './bezierUtils'
|
||||||
|
|
||||||
|
export type EditorMode = 'add' | 'select' | 'strip'
|
||||||
|
|
||||||
|
export interface CoasterPathHandle {
|
||||||
|
anchors: AnchorPoint[]
|
||||||
|
pathPts: Cesium.Cartesian3[]
|
||||||
|
selectedId: string | null
|
||||||
|
mode: EditorMode
|
||||||
|
setMode: (m: EditorMode) => void
|
||||||
|
updateAnchorHeight: (id: string, delta: number) => void
|
||||||
|
removeAnchor: (id: string) => void
|
||||||
|
loadAnchors: (anchors: AnchorPoint[]) => void
|
||||||
|
undoLast: () => void
|
||||||
|
clearAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let _counter = 0
|
||||||
|
function genId(): string {
|
||||||
|
return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle {
|
||||||
|
const [anchors, setAnchors] = useState<AnchorPoint[]>([])
|
||||||
|
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [mode, setMode] = useState<EditorMode>('add')
|
||||||
|
|
||||||
|
// Keep refs in sync so event-handler closures always see current values
|
||||||
|
const anchorsRef = useRef(anchors); anchorsRef.current = anchors
|
||||||
|
const modeRef = useRef(mode); modeRef.current = mode
|
||||||
|
const selectedRef = useRef(selectedId); selectedRef.current = selectedId
|
||||||
|
|
||||||
|
// Cesium entity refs
|
||||||
|
const sphereMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
|
||||||
|
const pathEntities = useRef<Cesium.Entity[]>([])
|
||||||
|
const startLabelRef = useRef<Cesium.Entity | null>(null)
|
||||||
|
|
||||||
|
// Drag state (refs to avoid triggering re-renders)
|
||||||
|
const isDragging = useRef(false)
|
||||||
|
const dragAnchorId = useRef<string | null>(null)
|
||||||
|
const dragPos = useRef<Cesium.Cartesian3 | null>(null)
|
||||||
|
const didMoveDuringDrag = useRef(false)
|
||||||
|
|
||||||
|
// ── public callbacks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const updateAnchorHeight = useCallback((id: string, delta: number) => {
|
||||||
|
setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAnchor = useCallback((id: string) => {
|
||||||
|
setAnchors(prev => prev.filter(a => a.id !== id))
|
||||||
|
setSelectedId(prev => prev === id ? null : prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAnchors = useCallback((newAnchors: AnchorPoint[]) => {
|
||||||
|
setAnchors(newAnchors)
|
||||||
|
setSelectedId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const undoLast = useCallback(() => {
|
||||||
|
setAnchors(prev => {
|
||||||
|
if (prev.length === 0) return prev
|
||||||
|
const removed = prev[prev.length - 1]
|
||||||
|
setSelectedId(s => s === removed.id ? null : s)
|
||||||
|
return prev.slice(0, -1)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setAnchors([])
|
||||||
|
setSelectedId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── cursor style ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewer.scene.canvas.style.cursor =
|
||||||
|
mode === 'add' ? 'crosshair' :
|
||||||
|
mode === 'strip' ? 'cell' : 'default'
|
||||||
|
}, [mode, viewer])
|
||||||
|
|
||||||
|
// ── entity sync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existingIds = new Set(anchors.map(a => a.id))
|
||||||
|
|
||||||
|
// Remove spheres for deleted anchors
|
||||||
|
sphereMapRef.current.forEach((entity, id) => {
|
||||||
|
if (!existingIds.has(id)) {
|
||||||
|
viewer.entities.remove(entity)
|
||||||
|
sphereMapRef.current.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add or update anchor spheres
|
||||||
|
anchors.forEach((anchor) => {
|
||||||
|
const pos = effectivePosition(anchor)
|
||||||
|
const isSelected = anchor.id === selectedId
|
||||||
|
const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE
|
||||||
|
const size = isSelected ? 15 : 10
|
||||||
|
|
||||||
|
if (!sphereMapRef.current.has(anchor.id)) {
|
||||||
|
const entity = viewer.entities.add({
|
||||||
|
id: `coaster-anchor-${anchor.id}`,
|
||||||
|
position: new Cesium.ConstantPositionProperty(pos),
|
||||||
|
point: {
|
||||||
|
pixelSize: size,
|
||||||
|
color,
|
||||||
|
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
|
||||||
|
outlineWidth: 2,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
properties: { anchorId: anchor.id },
|
||||||
|
})
|
||||||
|
sphereMapRef.current.set(anchor.id, entity)
|
||||||
|
} else {
|
||||||
|
const entity = sphereMapRef.current.get(anchor.id)!
|
||||||
|
entity.position = new Cesium.ConstantPositionProperty(pos)
|
||||||
|
entity.point!.color = new Cesium.ConstantProperty(color)
|
||||||
|
entity.point!.pixelSize = new Cesium.ConstantProperty(size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rebuild path + rails
|
||||||
|
pathEntities.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
pathEntities.current = []
|
||||||
|
|
||||||
|
if (anchors.length >= 2) {
|
||||||
|
const pts = samplePath(anchors)
|
||||||
|
setPathPts(pts)
|
||||||
|
const { left, right } = computeRails(pts)
|
||||||
|
|
||||||
|
const centre = viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions: pts,
|
||||||
|
width: 2,
|
||||||
|
material: Cesium.Color.YELLOW.withAlpha(0.55),
|
||||||
|
arcType: Cesium.ArcType.NONE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const leftRail = viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions: left,
|
||||||
|
width: 3,
|
||||||
|
material: Cesium.Color.fromCssColorString('#b0b8c1'),
|
||||||
|
arcType: Cesium.ArcType.NONE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const rightRail = viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions: right,
|
||||||
|
width: 3,
|
||||||
|
material: Cesium.Color.fromCssColorString('#b0b8c1'),
|
||||||
|
arcType: Cesium.ArcType.NONE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pathEntities.current = [centre, leftRail, rightRail]
|
||||||
|
} else {
|
||||||
|
setPathPts([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start label — always at first anchor
|
||||||
|
if (startLabelRef.current) {
|
||||||
|
viewer.entities.remove(startLabelRef.current)
|
||||||
|
startLabelRef.current = null
|
||||||
|
}
|
||||||
|
if (anchors.length > 0) {
|
||||||
|
startLabelRef.current = viewer.entities.add({
|
||||||
|
position: new Cesium.ConstantPositionProperty(effectivePosition(anchors[0])),
|
||||||
|
label: {
|
||||||
|
text: '\u25B6 Start',
|
||||||
|
font: '13px sans-serif',
|
||||||
|
fillColor: Cesium.Color.fromCssColorString('#4ade80'),
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||||
|
pixelOffset: new Cesium.Cartesian2(0, -22),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
showBackground: true,
|
||||||
|
backgroundColor: Cesium.Color.BLACK.withAlpha(0.45),
|
||||||
|
backgroundPadding: new Cesium.Cartesian2(6, 4),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [anchors, selectedId, viewer])
|
||||||
|
|
||||||
|
// ── path visibility toggle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pathEntities.current.forEach(e => { e.show = showPath })
|
||||||
|
}, [showPath])
|
||||||
|
|
||||||
|
// ── anchor visibility toggle ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sphereMapRef.current.forEach(e => { e.show = showAnchors })
|
||||||
|
if (startLabelRef.current) startLabelRef.current.show = showAnchors
|
||||||
|
}, [showAnchors])
|
||||||
|
|
||||||
|
// ── input handling ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
||||||
|
const canvas = viewer.scene.canvas
|
||||||
|
|
||||||
|
// Prevent browser context-menu in the viewer
|
||||||
|
const suppressCtx = (e: Event) => e.preventDefault()
|
||||||
|
canvas.addEventListener('contextmenu', suppressCtx)
|
||||||
|
|
||||||
|
const disableCam = () => {
|
||||||
|
const c = viewer.scene.screenSpaceCameraController
|
||||||
|
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = false
|
||||||
|
}
|
||||||
|
const enableCam = () => {
|
||||||
|
const c = viewer.scene.screenSpaceCameraController
|
||||||
|
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAnchorId(pos: Cesium.Cartesian2): string | null {
|
||||||
|
const picked = viewer.scene.pick(pos)
|
||||||
|
if (!Cesium.defined(picked)) return null
|
||||||
|
const entity = picked.id as Cesium.Entity | undefined
|
||||||
|
if (!(entity instanceof Cesium.Entity)) return null
|
||||||
|
return entity.properties?.anchorId?.getValue() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
|
||||||
|
const ray = viewer.camera.getPickRay(pos)
|
||||||
|
if (!ray) return null
|
||||||
|
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEFT_DOWN – start drag in select mode
|
||||||
|
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
|
||||||
|
if (modeRef.current !== 'select') return
|
||||||
|
const anchorId = pickAnchorId(e.position)
|
||||||
|
if (!anchorId) return
|
||||||
|
isDragging.current = true
|
||||||
|
dragAnchorId.current = anchorId
|
||||||
|
dragPos.current = null
|
||||||
|
didMoveDuringDrag.current = false
|
||||||
|
disableCam()
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
|
||||||
|
|
||||||
|
// MOUSE_MOVE – live-drag the sphere
|
||||||
|
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => {
|
||||||
|
if (!isDragging.current || !dragAnchorId.current) return
|
||||||
|
const newPos = pickTerrain(e.endPosition)
|
||||||
|
if (!newPos) return
|
||||||
|
didMoveDuringDrag.current = true
|
||||||
|
dragPos.current = newPos
|
||||||
|
// Move the sphere entity directly (no React re-render during drag)
|
||||||
|
const entity = sphereMapRef.current.get(dragAnchorId.current)
|
||||||
|
if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos)
|
||||||
|
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
|
||||||
|
|
||||||
|
// LEFT_UP – commit drag
|
||||||
|
handler.setInputAction(() => {
|
||||||
|
if (isDragging.current) {
|
||||||
|
if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) {
|
||||||
|
const id = dragAnchorId.current
|
||||||
|
const pos = dragPos.current
|
||||||
|
setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a))
|
||||||
|
}
|
||||||
|
isDragging.current = false
|
||||||
|
dragAnchorId.current = null
|
||||||
|
dragPos.current = null
|
||||||
|
enableCam()
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_UP)
|
||||||
|
|
||||||
|
// LEFT_CLICK – add point or select
|
||||||
|
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
|
||||||
|
// Suppress click that immediately follows a completed drag
|
||||||
|
if (didMoveDuringDrag.current) {
|
||||||
|
didMoveDuringDrag.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorId = pickAnchorId(e.position)
|
||||||
|
if (anchorId) {
|
||||||
|
setSelectedId(prev => prev === anchorId ? null : anchorId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeRef.current === 'add') {
|
||||||
|
const pos = pickTerrain(e.position)
|
||||||
|
if (!pos) return
|
||||||
|
const id = genId()
|
||||||
|
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 0 }])
|
||||||
|
setSelectedId(null)
|
||||||
|
} else if (modeRef.current === 'select') {
|
||||||
|
setSelectedId(null)
|
||||||
|
}
|
||||||
|
// 'strip' mode clicks are handled by useAccelerationStrips
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
||||||
|
|
||||||
|
// RIGHT_CLICK – undo / remove selected
|
||||||
|
handler.setInputAction(() => {
|
||||||
|
if (selectedRef.current) {
|
||||||
|
const id = selectedRef.current
|
||||||
|
setAnchors(prev => prev.filter(a => a.id !== id))
|
||||||
|
setSelectedId(null)
|
||||||
|
} else {
|
||||||
|
setAnchors(prev => prev.slice(0, -1))
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!handler.isDestroyed()) handler.destroy()
|
||||||
|
canvas.removeEventListener('contextmenu', suppressCtx)
|
||||||
|
enableCam()
|
||||||
|
viewer.scene.canvas.style.cursor = 'default'
|
||||||
|
}
|
||||||
|
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── cleanup on unmount ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
|
sphereMapRef.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
sphereMapRef.current.clear()
|
||||||
|
pathEntities.current.forEach(e => viewer.entities.remove(e))
|
||||||
|
pathEntities.current = []
|
||||||
|
if (startLabelRef.current) {
|
||||||
|
viewer.entities.remove(startLabelRef.current)
|
||||||
|
startLabelRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [viewer])
|
||||||
|
|
||||||
|
return { anchors, pathPts, selectedId, mode, setMode, updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
|
||||||
|
}
|
||||||
@ -97,6 +97,7 @@ export function SplatLayer() {
|
|||||||
// Clean up all entities on unmount
|
// Clean up all entities on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (viewer.isDestroyed()) return
|
||||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||||
entityMapRef.current.clear()
|
entityMapRef.current.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,77 @@ export interface ChallengeCreateBody {
|
|||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Coaster ----------
|
||||||
|
|
||||||
|
export interface StoredAnchor {
|
||||||
|
id: string
|
||||||
|
lon: number
|
||||||
|
lat: number
|
||||||
|
terrainAlt: number // ellipsoid height of terrain click point
|
||||||
|
heightOffset: number // metres above terrain (user-adjustable)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedCoaster {
|
||||||
|
id: string
|
||||||
|
creator_username: string
|
||||||
|
challenge: string
|
||||||
|
name: string
|
||||||
|
anchors: StoredAnchor[]
|
||||||
|
acceleration_strips: Array<{ id: string; startFrac: number; endFrac: number; accel_ms2: number }>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface AccelerationStrip {
|
||||||
|
id: string // client-only, not sent to backend
|
||||||
|
startFrac: number // 0–1 along total path arc length
|
||||||
|
endFrac: number
|
||||||
|
accel_ms2: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationProfile {
|
||||||
|
s_frac: number[]
|
||||||
|
velocity_ms: number[]
|
||||||
|
accel_ms2: number[]
|
||||||
|
g_force: number[]
|
||||||
|
total_length_m: number
|
||||||
|
total_duration_s: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoasterSimulateBody {
|
||||||
|
path: [number, number, number][] // [lon_deg, lat_deg, alt_m]
|
||||||
|
params?: {
|
||||||
|
rail_spacing?: number
|
||||||
|
mass?: number
|
||||||
|
initial_velocity?: number
|
||||||
|
friction_coeff?: number
|
||||||
|
junction_window_length?: number
|
||||||
|
junction_threshold?: number
|
||||||
|
binormal_smooth_iterations?: number
|
||||||
|
downsample_factor?: number
|
||||||
|
internal_steps?: number
|
||||||
|
}
|
||||||
|
acceleration_strips?: Array<{ start_frac: number; end_frac: number; accel_ms2: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoasterSimulationResult {
|
||||||
|
rail_1: [number, number, number][]
|
||||||
|
rail_2: [number, number, number][]
|
||||||
|
origin: [number, number, number]
|
||||||
|
model_url: string | null
|
||||||
|
diagnostics: {
|
||||||
|
height_range_m: [number, number]
|
||||||
|
velocity_range_ms: [number, number]
|
||||||
|
g_force_range: [number, number] | null
|
||||||
|
stall_at_pct: number | null
|
||||||
|
binormal_variation_max: number
|
||||||
|
binormal_downward_pct: number
|
||||||
|
}
|
||||||
|
profile: SimulationProfile
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Users ----------
|
// ---------- Users ----------
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
|
|||||||
@ -1,43 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useChallengeStore } from '../store/challengeStore'
|
import { useChallengeStore } from '../store/challengeStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import { MyChallengesPanel } from '../challenges/MyChallengesPanel'
|
||||||
|
import { SearchBar } from './SearchBar'
|
||||||
|
import { OverlayControls } from './OverlayControls'
|
||||||
import styles from './MapOverlay.module.css'
|
import styles from './MapOverlay.module.css'
|
||||||
|
|
||||||
export function MapOverlay() {
|
export function MapOverlay() {
|
||||||
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
|
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
|
||||||
const { logout } = useAuthStore()
|
const { logout } = useAuthStore()
|
||||||
|
const [showMine, setShowMine] = useState(false)
|
||||||
|
|
||||||
function handleDrawToggle() {
|
function handleDrawToggle() {
|
||||||
if (drawingMode) {
|
if (drawingMode) {
|
||||||
setDrawingMode(false)
|
setDrawingMode(false)
|
||||||
setDraftPolygon(null)
|
setDraftPolygon(null)
|
||||||
} else {
|
} else {
|
||||||
|
setShowMine(false)
|
||||||
setDrawingMode(true)
|
setDrawingMode(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.toolbar}>
|
<>
|
||||||
<button
|
<div className={styles.toolbar}>
|
||||||
className={`${styles.btn} ${drawingMode ? styles.active : ''}`}
|
<SearchBar />
|
||||||
onClick={handleDrawToggle}
|
<OverlayControls />
|
||||||
title={drawingMode ? 'Cancel drawing' : 'Create challenge (draw region)'}
|
|
||||||
>
|
|
||||||
{drawingMode ? '✕ Cancel' : '+ Challenge'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{drawingMode && (
|
<button
|
||||||
<p className={styles.hint}>
|
className={`${styles.btn} ${drawingMode ? styles.active : ''}`}
|
||||||
Click to place vertices · Right-click to close polygon
|
onClick={handleDrawToggle}
|
||||||
</p>
|
title={drawingMode ? 'Cancel drawing' : 'Create challenge (draw region)'}
|
||||||
)}
|
>
|
||||||
|
{drawingMode ? '✕ Cancel' : '+ Challenge'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`${styles.btn} ${styles.logout}`}
|
className={`${styles.btn} ${showMine ? styles.active : ''}`}
|
||||||
onClick={() => logout()}
|
onClick={() => setShowMine((v) => !v)}
|
||||||
title="Sign out"
|
title="View my challenges"
|
||||||
>
|
>
|
||||||
Sign out
|
My Challenges
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
{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)} />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
35
web/src/ui/OverlayControls.module.css
Normal file
35
web/src/ui/OverlayControls.module.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(15, 15, 20, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
background: rgba(40, 40, 55, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
border-color: rgba(16, 185, 129, 0.6);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
91
web/src/ui/OverlayControls.tsx
Normal file
91
web/src/ui/OverlayControls.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||||
|
import styles from './OverlayControls.module.css'
|
||||||
|
|
||||||
|
interface OverlayDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
// UrlTemplateImageryProvider url — uses {z}/{x}/{y} OR {z}/{y}/{x} for ESRI
|
||||||
|
url: string
|
||||||
|
alpha: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERLAYS: OverlayDef[] = [
|
||||||
|
{
|
||||||
|
id: 'streets',
|
||||||
|
label: 'Streets',
|
||||||
|
// ESRI World Transportation — roads, highways, rail; no labels
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
alpha: 0.75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labels',
|
||||||
|
label: 'City names',
|
||||||
|
// CartoDB Voyager labels-only — place/city/country labels
|
||||||
|
url: 'https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png',
|
||||||
|
alpha: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'borders',
|
||||||
|
label: 'Borders',
|
||||||
|
// ESRI World Boundaries & Places — country + admin borders + labels
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
alpha: 0.85,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function OverlayControls() {
|
||||||
|
const viewer = useCesiumViewer()
|
||||||
|
const [active, setActive] = useState<Set<string>>(new Set())
|
||||||
|
// Keep a ref map from overlay id → the live ImageryLayer so we can remove it
|
||||||
|
const layerRefs = useRef<Map<string, Cesium.ImageryLayer>>(new Map())
|
||||||
|
|
||||||
|
function toggle(overlay: OverlayDef) {
|
||||||
|
setActive((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(overlay.id)) {
|
||||||
|
// Remove layer
|
||||||
|
const layer = layerRefs.current.get(overlay.id)
|
||||||
|
if (layer && !viewer.isDestroyed()) {
|
||||||
|
viewer.imageryLayers.remove(layer, true)
|
||||||
|
}
|
||||||
|
layerRefs.current.delete(overlay.id)
|
||||||
|
next.delete(overlay.id)
|
||||||
|
} else {
|
||||||
|
// Add layer
|
||||||
|
const provider = new Cesium.UrlTemplateImageryProvider({ url: overlay.url })
|
||||||
|
const layer = viewer.imageryLayers.addImageryProvider(provider)
|
||||||
|
layer.alpha = overlay.alpha
|
||||||
|
layerRefs.current.set(overlay.id, layer)
|
||||||
|
next.add(overlay.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up all layers if the component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = layerRefs.current
|
||||||
|
return () => {
|
||||||
|
refs.forEach((layer) => {
|
||||||
|
if (!viewer.isDestroyed()) viewer.imageryLayers.remove(layer, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [viewer])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.group}>
|
||||||
|
{OVERLAYS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
className={`${styles.chip} ${active.has(o.id) ? styles.on : ''}`}
|
||||||
|
onClick={() => toggle(o)}
|
||||||
|
title={`Toggle ${o.label} overlay`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
web/src/ui/SearchBar.module.css
Normal file
83
web/src/ui/SearchBar.module.css
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: rgba(15, 15, 20, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(15, 15, 20, 0.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
z-index: 30;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
106
web/src/ui/SearchBar.tsx
Normal file
106
web/src/ui/SearchBar.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||||
|
import styles from './SearchBar.module.css'
|
||||||
|
|
||||||
|
interface NominatimResult {
|
||||||
|
place_id: number
|
||||||
|
display_name: string
|
||||||
|
lat: string
|
||||||
|
lon: string
|
||||||
|
boundingbox: [string, string, string, string] // [minLat, maxLat, minLon, maxLon]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const viewer = useCesiumViewer()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<NominatimResult[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleChange(value: string) {
|
||||||
|
setQuery(value)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
if (!value.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(value)}&format=json&limit=6&addressdetails=0`
|
||||||
|
const res = await fetch(url, { headers: { 'Accept-Language': 'en' } })
|
||||||
|
const data: NominatimResult[] = await res.json()
|
||||||
|
setResults(data)
|
||||||
|
setOpen(data.length > 0)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
setOpen(false)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flyTo(result: NominatimResult) {
|
||||||
|
const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(Number)
|
||||||
|
const rect = Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat)
|
||||||
|
viewer.camera.flyTo({
|
||||||
|
destination: rect,
|
||||||
|
duration: 1.5,
|
||||||
|
})
|
||||||
|
setQuery(result.display_name.split(',')[0])
|
||||||
|
setOpen(false)
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (results.length > 0) flyTo(results[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={styles.container}>
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<span className={styles.icon}>⌕</span>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search city…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onFocus={() => results.length > 0 && setOpen(true)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{loading && <span className={styles.spinner} />}
|
||||||
|
</form>
|
||||||
|
{open && (
|
||||||
|
<ul className={styles.dropdown}>
|
||||||
|
{results.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.place_id}
|
||||||
|
className={styles.item}
|
||||||
|
onMouseDown={() => flyTo(r)}
|
||||||
|
>
|
||||||
|
{r.display_name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user