Compare commits
3 Commits
42197bfbc9
...
81a6f1fa37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a6f1fa37 | ||
|
|
b836d9c01b | ||
|
|
3aeff0a7e7 |
1031
web/package-lock.json
generated
1031
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@mkkellogg/gaussian-splats-3d": "^0.4.6",
|
||||
"axios": "^1.7.9",
|
||||
"cesium": "^1.124.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -22,12 +22,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/maplibre-gl": "^1.0.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/three": "^0.171.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-cesium": "^1.2.22"
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { CallbackPage } from './auth/CallbackPage'
|
||||
import { CesiumViewer } from './cesium/CesiumViewer'
|
||||
import { MapLibreViewer } from './maplibre/MapLibreViewer'
|
||||
import { SplatLayer } from './splat/SplatLayer'
|
||||
import { SplatRenderer } from './splat/SplatRenderer'
|
||||
import { ChallengeLayer } from './challenges/ChallengeLayer'
|
||||
@ -17,17 +17,17 @@ import { UserProfilePage } from './users/UserProfilePage'
|
||||
|
||||
function MapPage() {
|
||||
return (
|
||||
<CesiumViewer>
|
||||
{/* Imperative Cesium layers — render no DOM, manage entities */}
|
||||
<MapLibreViewer>
|
||||
{/* Map-layer components — render no DOM, manage MapLibre sources */}
|
||||
<SplatLayer />
|
||||
<ChallengeLayer />
|
||||
{/* Three.js splat overlay — portalled canvas above Cesium */}
|
||||
{/* Three.js splat overlay — portalled canvas above the map */}
|
||||
<SplatRenderer />
|
||||
{/* React UI — z-indexed above Cesium canvas */}
|
||||
{/* React UI — z-indexed above the map canvas */}
|
||||
<MapOverlay />
|
||||
<ChallengePanel />
|
||||
<ChallengeCreator />
|
||||
</CesiumViewer>
|
||||
</MapLibreViewer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import 'cesium/Build/Cesium/Widgets/widgets.css'
|
||||
import { CesiumContext } from './cesiumContext'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function CesiumViewer({ children }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [viewer, setViewer] = useState<Cesium.Viewer | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Guard: only create if the container is mounted and no viewer yet
|
||||
if (!containerRef.current || viewer) return
|
||||
|
||||
Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_ION_TOKEN ?? ''
|
||||
|
||||
const v = new Cesium.Viewer(containerRef.current, {
|
||||
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
|
||||
homeButton: false,
|
||||
baseLayerPicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
geocoder: false,
|
||||
sceneModePicker: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
})
|
||||
|
||||
// Async: upgrade to world terrain after initial load
|
||||
Cesium.createWorldTerrainAsync()
|
||||
.then((tp) => {
|
||||
if (!v.isDestroyed()) v.terrainProvider = tp
|
||||
})
|
||||
.catch(() => {/* non-fatal: fall back to ellipsoid */})
|
||||
|
||||
// Async: switch base imagery to Bing Aerial with Labels
|
||||
Cesium.createWorldImageryAsync({ style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS })
|
||||
.then((ip) => {
|
||||
if (!v.isDestroyed()) v.imageryLayers.get(0).imageryProvider = ip
|
||||
})
|
||||
.catch(() => {/* non-fatal: keep default imagery */})
|
||||
|
||||
setViewer(v)
|
||||
|
||||
return () => {
|
||||
if (!v.isDestroyed()) v.destroy()
|
||||
setViewer(null)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cesium mounts itself into this div and fills it completely */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: 'fixed', inset: 0 }}
|
||||
/>
|
||||
{/* Provide viewer to all children; only render children once viewer is ready */}
|
||||
{viewer && (
|
||||
<CesiumContext.Provider value={viewer}>
|
||||
{children}
|
||||
</CesiumContext.Provider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type * as CesiumType from 'cesium'
|
||||
|
||||
type Viewer = InstanceType<typeof CesiumType.Viewer>
|
||||
|
||||
export const CesiumContext = createContext<Viewer | null>(null)
|
||||
|
||||
export function useCesiumViewer(): Viewer {
|
||||
const viewer = useContext(CesiumContext)
|
||||
if (!viewer) {
|
||||
throw new Error('useCesiumViewer must be used inside <CesiumViewer>')
|
||||
}
|
||||
return viewer
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import * as Cesium from 'cesium'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Build a Three.js Matrix4 that positions and orients a local scene
|
||||
* at the given geographic coordinate.
|
||||
*
|
||||
* The returned matrix transforms from local ENU space (metres from the
|
||||
* anchor point, X=East, Y=North, Z=Up) to Cesium ECEF space (metres
|
||||
* from Earth centre). Apply it to a Three.js Object3D.matrixWorld and
|
||||
* set matrixAutoUpdate = false.
|
||||
*
|
||||
* @param lon Longitude in degrees
|
||||
* @param lat Latitude in degrees
|
||||
* @param alt Altitude in metres above WGS-84 ellipsoid
|
||||
* @param headingDeg Clockwise heading in degrees (0 = North, 90 = East)
|
||||
*/
|
||||
export function buildSplatWorldMatrix(
|
||||
lon: number,
|
||||
lat: number,
|
||||
alt: number,
|
||||
headingDeg: number,
|
||||
): THREE.Matrix4 {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
|
||||
|
||||
// 4×4 column-major matrix: local ENU → ECEF
|
||||
const enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(position)
|
||||
|
||||
// Apply a rotation around local Up (Z in ENU) for heading.
|
||||
// Cesium heading is clockwise from North, which is –Z rotation in ENU.
|
||||
const headingRad = Cesium.Math.toRadians(-headingDeg)
|
||||
const headingRotation = Cesium.Matrix4.fromRotationTranslation(
|
||||
Cesium.Matrix3.fromRotationZ(headingRad),
|
||||
)
|
||||
const finalCesiumMatrix = new Cesium.Matrix4()
|
||||
Cesium.Matrix4.multiply(enuToEcef, headingRotation, finalCesiumMatrix)
|
||||
|
||||
// Cesium Matrix4 is a Float64Array in column-major order.
|
||||
// Three.js Matrix4 uses Float32Array, also column-major.
|
||||
// Direct cast works since both use the same element layout.
|
||||
const threeMatrix = new THREE.Matrix4()
|
||||
threeMatrix.set(
|
||||
finalCesiumMatrix[0], finalCesiumMatrix[4], finalCesiumMatrix[8], finalCesiumMatrix[12],
|
||||
finalCesiumMatrix[1], finalCesiumMatrix[5], finalCesiumMatrix[9], finalCesiumMatrix[13],
|
||||
finalCesiumMatrix[2], finalCesiumMatrix[6], finalCesiumMatrix[10], finalCesiumMatrix[14],
|
||||
finalCesiumMatrix[3], finalCesiumMatrix[7], finalCesiumMatrix[11], finalCesiumMatrix[15],
|
||||
)
|
||||
return threeMatrix
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Cesium Rectangle (radians) to a bbox tuple (degrees).
|
||||
*/
|
||||
export function rectangleToBbox(
|
||||
rect: Cesium.Rectangle,
|
||||
): [number, number, number, number] {
|
||||
return [
|
||||
Cesium.Math.toDegrees(rect.west),
|
||||
Cesium.Math.toDegrees(rect.south),
|
||||
Cesium.Math.toDegrees(rect.east),
|
||||
Cesium.Math.toDegrees(rect.north),
|
||||
]
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useCesiumViewer } from './cesiumContext'
|
||||
import { useMapStore } from '../store/mapStore'
|
||||
import { rectangleToBbox } from './geoUtils'
|
||||
|
||||
/**
|
||||
* Attaches a scene.preUpdate listener that writes camera height and
|
||||
* the current view bbox to mapStore on every frame.
|
||||
*
|
||||
* Throttled so the store update fires at most once per 200 ms to avoid
|
||||
* triggering expensive API queries on every rendered frame.
|
||||
*/
|
||||
export function useCesiumCamera() {
|
||||
const viewer = useCesiumViewer()
|
||||
const setCameraState = useMapStore((s) => s.setCameraState)
|
||||
|
||||
useEffect(() => {
|
||||
let lastFired = 0
|
||||
const THROTTLE_MS = 200
|
||||
|
||||
const removeListener = viewer.scene.preUpdate.addEventListener(() => {
|
||||
const now = Date.now()
|
||||
if (now - lastFired < THROTTLE_MS) return
|
||||
lastFired = now
|
||||
|
||||
const height = viewer.camera.positionCartographic.height
|
||||
const rect = viewer.camera.computeViewRectangle()
|
||||
if (rect) {
|
||||
setCameraState(height, rectangleToBbox(rect))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [viewer, setCameraState])
|
||||
}
|
||||
@ -1,31 +1,53 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import maplibregl, { type GeoJSONSource } from 'maplibre-gl'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
import { useMapStore } from '../store/mapStore'
|
||||
import { useChallengeStore } from '../store/challengeStore'
|
||||
import { usePolygonDraw } from './usePolygonDraw'
|
||||
import { fetchChallenges } from '../api/challenges'
|
||||
import { fetchChallenges, fetchChallengeDetail } from '../api/challenges'
|
||||
import type { BBox } from '../types/geo'
|
||||
import type { ChallengeMapProperties } from '../types/api'
|
||||
|
||||
const CHALLENGE_VISIBLE_HEIGHT = 200_000
|
||||
|
||||
const SRC_REGION = 'challenge-region'
|
||||
const LYR_REGION_FILL = 'challenge-region-fill'
|
||||
const LYR_REGION_LINE = 'challenge-region-line'
|
||||
|
||||
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
|
||||
|
||||
export function ChallengeLayer() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
usePolygonDraw()
|
||||
|
||||
const { bbox, cameraHeight, setLoadedChallenges } = useMapStore()
|
||||
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
|
||||
|
||||
const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
|
||||
const regionEntityRef = useRef<Cesium.Entity | null>(null)
|
||||
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map())
|
||||
const lastBboxRef = useRef<BBox | null>(null)
|
||||
|
||||
// Fetch and render challenge pins
|
||||
// ── Set up region polygon sources + layers ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() })
|
||||
map.addLayer({ id: LYR_REGION_FILL, type: 'fill', source: SRC_REGION,
|
||||
paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } })
|
||||
map.addLayer({ id: LYR_REGION_LINE, type: 'line', source: SRC_REGION,
|
||||
paint: { 'line-color': '#fbbf24', 'line-width': 2 } })
|
||||
|
||||
return () => {
|
||||
safeRemoveLayers(map,
|
||||
[LYR_REGION_FILL, LYR_REGION_LINE],
|
||||
[SRC_REGION],
|
||||
)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// ── Fetch and render challenge markers ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!bbox || cameraHeight > CHALLENGE_VISIBLE_HEIGHT) {
|
||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||
entityMapRef.current.clear()
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
setLoadedChallenges([])
|
||||
return
|
||||
}
|
||||
@ -43,101 +65,68 @@ export function ChallengeLayer() {
|
||||
fetchChallenges({ bbox }).then((fc) => {
|
||||
const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => String(f.id)))
|
||||
|
||||
entityMapRef.current.forEach((entity, id) => {
|
||||
if (!incoming.has(id)) {
|
||||
viewer.entities.remove(entity)
|
||||
entityMapRef.current.delete(id)
|
||||
}
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) }
|
||||
})
|
||||
|
||||
fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, ChallengeMapProperties>) => {
|
||||
const id = String(feature.id)
|
||||
if (entityMapRef.current.has(id)) return
|
||||
if (markersRef.current.has(id)) return
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates
|
||||
const el = createChallengePinElement()
|
||||
el.addEventListener('click', () => setSelectedChallengeId(id))
|
||||
|
||||
const entity = viewer.entities.add({
|
||||
id: `challenge-${id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
billboard: {
|
||||
image: createChallengePinSvg(),
|
||||
width: 36,
|
||||
height: 36,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
properties: { challengeId: id },
|
||||
})
|
||||
entityMapRef.current.set(id, entity)
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([lon, lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(id, marker)
|
||||
})
|
||||
|
||||
setLoadedChallenges(fc.features)
|
||||
}).catch(console.error)
|
||||
}, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Show region polygon for selected challenge
|
||||
// ── Show region polygon for selected challenge ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (regionEntityRef.current) {
|
||||
viewer.entities.remove(regionEntityRef.current)
|
||||
regionEntityRef.current = null
|
||||
const src = map.getSource(SRC_REGION) as GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
|
||||
if (!selectedChallengeId) {
|
||||
src.setData(emptyFC())
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedChallengeId) return
|
||||
|
||||
// We need the full detail to get the region polygon — ChallengePanel fetches
|
||||
// it; we read from the DOM or re-fetch. For simplicity, re-fetch here.
|
||||
import('../api/challenges').then(({ fetchChallengeDetail }) =>
|
||||
fetchChallengeDetail(selectedChallengeId),
|
||||
).then((detail) => {
|
||||
fetchChallengeDetail(selectedChallengeId).then((detail) => {
|
||||
if (!detail.region) return
|
||||
const coords = detail.region.coordinates[0]
|
||||
const positions = coords.map((c) =>
|
||||
Cesium.Cartesian3.fromDegrees(c[0], c[1]),
|
||||
)
|
||||
|
||||
regionEntityRef.current = viewer.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
material: Cesium.Color.YELLOW.withAlpha(0.15),
|
||||
outline: true,
|
||||
outlineColor: Cesium.Color.YELLOW,
|
||||
outlineWidth: 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
src.setData({
|
||||
type: 'Feature',
|
||||
geometry: detail.region,
|
||||
properties: {},
|
||||
})
|
||||
}).catch(console.error)
|
||||
}, [selectedChallengeId, viewer])
|
||||
}, [selectedChallengeId, map])
|
||||
|
||||
// Wire up entity selection
|
||||
useEffect(() => {
|
||||
const remove = viewer.selectedEntityChanged.addEventListener((entity) => {
|
||||
if (!entity) return
|
||||
const challengeId = entity.properties?.challengeId?.getValue()
|
||||
if (challengeId) setSelectedChallengeId(challengeId)
|
||||
})
|
||||
return () => remove()
|
||||
}, [viewer, setSelectedChallengeId])
|
||||
|
||||
// Cleanup on unmount
|
||||
// ── Cleanup on unmount ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewer.isDestroyed()) return
|
||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||
entityMapRef.current.clear()
|
||||
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [viewer])
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function createChallengePinSvg(): string {
|
||||
const svg = `
|
||||
function createChallengePinElement(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:36px;height:36px;cursor:pointer'
|
||||
el.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="16" r="12" fill="#f59e0b" stroke="#fff" stroke-width="2"/>
|
||||
<polygon points="18,32 11,21 25,21" fill="#f59e0b"/>
|
||||
<text x="18" y="20" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">!</text>
|
||||
</svg>
|
||||
`
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`
|
||||
return el
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Panel } from '../ui/Panel'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { useChallengeStore } from '../store/challengeStore'
|
||||
import { fetchChallengeDetail, participateInChallenge } from '../api/challenges'
|
||||
import type { ChallengeDetail } from '../types/api'
|
||||
import styles from './ChallengePanel.module.css'
|
||||
|
||||
export function ChallengePanel() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
const navigate = useNavigate()
|
||||
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
|
||||
const [detail, setDetail] = useState<ChallengeDetail | null>(null)
|
||||
@ -31,10 +30,7 @@ export function ChallengePanel() {
|
||||
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,
|
||||
})
|
||||
map.flyTo({ center: [lon, lat], zoom: 14, pitch: 50, duration: 1500 })
|
||||
}
|
||||
|
||||
async function handleParticipate() {
|
||||
|
||||
@ -1,158 +1,157 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import type { Map, MapMouseEvent, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
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]]] }
|
||||
// ── Layer / source IDs ────────────────────────────────────────────────────────
|
||||
|
||||
const SRC_FILL = 'pd-fill'
|
||||
const SRC_OUTLINE = 'pd-outline'
|
||||
const SRC_RUBBER = 'pd-rubber'
|
||||
const SRC_VERTICES = 'pd-vertices'
|
||||
const LYR_FILL = 'pd-fill-lyr'
|
||||
const LYR_OUTLINE = 'pd-outline-lyr'
|
||||
const LYR_RUBBER = 'pd-rubber-lyr'
|
||||
const LYR_VERTICES = 'pd-vertices-lyr'
|
||||
|
||||
function emptyFC(): GeoJSON.FeatureCollection {
|
||||
return { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
|
||||
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 addDrawLayers(map: Map) {
|
||||
map.addSource(SRC_FILL, { type: 'geojson', data: emptyFC() })
|
||||
map.addSource(SRC_OUTLINE, { type: 'geojson', data: emptyFC() })
|
||||
map.addSource(SRC_RUBBER, { type: 'geojson', data: emptyFC() })
|
||||
map.addSource(SRC_VERTICES, { type: 'geojson', data: emptyFC() })
|
||||
|
||||
map.addLayer({ id: LYR_FILL, type: 'fill', source: SRC_FILL,
|
||||
paint: { 'fill-color': '#fbbf24', 'fill-opacity': 0.15 } })
|
||||
map.addLayer({ id: LYR_OUTLINE, type: 'line', source: SRC_OUTLINE,
|
||||
paint: { 'line-color': '#fbbf24', 'line-width': 2 } })
|
||||
map.addLayer({ id: LYR_RUBBER, type: 'line', source: SRC_RUBBER,
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 1.5, 'line-dasharray': [4, 4] } })
|
||||
map.addLayer({ id: LYR_VERTICES, type: 'circle', source: SRC_VERTICES,
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#ffffff',
|
||||
'circle-stroke-color': '#000000',
|
||||
'circle-stroke-width': 2,
|
||||
} })
|
||||
}
|
||||
|
||||
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
|
||||
function removeDrawLayers(map: Map) {
|
||||
safeRemoveLayers(map,
|
||||
[LYR_FILL, LYR_OUTLINE, LYR_RUBBER, LYR_VERTICES],
|
||||
[SRC_FILL, SRC_OUTLINE, SRC_RUBBER, SRC_VERTICES],
|
||||
)
|
||||
}
|
||||
|
||||
function setSource(map: Map, id: string, data: GeoJSON.GeoJSON) {
|
||||
(map.getSource(id) as GeoJSONSource | undefined)?.setData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages two phases of polygon interaction:
|
||||
*
|
||||
* Drawing (drawingMode=true)
|
||||
* LEFT_CLICK → place vertex
|
||||
* MOUSE_MOVE → rubber-band line from last vertex to cursor
|
||||
* RIGHT_CLICK → close polygon (≥3 verts), enter edit phase
|
||||
* click → place vertex
|
||||
* mousemove → rubber-band line from last vertex to cursor
|
||||
* contextmenu → 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() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
|
||||
useChallengeStore()
|
||||
|
||||
// Persists vertex positions across edit-phase effect re-runs that are
|
||||
// triggered by setDraftPolygon being called after each drag.
|
||||
const editVertsRef = useRef<Cesium.Cartesian3[]>([])
|
||||
const editVertsRef = useRef<[number, number][]>([])
|
||||
|
||||
// ── Set up / tear down MapLibre sources + layers ──────────────────────────
|
||||
useEffect(() => {
|
||||
addDrawLayers(map)
|
||||
return () => { removeDrawLayers(map) }
|
||||
}, [map])
|
||||
|
||||
// ── Drawing phase ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!drawingMode) return
|
||||
|
||||
const verts: Cesium.Cartesian3[] = []
|
||||
const vertPointEntities: Cesium.Entity[] = []
|
||||
let outlineEntity: Cesium.Entity | null = null
|
||||
let rubberBandEntity: Cesium.Entity | null = null
|
||||
const verts: [number, number][] = []
|
||||
const canvas = map.getCanvas()
|
||||
|
||||
const canvas = viewer.scene.canvas
|
||||
|
||||
// Prevent the browser context menu so right-click can close the polygon.
|
||||
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
|
||||
canvas.addEventListener('contextmenu', suppressContextMenu)
|
||||
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
|
||||
canvas.style.cursor = 'crosshair'
|
||||
|
||||
function refreshOutline() {
|
||||
if (outlineEntity) {
|
||||
viewer.entities.remove(outlineEntity)
|
||||
outlineEntity = null
|
||||
if (verts.length < 2) {
|
||||
setSource(map, SRC_OUTLINE, emptyFC())
|
||||
return
|
||||
}
|
||||
if (verts.length < 2) return
|
||||
outlineEntity = viewer.entities.add({
|
||||
polyline: {
|
||||
positions: [...verts, verts[0]],
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
Cesium.Color.YELLOW.withAlpha(0.9),
|
||||
),
|
||||
clampToGround: true,
|
||||
},
|
||||
setSource(map, SRC_OUTLINE, {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: [...verts, verts[0]] },
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
|
||||
function refreshRubberBand(mousePos: Cesium.Cartesian3) {
|
||||
if (rubberBandEntity) {
|
||||
viewer.entities.remove(rubberBandEntity)
|
||||
rubberBandEntity = null
|
||||
function refreshVertices() {
|
||||
setSource(map, SRC_VERTICES, {
|
||||
type: 'FeatureCollection',
|
||||
features: verts.map((v, i) => ({
|
||||
type: 'Feature',
|
||||
id: i,
|
||||
geometry: { type: 'Point', coordinates: v },
|
||||
properties: {},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MapMouseEvent) => {
|
||||
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,
|
||||
},
|
||||
setSource(map, SRC_RUBBER, {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: [verts[verts.length - 1], [e.lngLat.lng, e.lngLat.lat]] },
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const onClick = (e: MapMouseEvent) => {
|
||||
verts.push([e.lngLat.lng, e.lngLat.lat])
|
||||
refreshOutline()
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
||||
refreshVertices()
|
||||
}
|
||||
|
||||
handler.setInputAction(() => {
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (verts.length < 3) return
|
||||
const polygon = vertsToGeoJson(verts)
|
||||
const polygon: GeoJSON.Polygon = {
|
||||
type: 'Polygon',
|
||||
coordinates: [[...verts, verts[0]]],
|
||||
}
|
||||
cleanup()
|
||||
setDraftPolygon(polygon)
|
||||
setDrawingMode(false)
|
||||
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||
}
|
||||
|
||||
canvas.addEventListener('contextmenu', onContextMenu)
|
||||
map.on('click', onClick)
|
||||
map.on('mousemove', onMouseMove)
|
||||
|
||||
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
|
||||
}
|
||||
canvas.style.cursor = ''
|
||||
map.off('click', onClick)
|
||||
map.off('mousemove', onMouseMove)
|
||||
canvas.removeEventListener('contextmenu', onContextMenu)
|
||||
setSource(map, SRC_FILL, emptyFC())
|
||||
setSource(map, SRC_OUTLINE, emptyFC())
|
||||
setSource(map, SRC_RUBBER, emptyFC())
|
||||
setSource(map, SRC_VERTICES, emptyFC())
|
||||
}
|
||||
|
||||
return () => {
|
||||
handler.destroy()
|
||||
canvas.removeEventListener('contextmenu', suppressContextMenu)
|
||||
cleanup()
|
||||
}
|
||||
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
|
||||
return cleanup
|
||||
}, [drawingMode, map, setDrawingMode, setDraftPolygon])
|
||||
|
||||
// ── Edit phase ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@ -161,120 +160,84 @@ export function usePolygonDraw() {
|
||||
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.
|
||||
// Only initialise from the store on first entry into edit mode.
|
||||
if (editVertsRef.current.length === 0) {
|
||||
editVertsRef.current = geoJsonToVerts(draftPolygon)
|
||||
const ring = draftPolygon.coordinates[0]
|
||||
editVertsRef.current = ring.slice(0, -1) as [number, number][]
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
function refreshAll() {
|
||||
setSource(map, SRC_FILL, {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Polygon', coordinates: [[...verts, verts[0]]] },
|
||||
properties: {},
|
||||
})
|
||||
entities.push(e)
|
||||
return e
|
||||
setSource(map, SRC_OUTLINE, {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: [...verts, verts[0]] },
|
||||
properties: {},
|
||||
})
|
||||
setSource(map, SRC_VERTICES, {
|
||||
type: 'FeatureCollection',
|
||||
features: verts.map((v, i) => ({
|
||||
type: 'Feature',
|
||||
id: i,
|
||||
geometry: { type: 'Point', coordinates: v },
|
||||
properties: { idx: i },
|
||||
})),
|
||||
})
|
||||
|
||||
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
|
||||
refreshAll()
|
||||
|
||||
const canvas = map.getCanvas()
|
||||
|
||||
const onVertexMouseDown = (e: MapLayerMouseEvent) => {
|
||||
const idx = e.features?.[0]?.properties?.idx as number | undefined
|
||||
if (idx == null) return
|
||||
e.preventDefault()
|
||||
draggingIndex = idx
|
||||
canvas.style.cursor = 'grabbing'
|
||||
viewer.scene.screenSpaceCameraController.enableRotate = false
|
||||
viewer.scene.screenSpaceCameraController.enableTranslate = false
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
|
||||
map.dragPan.disable()
|
||||
}
|
||||
|
||||
handler.setInputAction(() => {
|
||||
const onMouseMove = (e: MapMouseEvent) => {
|
||||
if (draggingIndex !== -1) {
|
||||
verts[draggingIndex] = [e.lngLat.lng, e.lngLat.lat]
|
||||
refreshAll()
|
||||
return
|
||||
}
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_VERTICES] })
|
||||
canvas.style.cursor = features.length ? 'grab' : ''
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
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
|
||||
canvas.style.cursor = ''
|
||||
map.dragPan.enable()
|
||||
setDraftPolygon({
|
||||
type: 'Polygon',
|
||||
coordinates: [[...verts, verts[0]]],
|
||||
})
|
||||
}
|
||||
|
||||
map.on('mousedown', LYR_VERTICES, onVertexMouseDown)
|
||||
map.on('mousemove', onMouseMove)
|
||||
map.on('mouseup', onMouseUp)
|
||||
|
||||
return () => {
|
||||
handler.destroy()
|
||||
canvas.removeEventListener('contextmenu', suppressContextMenu)
|
||||
cleanup()
|
||||
map.off('mousedown', LYR_VERTICES, onVertexMouseDown)
|
||||
map.off('mousemove', onMouseMove)
|
||||
map.off('mouseup', onMouseUp)
|
||||
map.dragPan.enable()
|
||||
canvas.style.cursor = ''
|
||||
setSource(map, SRC_FILL, emptyFC())
|
||||
setSource(map, SRC_OUTLINE, emptyFC())
|
||||
setSource(map, SRC_VERTICES, emptyFC())
|
||||
}
|
||||
}, [drawingMode, draftPolygon, viewer, setDraftPolygon])
|
||||
}, [drawingMode, draftPolygon, map, setDraftPolygon])
|
||||
}
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 60px;
|
||||
z-index: 600;
|
||||
width: 280px;
|
||||
width: 340px;
|
||||
background: rgba(8, 8, 12, 0.84);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
@ -57,7 +53,7 @@
|
||||
.stripRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@ -66,37 +62,17 @@
|
||||
color: rgba(245, 158, 11, 0.9);
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stripRange {
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-size: 11px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.accelInput {
|
||||
width: 50px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
text-align: right;
|
||||
}
|
||||
.accelInput:focus {
|
||||
outline: none;
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.stripUnit {
|
||||
.rangeSep {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stripDelete {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AccelerationStrip } from '../types/api'
|
||||
import { BlenderInput } from '../ui/BlenderInput'
|
||||
import styles from './AccelerationStripsPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
strips: AccelerationStrip[]
|
||||
onRemoveStrip: (id: string) => void
|
||||
onUpdateStrip: (id: string, accel_ms2: number) => void
|
||||
onUpdateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||
}
|
||||
|
||||
function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
|
||||
|
||||
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) {
|
||||
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
// Auto-open when a strip is added
|
||||
@ -31,19 +31,45 @@ export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }
|
||||
: strips.map((s, i) => (
|
||||
<div key={s.id} className={styles.stripRow}>
|
||||
<span className={styles.stripIndex}>Strip {i + 1}</span>
|
||||
<span className={styles.stripRange}>
|
||||
{pct(s.startFrac)}–{pct(s.endFrac)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={0.5}
|
||||
|
||||
<BlenderInput
|
||||
value={s.startFrac * 100}
|
||||
onChange={v => {
|
||||
const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001))
|
||||
onUpdateStripFrac(s.id, sf, s.endFrac)
|
||||
}}
|
||||
min={0}
|
||||
max={s.endFrac * 100 - 0.1}
|
||||
step={0.2}
|
||||
decimals={1}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<span className={styles.rangeSep}>–</span>
|
||||
|
||||
<BlenderInput
|
||||
value={s.endFrac * 100}
|
||||
onChange={v => {
|
||||
const ef = Math.min(100, Math.max(v / 100, s.startFrac + 0.001))
|
||||
onUpdateStripFrac(s.id, s.startFrac, ef)
|
||||
}}
|
||||
min={s.startFrac * 100 + 0.1}
|
||||
max={100}
|
||||
step={0.2}
|
||||
decimals={1}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<BlenderInput
|
||||
value={s.accel_ms2}
|
||||
onChange={v => onUpdateStrip(s.id, v)}
|
||||
min={-50}
|
||||
max={50}
|
||||
value={s.accel_ms2}
|
||||
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
|
||||
className={styles.accelInput}
|
||||
step={0.1}
|
||||
decimals={1}
|
||||
suffix="m/s²"
|
||||
/>
|
||||
<span className={styles.stripUnit}>m/s²</span>
|
||||
|
||||
<button
|
||||
className={styles.stripDelete}
|
||||
onClick={() => onRemoveStrip(s.id)}
|
||||
|
||||
@ -516,6 +516,38 @@
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Right panel column ──────────────────────────────────────────────────────── */
|
||||
|
||||
.rightColumn {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 116px;
|
||||
z-index: 150;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 132px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Path hover tooltip ──────────────────────────────────────────────────────── */
|
||||
|
||||
.pathTooltip {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
padding: 3px 8px;
|
||||
background: rgba(8, 8, 12, 0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fbbf24;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.loading {
|
||||
|
||||
@ -1,20 +1,24 @@
|
||||
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 type { Map, GeoJSONSource } from 'maplibre-gl'
|
||||
import { MapLibreViewer } from '../maplibre/MapLibreViewer'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
|
||||
import type { Layer3DHandle } from '../maplibre/custom3DLayer'
|
||||
import { fetchChallengeDetail } from '../api/challenges'
|
||||
import { simulateCoaster, saveCoaster, listCoasters } from '../api/coaster'
|
||||
import { useCoasterPath } from './useCoasterPath'
|
||||
import { useAccelerationStrips } from './useAccelerationStrips'
|
||||
import { useAccelerationStrips, computeArcLengths, snapToPath } from './useAccelerationStrips'
|
||||
import { useTerrainCapture } from './useTerrainCapture'
|
||||
import { RideRenderer } from './RideRenderer'
|
||||
import { SimulationPlots } from './SimulationPlots'
|
||||
import { AccelerationStripsPanel } from './AccelerationStripsPanel'
|
||||
import { CoasterListPanel } from './CoasterListPanel'
|
||||
import { effectivePosition } from './bezierUtils'
|
||||
import { effectiveLngLatAlt } from './bezierUtils'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
|
||||
import type { AnchorPoint } from './bezierUtils'
|
||||
import styles from './CoasterEditorPage.module.css'
|
||||
|
||||
// ── Route pages ───────────────────────────────────────────────────────────────
|
||||
@ -29,9 +33,9 @@ export function CoasterEditorPage() {
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<CesiumViewer>
|
||||
<MapLibreViewer>
|
||||
<CoasterEditorScene challengeId={id} challenge={challenge} />
|
||||
</CesiumViewer>
|
||||
</MapLibreViewer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,32 +56,30 @@ export function CoasterViewerPage() {
|
||||
}, [challengeId, coasterId])
|
||||
|
||||
return (
|
||||
<CesiumViewer>
|
||||
<MapLibreViewer>
|
||||
<CoasterEditorScene
|
||||
challengeId={challengeId}
|
||||
challenge={challenge}
|
||||
readonly
|
||||
preloadCoaster={preloadCoaster ?? undefined}
|
||||
/>
|
||||
</CesiumViewer>
|
||||
</MapLibreViewer>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── Source / layer IDs for editor-specific overlays ───────────────────────────
|
||||
|
||||
/** 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 SRC_REGION = 'editor-region'
|
||||
const LYR_REGION_F = 'editor-region-fill'
|
||||
const LYR_REGION_L = 'editor-region-line'
|
||||
const LYR_SIM_RAILS = 'sim-rails-3d'
|
||||
|
||||
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
|
||||
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
|
||||
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
|
||||
}
|
||||
|
||||
const RAIL_SHAPE = buildCircleShape(0.075, 8) // 7.5 cm radius = 15 cm diameter
|
||||
|
||||
// ── Inner scene (needs viewer context) ────────────────────────────────────────
|
||||
// ── Inner scene (needs map context) ──────────────────────────────────────────
|
||||
|
||||
interface SceneProps {
|
||||
challengeId: string | undefined
|
||||
@ -87,9 +89,11 @@ interface SceneProps {
|
||||
}
|
||||
|
||||
function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadCoaster }: SceneProps) {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const simRailLayerRef = useRef<Layer3DHandle | null>(null)
|
||||
|
||||
const authUser = useAuthStore(s => s.user)
|
||||
const currentUsername = authUser?.profile?.preferred_username as string | undefined
|
||||
|
||||
@ -104,144 +108,107 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
const [coasterListKey, setCoasterListKey] = useState(0)
|
||||
const [isRideMode, setIsRideMode] = useState(false)
|
||||
const [rideCursor, setRideCursor] = useState<number | null>(null)
|
||||
const [pathHover, setPathHover] = useState<{ x: number; y: number; pct: number } | null>(null)
|
||||
|
||||
const path = useCoasterPath(viewer, showPath, showAnchors)
|
||||
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
|
||||
const terrain = useTerrainCapture(viewer, simResult)
|
||||
const path = useCoasterPath(map, showPath, showAnchors)
|
||||
const accel = useAccelerationStrips(map, path.pathPts, path.mode === 'strip', showStrips)
|
||||
const terrain = useTerrainCapture(map, simResult)
|
||||
|
||||
// Auto-load a preloaded coaster (viewer mode)
|
||||
// Auto-load preloaded coaster (viewer mode)
|
||||
useEffect(() => {
|
||||
if (preloadCoaster) handleLoad(preloadCoaster)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [preloadCoaster])
|
||||
|
||||
// Exit ride mode whenever the sim result changes; clear cursor on exit
|
||||
// Exit ride mode when sim result changes
|
||||
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
|
||||
|
||||
// Suspend Cesium's render loop while Three.js ride view is active so both
|
||||
// renderers don't compete for the GPU simultaneously.
|
||||
useEffect(() => {
|
||||
viewer.useDefaultRenderLoop = !isRideMode
|
||||
}, [isRideMode, viewer])
|
||||
|
||||
// Refs for simulation result entities (cleared on each new run / unmount)
|
||||
const simEntitiesRef = useRef<Cesium.Entity[]>([])
|
||||
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
|
||||
|
||||
// ── Fly to challenge region ───────────────────────────────────────────────
|
||||
// ── Set up region + sim-rail sources ──────────────────────────────────────
|
||||
|
||||
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)
|
||||
map.addSource(SRC_REGION, { type: 'geojson', data: emptyFC() })
|
||||
map.addLayer({ id: LYR_REGION_F, type: 'fill', source: SRC_REGION,
|
||||
paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.04 } })
|
||||
map.addLayer({ id: LYR_REGION_L, type: 'line', source: SRC_REGION,
|
||||
paint: { 'line-color': '#06b6d4', 'line-opacity': 0.45, 'line-width': 2 } })
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
const handle = createCustom3DLayer(LYR_SIM_RAILS, map)
|
||||
simRailLayerRef.current = handle
|
||||
|
||||
return () => {
|
||||
if (regionEntityRef.current && !viewer.isDestroyed()) {
|
||||
viewer.entities.remove(regionEntityRef.current)
|
||||
regionEntityRef.current = null
|
||||
handle.destroy()
|
||||
simRailLayerRef.current = null
|
||||
safeRemoveLayers(map, [LYR_REGION_F, LYR_REGION_L], [SRC_REGION])
|
||||
}
|
||||
}
|
||||
}, [challenge, viewer])
|
||||
}, [map])
|
||||
|
||||
// ── Render simulation result rails ────────────────────────────────────────
|
||||
// ── Fly to challenge region ────────────────────────────────────────────────
|
||||
|
||||
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 (!challenge) return
|
||||
|
||||
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),
|
||||
const coords = challenge.region.coordinates[0] as [number, number][]
|
||||
const lons = coords.map(c => c[0])
|
||||
const lats = coords.map(c => c[1])
|
||||
map.fitBounds(
|
||||
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||||
{ padding: 60, pitch: 55, bearing: 0, duration: 1200 },
|
||||
)
|
||||
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))
|
||||
|
||||
setData(map, SRC_REGION, { type: 'Feature', geometry: challenge.region, properties: {} })
|
||||
}, [challenge, map])
|
||||
|
||||
// ── Simulation result rails (custom 3D layer at absolute altitude) ────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!simResult) {
|
||||
simRailLayerRef.current?.update([])
|
||||
return
|
||||
}
|
||||
|
||||
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 = []
|
||||
simRailLayerRef.current?.update([
|
||||
{
|
||||
pts: simResult.rail_1 as [number, number, number][],
|
||||
color: 0xef4444,
|
||||
radiusMeters: 0.12,
|
||||
},
|
||||
{
|
||||
pts: simResult.rail_2 as [number, number, number][],
|
||||
color: 0xef4444,
|
||||
radiusMeters: 0.12,
|
||||
},
|
||||
])
|
||||
}, [simResult])
|
||||
|
||||
// ── Path hover tooltip ────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (path.pathPts.length < 2) { setPathHover(null); return }
|
||||
|
||||
const arcs = computeArcLengths(path.pathPts)
|
||||
|
||||
function onMove(e: { point: { x: number; y: number }; lngLat: { lng: number; lat: number } }) {
|
||||
const { x, y } = e.point
|
||||
const hits = map.queryRenderedFeatures(
|
||||
[{ x: x - 4, y: y - 4 }, { x: x + 4, y: y + 4 }],
|
||||
{ layers: ['coaster-path-lyr'] },
|
||||
)
|
||||
if (hits.length === 0) { setPathHover(null); return }
|
||||
const { frac } = snapToPath([e.lngLat.lng, e.lngLat.lat], path.pathPts, arcs)
|
||||
setPathHover({ x, y, pct: frac * 100 })
|
||||
}
|
||||
}, [simResult, viewer])
|
||||
|
||||
map.on('mousemove', onMove)
|
||||
return () => { map.off('mousemove', onMove) }
|
||||
}, [map, path.pathPts])
|
||||
|
||||
// ── Load / Save handlers ─────────────────────────────────────────────────
|
||||
|
||||
function handleLoad(coaster: SavedCoaster) {
|
||||
const anchorPoints = coaster.anchors.map(a => ({
|
||||
const anchorPoints: AnchorPoint[] = coaster.anchors.map(a => ({
|
||||
id: a.id,
|
||||
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt),
|
||||
lngLat: [a.lon, a.lat] as [number, number],
|
||||
terrainHeight: a.terrainAlt,
|
||||
heightOffset: a.heightOffset,
|
||||
}))
|
||||
path.loadAnchors(anchorPoints)
|
||||
@ -252,16 +219,13 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
|
||||
async function handleSave() {
|
||||
if (!challengeId || path.anchors.length < 2) return
|
||||
const storedAnchors = path.anchors.map(a => {
|
||||
const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a))
|
||||
return {
|
||||
const storedAnchors = path.anchors.map(a => ({
|
||||
id: a.id,
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
terrainAlt: carto.height,
|
||||
lon: a.lngLat[0],
|
||||
lat: a.lngLat[1],
|
||||
terrainAlt: a.terrainHeight,
|
||||
heightOffset: a.heightOffset,
|
||||
}
|
||||
})
|
||||
}))
|
||||
await saveCoaster(challengeId, {
|
||||
name: coasterName.trim(),
|
||||
anchors: storedAnchors,
|
||||
@ -278,19 +242,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
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 geoPath = path.anchors.map(anchor => effectiveLngLatAlt(anchor))
|
||||
|
||||
const result = await simulateCoaster({
|
||||
path: geoPath,
|
||||
@ -318,13 +270,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */}
|
||||
{/* ── Three.js ride renderer (fullscreen, mounts over map) ──────────── */}
|
||||
{isRideMode && simResult && terrain.captureData && (
|
||||
<RideRenderer
|
||||
simResult={simResult}
|
||||
captureData={terrain.captureData}
|
||||
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
|
||||
onRideProgress={setRideCursor}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -347,6 +298,15 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
{challenge ? challenge.title : 'Loading…'}
|
||||
</h1>
|
||||
<span className={styles.badge}>{readonly ? 'Viewer' : 'Coaster Editor'}</span>
|
||||
{challengeId && (
|
||||
<CoasterListPanel
|
||||
challengeId={challengeId}
|
||||
currentUsername={currentUsername}
|
||||
onLoad={handleLoad}
|
||||
refreshKey={coasterListKey}
|
||||
menuMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
|
||||
@ -431,9 +391,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
)}
|
||||
|
||||
{/* ── Simulation error ─────────────────────────────────────────────── */}
|
||||
{simError && (
|
||||
<div className={styles.simError}>{simError}</div>
|
||||
)}
|
||||
{simError && <div className={styles.simError}>{simError}</div>}
|
||||
|
||||
{/* ── Diagnostics strip ────────────────────────────────────────────── */}
|
||||
{diag && !simError && (
|
||||
@ -496,7 +454,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
)}
|
||||
|
||||
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
|
||||
{simResult?.profile && (
|
||||
{!isRideMode && simResult?.profile && (
|
||||
<SimulationPlots
|
||||
profile={simResult.profile}
|
||||
strips={accel.strips}
|
||||
@ -504,23 +462,26 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Acceleration strips (right panel) ───────────────────────────── */}
|
||||
{accel.strips.length > 0 && (
|
||||
{/* ── Right panel column (acceleration strips) ─────────────────────── */}
|
||||
{!isRideMode && accel.strips.length > 0 && (
|
||||
<div className={styles.rightColumn}>
|
||||
<AccelerationStripsPanel
|
||||
strips={accel.strips}
|
||||
onRemoveStrip={accel.removeStrip}
|
||||
onUpdateStrip={accel.updateStrip}
|
||||
onUpdateStripFrac={accel.updateStripFrac}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
|
||||
{challengeId && (
|
||||
<CoasterListPanel
|
||||
challengeId={challengeId}
|
||||
currentUsername={currentUsername}
|
||||
onLoad={handleLoad}
|
||||
refreshKey={coasterListKey}
|
||||
/>
|
||||
{/* ── Path hover tooltip ──────────────────────────────────────────── */}
|
||||
{!isRideMode && pathHover && (
|
||||
<div
|
||||
className={styles.pathTooltip}
|
||||
style={{ left: pathHover.x + 14, top: pathHover.y - 10 }}
|
||||
>
|
||||
{pathHover.pct.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loading overlay ──────────────────────────────────────────────── */}
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 112px; /* 52px header + ~44px topBar + 16px gap */
|
||||
z-index: 200;
|
||||
width: 240px;
|
||||
background: rgba(8, 8, 12, 0.84);
|
||||
backdrop-filter: blur(10px);
|
||||
@ -122,3 +118,45 @@
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
/* ── Top-bar menu mode ───────────────────────────────────────────────────────── */
|
||||
|
||||
.menuWrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuToggle {
|
||||
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: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.menuToggle:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menuDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 400;
|
||||
width: 260px;
|
||||
background: rgba(8, 8, 12, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 6px 12px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -8,9 +8,11 @@ interface Props {
|
||||
currentUsername: string | undefined
|
||||
onLoad: (coaster: SavedCoaster) => void
|
||||
refreshKey: number
|
||||
/** Render as a top-bar dropdown instead of a sidebar panel */
|
||||
menuMode?: boolean
|
||||
}
|
||||
|
||||
export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) {
|
||||
export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey, menuMode }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coasters, setCoasters] = useState<SavedCoaster[]>([])
|
||||
|
||||
@ -23,6 +25,46 @@ export function CoasterListPanel({ challengeId, currentUsername, onLoad, refresh
|
||||
setCoasters(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
if (menuMode) {
|
||||
return (
|
||||
<div className={styles.menuWrapper}>
|
||||
<button className={styles.menuToggle} onClick={() => setOpen(o => !o)}>
|
||||
Coasters ({coasters.length})
|
||||
<span className={`${styles.toggleArrow}${open ? ` ${styles.open}` : ''}`}>▶</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.menuDropdown}>
|
||||
{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}>
|
||||
<div className={styles.rowInfo}>
|
||||
<span className={styles.rowName}>{c.name || 'Unnamed coaster'}</span>
|
||||
<span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}>
|
||||
@{c.creator_username}
|
||||
</span>
|
||||
</div>
|
||||
<button className={styles.loadBtn} onClick={() => { onLoad(c); setOpen(false) }}>
|
||||
Load
|
||||
</button>
|
||||
{isOwn && (
|
||||
<button className={styles.deleteBtn} onClick={() => handleDelete(c.id)}>
|
||||
Del
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<button className={styles.toggle} onClick={() => setOpen(o => !o)}>
|
||||
|
||||
@ -154,8 +154,8 @@ function computeCameraTarget(
|
||||
|
||||
function buildTerrainMesh(
|
||||
patch: TerrainCaptureData,
|
||||
patchIdx: number,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
visible: boolean,
|
||||
): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
|
||||
const GRID = patch.gridSize
|
||||
const verts = patch.terrainVertices
|
||||
@ -168,7 +168,7 @@ function buildTerrainMesh(
|
||||
const idx = j * GRID + i
|
||||
const v = verts[idx]
|
||||
posArr[idx * 3] = v.x
|
||||
posArr[idx * 3 + 1] = v.y - 0.5
|
||||
posArr[idx * 3 + 1] = v.y
|
||||
posArr[idx * 3 + 2] = v.z
|
||||
uvArr[idx * 2] = i / (GRID - 1)
|
||||
uvArr[idx * 2 + 1] = j / (GRID - 1)
|
||||
@ -197,9 +197,16 @@ function buildTerrainMesh(
|
||||
tex.magFilter = THREE.LinearFilter
|
||||
tex.needsUpdate = true
|
||||
|
||||
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
|
||||
// Polygon offset: higher-indexed patches render on top of lower-indexed ones in
|
||||
// overlap zones so depth-fighting never causes flickering under the camera.
|
||||
const mat = new THREE.MeshLambertMaterial({
|
||||
map: tex,
|
||||
side: THREE.FrontSide,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -(patchIdx + 1),
|
||||
polygonOffsetUnits: -(patchIdx + 1),
|
||||
})
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.visible = visible
|
||||
return { mesh, geo, mat, tex }
|
||||
}
|
||||
|
||||
@ -218,23 +225,17 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
|
||||
const mats: THREE.Material[] = []
|
||||
const texes: THREE.Texture[] = []
|
||||
|
||||
// ── Terrain patches — one mesh per patch, only the active one visible ────
|
||||
const terrainMeshes = captureData.map((patch, i) => {
|
||||
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0)
|
||||
// ── Terrain patches — all meshes permanently visible ─────────────────────
|
||||
// Polygon offset (set per-mesh in buildTerrainMesh) ensures higher-indexed
|
||||
// patches win the depth test in overlap zones without flickering.
|
||||
captureData.forEach((patch, i) => {
|
||||
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, i, renderer)
|
||||
geos.push(geo)
|
||||
mats.push(mat)
|
||||
texes.push(tex)
|
||||
scene.add(mesh)
|
||||
// Pre-upload texture to GPU so visibility swaps have zero hitch
|
||||
renderer.initTexture(tex)
|
||||
return mesh
|
||||
})
|
||||
|
||||
function setActivePatch(idx: number) {
|
||||
const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1))
|
||||
terrainMeshes.forEach((m, i) => { m.visible = i === clamped })
|
||||
}
|
||||
|
||||
// ── Coaster rails ──────────────────────────────────────────────────────────
|
||||
function addRail(pts: THREE.Vector3[]) {
|
||||
const step = Math.max(1, Math.floor(pts.length / 200))
|
||||
@ -251,7 +252,6 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
|
||||
|
||||
return {
|
||||
scene,
|
||||
setActivePatch,
|
||||
disposeAll: () => {
|
||||
geos.forEach(g => g.dispose())
|
||||
mats.forEach(m => m.dispose())
|
||||
@ -288,10 +288,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
const smoothQuatRef = useRef(new THREE.Quaternion())
|
||||
const needsInitRef = useRef(true)
|
||||
|
||||
// Active terrain patch switching
|
||||
const setActivePatchRef = useRef<((idx: number) => void) | null>(null)
|
||||
const activePatchIdxRef = useRef(0)
|
||||
|
||||
// First-person drag rotation state
|
||||
const userYawRef = useRef(0) // radians — current yaw offset from track forward
|
||||
const userPitchRef = useRef(0) // radians — current pitch offset
|
||||
@ -322,10 +318,8 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
rideDataRef.current = rideData
|
||||
setTotalDuration(rideData.totalDuration)
|
||||
|
||||
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
|
||||
const { scene, disposeAll } = buildScene(captureData, rideData, renderer)
|
||||
sceneRef.current = scene
|
||||
setActivePatchRef.current = setActivePatch
|
||||
activePatchIdxRef.current = 0
|
||||
|
||||
function onResize() {
|
||||
const w = window.innerWidth, h = window.innerHeight
|
||||
@ -345,6 +339,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
cameraRef.current.updateMatrixWorld()
|
||||
}
|
||||
|
||||
// Initial render — all patches are permanently visible so no warm-up needed.
|
||||
renderer.render(scene, cameraRef.current)
|
||||
|
||||
return () => {
|
||||
@ -419,24 +414,6 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active terrain patch ──────────────────────────────────────────────
|
||||
if (captureData.length > 1 && setActivePatchRef.current) {
|
||||
const d = rideDataRef.current
|
||||
if (d) {
|
||||
const pi = bisect(d.timeArray, rideT)
|
||||
const pi1 = Math.min(pi + 1, d.count - 1)
|
||||
const pa = d.timeArray[pi1] > d.timeArray[pi]
|
||||
? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi])
|
||||
: 0
|
||||
const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa
|
||||
const target = Math.round(frac * (captureData.length - 1))
|
||||
if (target !== activePatchIdxRef.current) {
|
||||
activePatchIdxRef.current = target
|
||||
setActivePatchRef.current(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Camera ────────────────────────────────────────────────────────────
|
||||
const data = rideDataRef.current
|
||||
if (data) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.plotsPanel {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
top: 60px;
|
||||
top: 116px;
|
||||
z-index: 600; /* above Three.js ride canvas (z-index 500) */
|
||||
width: 340px;
|
||||
background: rgba(8, 8, 12, 0.84);
|
||||
|
||||
@ -94,8 +94,10 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero, rideCursor
|
||||
/>
|
||||
<Tooltip
|
||||
{...tooltipStyle}
|
||||
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]}
|
||||
labelFormatter={(l: number) => `s = ${pct(l)}`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(v: any) => [`${Number(v).toFixed(2)} ${unit}`, dataKey]}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
labelFormatter={(l: any) => `s = ${pct(Number(l))}`}
|
||||
/>
|
||||
{strips && <StripAreas strips={strips} />}
|
||||
{showZero && (
|
||||
|
||||
@ -1,54 +1,46 @@
|
||||
import * as Cesium from 'cesium'
|
||||
import * as THREE from 'three'
|
||||
import { lngLatAltToECEF, ecefToLngLatAlt } from '../maplibre/geoUtils'
|
||||
|
||||
export interface AnchorPoint {
|
||||
id: string
|
||||
position: Cesium.Cartesian3 // base position on terrain
|
||||
heightOffset: number // meters above terrain surface
|
||||
lngLat: [number, number] // [longitude, latitude]
|
||||
terrainHeight: number // metres above WGS-84 ellipsoid (from terrain query)
|
||||
heightOffset: number // metres above terrain surface (user-adjustable)
|
||||
}
|
||||
|
||||
// ── 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 in ECEF metres. */
|
||||
export function effectivePosition(anchor: AnchorPoint): THREE.Vector3 {
|
||||
const [lon, lat] = anchor.lngLat
|
||||
const alt = anchor.terrainHeight + anchor.heightOffset
|
||||
return lngLatAltToECEF(lon, lat, alt)
|
||||
}
|
||||
|
||||
/** 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))
|
||||
/** Return effective position as [lon, lat, alt]. */
|
||||
export function effectiveLngLatAlt(anchor: AnchorPoint): [number, number, number] {
|
||||
return [anchor.lngLat[0], anchor.lngLat[1], anchor.terrainHeight + anchor.heightOffset]
|
||||
}
|
||||
|
||||
// ── Catmull-Rom → cubic Bézier ───────────────────────────────────────────────
|
||||
|
||||
interface Segment {
|
||||
p0: Cesium.Cartesian3
|
||||
c1: Cesium.Cartesian3
|
||||
c2: Cesium.Cartesian3
|
||||
p1: Cesium.Cartesian3
|
||||
p0: THREE.Vector3
|
||||
c1: THREE.Vector3
|
||||
c2: THREE.Vector3
|
||||
p1: THREE.Vector3
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert anchor points to cubic Bézier segments via Catmull-Rom.
|
||||
* The curve passes through every anchor point with C1 continuity.
|
||||
* All coordinates are in ECEF metres.
|
||||
*/
|
||||
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
|
||||
// phantom end-points so curve starts/ends at first/last anchor
|
||||
const ext = [pts[0], ...pts, pts[n - 1]]
|
||||
|
||||
const segments: Segment[] = []
|
||||
@ -57,33 +49,36 @@ export function computeSegments(anchors: AnchorPoint[]): Segment[] {
|
||||
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))
|
||||
// Catmull-Rom tangent handles → cubic Bézier control points
|
||||
const c1 = p0.clone().addScaledVector(p1.clone().sub(pm1), 1 / 6)
|
||||
const c2 = p1.clone().addScaledVector(p0.clone().sub(p2), 1 / 6)
|
||||
segments.push({ p0, c1, c2, p1 })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 {
|
||||
function evalBezier(seg: Segment, t: number): THREE.Vector3 {
|
||||
const { p0, c1, c2, p1 } = seg
|
||||
const mt = 1 - t
|
||||
return new Cesium.Cartesian3(
|
||||
return new THREE.Vector3(
|
||||
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). */
|
||||
/**
|
||||
* Sample the full spline as a polyline (all segments concatenated).
|
||||
* Returns ECEF THREE.Vector3 positions.
|
||||
*/
|
||||
export function samplePath(
|
||||
anchors: AnchorPoint[],
|
||||
samplesPerSegment = 40,
|
||||
): Cesium.Cartesian3[] {
|
||||
): THREE.Vector3[] {
|
||||
const segs = computeSegments(anchors)
|
||||
if (segs.length === 0) return anchors.map(effectivePosition)
|
||||
|
||||
const pts: Cesium.Cartesian3[] = []
|
||||
const pts: THREE.Vector3[] = []
|
||||
segs.forEach((seg, i) => {
|
||||
const from = i === 0 ? 0 : 1
|
||||
for (let s = from; s <= samplesPerSegment; s++) {
|
||||
@ -93,23 +88,33 @@ export function samplePath(
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the full spline as geographic [lon, lat, alt] tuples.
|
||||
*/
|
||||
export function samplePathGeo(
|
||||
anchors: AnchorPoint[],
|
||||
samplesPerSegment = 40,
|
||||
): [number, number, number][] {
|
||||
return samplePath(anchors, samplesPerSegment).map(v => ecefToLngLatAlt(v))
|
||||
}
|
||||
|
||||
// ── Rail geometry ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RailPositions {
|
||||
left: Cesium.Cartesian3[]
|
||||
right: Cesium.Cartesian3[]
|
||||
left: THREE.Vector3[]
|
||||
right: THREE.Vector3[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a centre-line path compute parallel left/right rail positions.
|
||||
* Given a centre-line path (ECEF) 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[],
|
||||
path: THREE.Vector3[],
|
||||
gauge = 2.5,
|
||||
): RailPositions {
|
||||
const left: Cesium.Cartesian3[] = []
|
||||
const right: Cesium.Cartesian3[] = []
|
||||
const left: THREE.Vector3[] = []
|
||||
const right: THREE.Vector3[] = []
|
||||
const half = gauge / 2
|
||||
const n = path.length
|
||||
|
||||
@ -117,19 +122,19 @@ export function computeRails(
|
||||
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)
|
||||
let tangent: THREE.Vector3
|
||||
if (i === 0) tangent = path[1].clone().sub(path[0])
|
||||
else if (i === n - 1) tangent = path[n - 1].clone().sub(path[n - 2])
|
||||
else tangent = path[i + 1].clone().sub(path[i - 1])
|
||||
tangent.normalize()
|
||||
|
||||
// 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))
|
||||
// Local up = radially outward from Earth centre (normalise ECEF position)
|
||||
const up = pt.clone().normalize()
|
||||
// Track-right = tangent × up (right-hand rule)
|
||||
const rightDir = new THREE.Vector3().crossVectors(tangent, up).normalize()
|
||||
|
||||
left.push(v3add(pt, v3scale(rightDir, -half)))
|
||||
right.push(v3add(pt, v3scale(rightDir, half)))
|
||||
left.push(pt.clone().addScaledVector(rightDir, -half))
|
||||
right.push(pt.clone().addScaledVector(rightDir, half))
|
||||
}
|
||||
|
||||
return { left, right }
|
||||
|
||||
@ -1,26 +1,40 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import type { AccelerationStrip } from '../types/api'
|
||||
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
|
||||
import type { Layer3DHandle } from '../maplibre/custom3DLayer'
|
||||
|
||||
// ── Arc-length utilities ───────────────────────────────────────────────────────
|
||||
// ── Arc-length utilities (geographic [lon, lat, alt] inputs) ──────────────────
|
||||
|
||||
function computeArcLengths(pts: Cesium.Cartesian3[]): number[] {
|
||||
function geoDist(a: [number, number, number], b: [number, number, number]): number {
|
||||
const midLat = (a[1] + b[1]) / 2
|
||||
const dlat = (b[1] - a[1]) * 111320
|
||||
const dlon = (b[0] - a[0]) * 111320 * Math.cos(midLat * Math.PI / 180)
|
||||
return Math.sqrt(dlat * dlat + dlon * dlon)
|
||||
}
|
||||
|
||||
export function computeArcLengths(pts: [number, number, number][]): 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]))
|
||||
s.push(s[i - 1] + geoDist(pts[i - 1], pts[i]))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function snapToPath(
|
||||
pos: Cesium.Cartesian3,
|
||||
pts: Cesium.Cartesian3[],
|
||||
export function snapToPath(
|
||||
lngLat: [number, number],
|
||||
pts: [number, number, number][],
|
||||
arcs: number[],
|
||||
): { frac: number; pt: Cesium.Cartesian3 } {
|
||||
): { frac: number; pt: [number, number, number] } {
|
||||
let minDist = Infinity
|
||||
let best = 0
|
||||
const refLat = lngLat[1]
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const d = Cesium.Cartesian3.distance(pos, pts[i])
|
||||
const dlat = (pts[i][1] - lngLat[1]) * 111320
|
||||
const dlon = (pts[i][0] - lngLat[0]) * 111320 * Math.cos(refLat * Math.PI / 180)
|
||||
const d = Math.sqrt(dlat * dlat + dlon * dlon)
|
||||
if (d < minDist) { minDist = d; best = i }
|
||||
}
|
||||
const total = arcs[arcs.length - 1]
|
||||
@ -37,33 +51,46 @@ function fracToIndex(frac: number, arcs: number[]): number {
|
||||
return best
|
||||
}
|
||||
|
||||
// ── Source / layer IDs ────────────────────────────────────────────────────────
|
||||
|
||||
const SRC_STRIPS = 'accel-strips-src'
|
||||
const LYR_STRIPS = 'accel-strips-lyr' // invisible draped line for right-click hit test
|
||||
const SRC_PENDING = 'accel-pending-src'
|
||||
const LYR_PENDING = 'accel-pending-lyr' // draped circle for pending start point
|
||||
const LYR_STRIPS_3D = 'accel-strips-3d' // custom 3D layer for visual rendering
|
||||
|
||||
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
|
||||
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
|
||||
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccelerationStripsHandle {
|
||||
strips: AccelerationStrip[]
|
||||
removeStrip: (id: string) => void
|
||||
updateStrip: (id: string, accel_ms2: number) => void
|
||||
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||
clearStrips: () => void
|
||||
loadStrips: (strips: AccelerationStrip[]) => void
|
||||
}
|
||||
|
||||
interface PendingStrip {
|
||||
startFrac: number
|
||||
marker: Cesium.Entity
|
||||
}
|
||||
interface PendingStrip { startFrac: number; pt: [number, number, number] }
|
||||
|
||||
export function useAccelerationStrips(
|
||||
viewer: Cesium.Viewer,
|
||||
pathPts: Cesium.Cartesian3[],
|
||||
map: Map,
|
||||
pathPts: [number, number, number][],
|
||||
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
|
||||
const isActiveRef = useRef(isActive)
|
||||
isActiveRef.current = isActive
|
||||
const layer3DRef = useRef<Layer3DHandle | null>(null)
|
||||
|
||||
// ── Public callbacks ───────────────────────────────────────────────────────
|
||||
|
||||
@ -75,166 +102,167 @@ export function useAccelerationStrips(
|
||||
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
||||
}, [])
|
||||
|
||||
const clearStrips = useCallback(() => {
|
||||
setStrips([])
|
||||
const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
|
||||
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
|
||||
}, [])
|
||||
|
||||
const clearStrips = useCallback(() => setStrips([]), [])
|
||||
|
||||
const loadStrips = useCallback((newStrips: AccelerationStrip[]) => {
|
||||
setStrips(newStrips)
|
||||
}, [])
|
||||
|
||||
// ── Set up MapLibre sources + layers ───────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
map.addSource(SRC_STRIPS, { type: 'geojson', data: emptyFC() })
|
||||
map.addSource(SRC_PENDING, { type: 'geojson', data: emptyFC() })
|
||||
|
||||
// Invisible line kept only for right-click hit-testing (queryRenderedFeatures).
|
||||
map.addLayer({ id: LYR_STRIPS, type: 'line', source: SRC_STRIPS,
|
||||
paint: { 'line-color': '#f59e0b', 'line-width': 8, 'line-opacity': 0.01 } })
|
||||
|
||||
// Draped circle showing the pending strip start point.
|
||||
map.addLayer({ id: LYR_PENDING, type: 'circle', source: SRC_PENDING,
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#f59e0b',
|
||||
'circle-stroke-color': 'rgba(0,0,0,0.6)',
|
||||
'circle-stroke-width': 2,
|
||||
} })
|
||||
|
||||
return () => {
|
||||
safeRemoveLayers(map,
|
||||
[LYR_STRIPS, LYR_PENDING],
|
||||
[SRC_STRIPS, SRC_PENDING],
|
||||
)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// ── Custom 3D layer for strip visual rendering ─────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handle = createCustom3DLayer(LYR_STRIPS_3D, map)
|
||||
layer3DRef.current = handle
|
||||
return () => {
|
||||
handle.destroy()
|
||||
layer3DRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// ── 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 onClick = (e: MapMouseEvent) => {
|
||||
if (!isActiveRef.current) 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)
|
||||
const { frac, pt } = snapToPath([e.lngLat.lng, e.lngLat.lat], 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, pt }
|
||||
setData(map, SRC_PENDING, {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: pt },
|
||||
properties: {},
|
||||
})
|
||||
pendingRef.current = { startFrac: frac, marker }
|
||||
} else {
|
||||
// Second click — complete the strip
|
||||
const { startFrac, marker } = pendingRef.current
|
||||
viewer.entities.remove(marker)
|
||||
const { startFrac } = pendingRef.current
|
||||
pendingRef.current = null
|
||||
setData(map, SRC_PENDING, emptyFC())
|
||||
|
||||
let sf = startFrac, ef = frac
|
||||
if (sf > ef) [sf, ef] = [ef, sf]
|
||||
if (sf === ef) return // degenerate — same point
|
||||
if (sf === ef) return
|
||||
|
||||
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)
|
||||
const onContextMenu = () => {
|
||||
if (!isActiveRef.current || !pendingRef.current) return
|
||||
pendingRef.current = null
|
||||
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||
setData(map, SRC_PENDING, emptyFC())
|
||||
}
|
||||
|
||||
map.on('click', onClick)
|
||||
map.getCanvas().addEventListener('contextmenu', onContextMenu)
|
||||
|
||||
return () => {
|
||||
if (!handler.isDestroyed()) handler.destroy()
|
||||
map.off('click', onClick)
|
||||
map.getCanvas().removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}, [viewer, isActive]) // pathPts via ref — intentional
|
||||
}, [map])
|
||||
|
||||
// ── Cesium polyline entity sync ────────────────────────────────────────────
|
||||
// ── Right-click on strip to delete ────────────────────────────────────────
|
||||
|
||||
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)
|
||||
const onContextMenuStrip = (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
if (isActiveRef.current) return
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: [LYR_STRIPS] })
|
||||
if (!features.length) return
|
||||
const stripId = features[0].properties?.stripId as string | undefined
|
||||
if (stripId) removeStrip(stripId)
|
||||
}
|
||||
})
|
||||
|
||||
if (pathPts.length < 2) return
|
||||
map.on('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
|
||||
return () => {
|
||||
map.off('contextmenu', LYR_STRIPS, onContextMenuStrip as Parameters<typeof map.on>[1])
|
||||
}
|
||||
}, [map, removeStrip])
|
||||
|
||||
const arcs = computeArcLengths(pathPts)
|
||||
// ── Sync strip GeoJSON + 3D tubes ─────────────────────────────────────────
|
||||
|
||||
// Remove all existing strip entities and rebuild — pathPts may have changed
|
||||
stripEntities.current.forEach(entity => viewer.entities.remove(entity))
|
||||
stripEntities.current.clear()
|
||||
useEffect(() => {
|
||||
if (!showStrips) {
|
||||
layer3DRef.current?.update([])
|
||||
return
|
||||
}
|
||||
const pts = pathPts
|
||||
if (pts.length < 2) {
|
||||
setData(map, SRC_STRIPS, emptyFC())
|
||||
layer3DRef.current?.update([])
|
||||
return
|
||||
}
|
||||
const arcs = computeArcLengths(pts)
|
||||
|
||||
for (const strip of strips) {
|
||||
const segments: { coords: [number, number, number][]; id: string }[] = []
|
||||
|
||||
const features: GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>[] = strips.map(strip => {
|
||||
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,
|
||||
},
|
||||
const lo = Math.min(si, ei)
|
||||
const hi = Math.max(si, ei)
|
||||
const sliced = pts.slice(lo, hi + 1) as [number, number, number][]
|
||||
if (sliced.length >= 2) segments.push({ coords: sliced, id: strip.id })
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'LineString' as const, coordinates: sliced },
|
||||
properties: { stripId: strip.id },
|
||||
})
|
||||
stripEntities.current.set(strip.id, entity)
|
||||
}
|
||||
}, [strips, pathPts, viewer])
|
||||
}).filter(f => (f.geometry as GeoJSON.LineString).coordinates.length >= 2)
|
||||
|
||||
// ── Right-click on strip entity to delete ─────────────────────────────────
|
||||
// Invisible draped geometry for right-click hit-testing
|
||||
setData(map, SRC_STRIPS, { type: 'FeatureCollection', features })
|
||||
|
||||
// 3D tube rendering on the path
|
||||
layer3DRef.current?.update(
|
||||
segments.map(s => ({ pts: s.coords, color: 0xf59e0b, radiusMeters: 0.4 }))
|
||||
)
|
||||
}, [strips, pathPts, showStrips, map])
|
||||
|
||||
// ── Visibility toggle ─────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
||||
const vis = showStrips ? 'visible' : 'none'
|
||||
if (map.getLayer(LYR_STRIPS)) map.setLayoutProperty(LYR_STRIPS, 'visibility', vis)
|
||||
if (map.getLayer(LYR_PENDING)) map.setLayoutProperty(LYR_PENDING, 'visibility', vis)
|
||||
// 3D layer: update with empty when hidden, restore on show
|
||||
// (handled by the strip sync effect which runs on showStrips via the deps below)
|
||||
}, [showStrips, map])
|
||||
|
||||
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 }
|
||||
return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
|
||||
}
|
||||
|
||||
@ -1,13 +1,35 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import type { Map, MapMouseEvent, GeoJSONSource } from 'maplibre-gl'
|
||||
import type { AnchorPoint } from './bezierUtils'
|
||||
import { effectivePosition, samplePath, computeRails } from './bezierUtils'
|
||||
import { effectivePosition, effectiveLngLatAlt, samplePath, computeRails } from './bezierUtils'
|
||||
import { ecefToLngLatAlt, safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
import { createCustom3DLayer } from '../maplibre/custom3DLayer'
|
||||
import type { Layer3DHandle, Point3D } from '../maplibre/custom3DLayer'
|
||||
|
||||
export type EditorMode = 'add' | 'select' | 'strip'
|
||||
|
||||
// ── Altitude helper ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// map.queryTerrainElevation() returns a RELATIVE value:
|
||||
// (DEM_at_point − DEM_at_center) × exaggeration
|
||||
//
|
||||
// To get absolute MSL elevation in metres (matching Terrarium tile encoding and
|
||||
// MercatorCoordinate.fromLngLat's altitude parameter), we add back the centre
|
||||
// elevation stored in the transform and divide out the exaggeration factor.
|
||||
//
|
||||
// The exaggeration constant must match the value in MapLibreViewer.tsx.
|
||||
const TERRAIN_EXAGGERATION = 1.5
|
||||
|
||||
function getAbsoluteTerrainElevation(map: Map, lngLat: { lng: number; lat: number }): number {
|
||||
const relative = map.queryTerrainElevation(lngLat) ?? 0
|
||||
const centerElev = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0
|
||||
return (relative + centerElev) / TERRAIN_EXAGGERATION
|
||||
}
|
||||
|
||||
export interface CoasterPathHandle {
|
||||
anchors: AnchorPoint[]
|
||||
pathPts: Cesium.Cartesian3[]
|
||||
/** Sampled path as geographic [lon, lat, alt] tuples */
|
||||
pathPts: [number, number, number][]
|
||||
selectedId: string | null
|
||||
mode: EditorMode
|
||||
setMode: (m: EditorMode) => void
|
||||
@ -18,37 +40,54 @@ export interface CoasterPathHandle {
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
let _counter = 0
|
||||
function genId(): string {
|
||||
return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}`
|
||||
// ── Source / layer IDs ────────────────────────────────────────────────────────
|
||||
|
||||
const SRC_PATH = 'coaster-path-src' // thin draped line for queryRenderedFeatures hover
|
||||
const SRC_LABEL = 'coaster-label-src'
|
||||
const LYR_PATH = 'coaster-path-lyr'
|
||||
const LYR_LABEL = 'coaster-label-lyr'
|
||||
const LYR_PATH_3D = 'coaster-path-3d' // custom 3D layer id
|
||||
|
||||
function emptyFC(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [] } }
|
||||
|
||||
function setData(map: Map, src: string, data: GeoJSON.GeoJSON) {
|
||||
(map.getSource(src) as GeoJSONSource | undefined)?.setData(data)
|
||||
}
|
||||
|
||||
export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle {
|
||||
let _counter = 0
|
||||
function genId(): string { return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}` }
|
||||
|
||||
// Sphere radius in metres for anchor markers at each selection state.
|
||||
const ANCHOR_RADIUS_NORMAL = 4
|
||||
const ANCHOR_RADIUS_SELECTED = 6
|
||||
|
||||
export function useCoasterPath(
|
||||
map: Map,
|
||||
showPath = true,
|
||||
showAnchors = true,
|
||||
): CoasterPathHandle {
|
||||
const [anchors, setAnchors] = useState<AnchorPoint[]>([])
|
||||
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
|
||||
const [pathPts, setPathPts] = useState<[number, number, number][]>([])
|
||||
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
|
||||
const layer3DRef = useRef<Layer3DHandle | null>(null)
|
||||
|
||||
// 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)
|
||||
// Drag state (no React re-renders during drag)
|
||||
const isDragging = useRef(false)
|
||||
const dragAnchorId = useRef<string | null>(null)
|
||||
const dragPos = useRef<Cesium.Cartesian3 | null>(null)
|
||||
const dragLngLat = useRef<[number, number] | null>(null)
|
||||
const didMoveDuringDrag = useRef(false)
|
||||
|
||||
// ── public callbacks ───────────────────────────────────────────────────────
|
||||
// ── 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))
|
||||
setAnchors(prev => prev.map(a =>
|
||||
a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a,
|
||||
))
|
||||
}, [])
|
||||
|
||||
const removeAnchor = useCallback((id: string) => {
|
||||
@ -70,237 +109,229 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setAnchors([])
|
||||
setSelectedId(null)
|
||||
}, [])
|
||||
const clearAll = useCallback(() => { setAnchors([]); setSelectedId(null) }, [])
|
||||
|
||||
// ── cursor style ───────────────────────────────────────────────────────────
|
||||
// ── Set up MapLibre sources + layers ───────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
viewer.scene.canvas.style.cursor =
|
||||
mode === 'add' ? 'crosshair' :
|
||||
mode === 'strip' ? 'cell' : 'default'
|
||||
}, [mode, viewer])
|
||||
map.addSource(SRC_PATH, { type: 'geojson', data: emptyFC() })
|
||||
map.addSource(SRC_LABEL, { type: 'geojson', data: emptyFC() })
|
||||
|
||||
// ── entity sync ────────────────────────────────────────────────────────────
|
||||
// Thin draped line — used only so queryRenderedFeatures can pick up hover events
|
||||
map.addLayer({ id: LYR_PATH, type: 'line', source: SRC_PATH,
|
||||
layout: { 'line-cap': 'round' },
|
||||
paint: { 'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.7 } })
|
||||
|
||||
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,
|
||||
map.addLayer({ id: LYR_LABEL, type: 'symbol', source: SRC_LABEL,
|
||||
layout: {
|
||||
'text-field': ['get', 'label'],
|
||||
'text-size': 13,
|
||||
'text-anchor': 'bottom',
|
||||
'text-offset': [0, -1.2],
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
paint: {
|
||||
'text-color': '#4ade80',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1.5,
|
||||
} })
|
||||
|
||||
// Rebuild path + rails
|
||||
pathEntities.current.forEach(e => viewer.entities.remove(e))
|
||||
pathEntities.current = []
|
||||
return () => {
|
||||
safeRemoveLayers(map,
|
||||
[LYR_PATH, LYR_LABEL],
|
||||
[SRC_PATH, SRC_LABEL],
|
||||
)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// ── Custom 3D layer for path, rails, and anchor spheres ───────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handle = createCustom3DLayer(LYR_PATH_3D, map)
|
||||
layer3DRef.current = handle
|
||||
return () => {
|
||||
handle.destroy()
|
||||
layer3DRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// ── Cursor style ───────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = map.getCanvas()
|
||||
canvas.style.cursor =
|
||||
mode === 'add' ? 'crosshair' :
|
||||
mode === 'strip' ? 'cell' : ''
|
||||
}, [mode, map])
|
||||
|
||||
// ── Sync anchors + path to GeoJSON sources and 3D layer ───────────────────
|
||||
|
||||
useEffect(() => {
|
||||
// Draped label for the start point
|
||||
if (anchors.length > 0) {
|
||||
const [lon, lat, alt] = ecefToLngLatAlt(effectivePosition(anchors[0]))
|
||||
setData(map, SRC_LABEL, {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lon, lat, alt] },
|
||||
properties: { label: '▶ Start' },
|
||||
}],
|
||||
})
|
||||
} else {
|
||||
setData(map, SRC_LABEL, emptyFC())
|
||||
}
|
||||
|
||||
if (anchors.length >= 2) {
|
||||
const pts = samplePath(anchors)
|
||||
setPathPts(pts)
|
||||
const { left, right } = computeRails(pts)
|
||||
const ecefPts = samplePath(anchors)
|
||||
const geoPts = ecefPts.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
|
||||
setPathPts(geoPts)
|
||||
|
||||
const centre = viewer.entities.add({
|
||||
polyline: {
|
||||
positions: pts,
|
||||
width: 2,
|
||||
material: Cesium.Color.YELLOW.withAlpha(0.55),
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
},
|
||||
// Thin draped line for hover hit-testing (queryRenderedFeatures)
|
||||
setData(map, SRC_PATH, {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: geoPts },
|
||||
properties: {},
|
||||
}],
|
||||
})
|
||||
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]
|
||||
|
||||
const { left, right } = computeRails(ecefPts)
|
||||
const leftPts = left.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
|
||||
const rightPts = right.map(v => ecefToLngLatAlt(v)) as [number, number, number][]
|
||||
|
||||
layer3DRef.current?.update([
|
||||
{ pts: geoPts, color: 0xfbbf24, radiusMeters: 0.15 },
|
||||
{ pts: leftPts, color: 0xb0b8c1, radiusMeters: 0.08 },
|
||||
{ pts: rightPts, color: 0xb0b8c1, radiusMeters: 0.08 },
|
||||
])
|
||||
} else {
|
||||
setData(map, SRC_PATH, emptyFC())
|
||||
layer3DRef.current?.update([])
|
||||
setPathPts([])
|
||||
}
|
||||
|
||||
// Start label — always at first anchor
|
||||
if (startLabelRef.current) {
|
||||
viewer.entities.remove(startLabelRef.current)
|
||||
startLabelRef.current = null
|
||||
// Anchor sphere markers rendered in 3D at path altitude
|
||||
if (showAnchors && anchors.length > 0) {
|
||||
const spheres: Point3D[] = anchors.map(a => ({
|
||||
id: a.id,
|
||||
pt: effectiveLngLatAlt(a),
|
||||
color: a.id === selectedId ? 0xf59e0b : 0xffffff,
|
||||
radiusMeters: a.id === selectedId ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL,
|
||||
}))
|
||||
layer3DRef.current?.updatePoints(spheres)
|
||||
} else {
|
||||
layer3DRef.current?.updatePoints([])
|
||||
}
|
||||
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])
|
||||
}, [anchors, selectedId, showAnchors, map])
|
||||
|
||||
// ── path visibility toggle ─────────────────────────────────────────────────
|
||||
// ── Visibility toggles ─────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
pathEntities.current.forEach(e => { e.show = showPath })
|
||||
}, [showPath])
|
||||
const vis = showPath ? 'visible' : 'none'
|
||||
if (map.getLayer(LYR_PATH)) map.setLayoutProperty(LYR_PATH, 'visibility', vis)
|
||||
}, [showPath, map])
|
||||
|
||||
// ── anchor visibility toggle ───────────────────────────────────────────────
|
||||
// ── Input handling ─────────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
const canvas = map.getCanvas()
|
||||
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) => {
|
||||
// Mousedown — begin drag on anchor sphere (select mode only)
|
||||
const onMouseDown = (e: MapMouseEvent) => {
|
||||
if (modeRef.current !== 'select') return
|
||||
const anchorId = pickAnchorId(e.position)
|
||||
const { clientWidth: w, clientHeight: h } = canvas
|
||||
const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null
|
||||
if (!anchorId) return
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
dragAnchorId.current = anchorId
|
||||
dragPos.current = null
|
||||
dragLngLat.current = null
|
||||
didMoveDuringDrag.current = false
|
||||
disableCam()
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
|
||||
map.dragPan.disable()
|
||||
}
|
||||
|
||||
// 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
|
||||
// Mousemove → drag anchor live, or update cursor
|
||||
const onMouseMove = (e: MapMouseEvent) => {
|
||||
if (isDragging.current && dragAnchorId.current) {
|
||||
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 ll: [number, number] = [e.lngLat.lng, e.lngLat.lat]
|
||||
dragLngLat.current = ll
|
||||
// Live-update anchor sphere position without React re-render
|
||||
const id = dragAnchorId.current
|
||||
const pos = dragPos.current
|
||||
setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a))
|
||||
const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
|
||||
const current = anchorsRef.current
|
||||
const spheres: Point3D[] = current.map(a => {
|
||||
const pt: [number, number, number] = a.id === id
|
||||
? [ll[0], ll[1], terrainAlt + a.heightOffset]
|
||||
: effectiveLngLatAlt(a)
|
||||
return {
|
||||
id: a.id,
|
||||
pt,
|
||||
color: a.id === selectedRef.current ? 0xf59e0b : 0xffffff,
|
||||
radiusMeters: a.id === selectedRef.current ? ANCHOR_RADIUS_SELECTED : ANCHOR_RADIUS_NORMAL,
|
||||
}
|
||||
})
|
||||
layer3DRef.current?.updatePoints(spheres)
|
||||
return
|
||||
}
|
||||
if (modeRef.current === 'select') {
|
||||
const { clientWidth: w, clientHeight: h } = canvas
|
||||
const hit = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h)
|
||||
canvas.style.cursor = hit ? 'grab' : ''
|
||||
}
|
||||
}
|
||||
|
||||
// Mouseup → commit drag to React state
|
||||
const onMouseUp = (e: MapMouseEvent) => {
|
||||
if (!isDragging.current) return
|
||||
if (didMoveDuringDrag.current && dragAnchorId.current && dragLngLat.current) {
|
||||
const id = dragAnchorId.current
|
||||
const [lng, lat] = dragLngLat.current
|
||||
const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
|
||||
setAnchors(prev => prev.map(a =>
|
||||
a.id === id ? { ...a, lngLat: [lng, lat], terrainHeight: terrainAlt } : 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
|
||||
dragLngLat.current = null
|
||||
map.dragPan.enable()
|
||||
if (modeRef.current === 'select') canvas.style.cursor = ''
|
||||
}
|
||||
|
||||
const anchorId = pickAnchorId(e.position)
|
||||
// Click → add anchor or select/deselect
|
||||
const onClick = (e: MapMouseEvent) => {
|
||||
if (didMoveDuringDrag.current) { didMoveDuringDrag.current = false; return }
|
||||
|
||||
// 3D sphere hit test for anchor selection
|
||||
const { clientWidth: w, clientHeight: h } = canvas
|
||||
const anchorId = layer3DRef.current?.hitTestPoints(e.point.x, e.point.y, w, h) ?? null
|
||||
if (anchorId) {
|
||||
setSelectedId(prev => prev === anchorId ? null : anchorId)
|
||||
return
|
||||
}
|
||||
|
||||
if (modeRef.current === 'add') {
|
||||
const pos = pickTerrain(e.position)
|
||||
if (!pos) return
|
||||
const terrainAlt = getAbsoluteTerrainElevation(map, e.lngLat)
|
||||
const id = genId()
|
||||
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 1 }])
|
||||
setAnchors(prev => [...prev, {
|
||||
id,
|
||||
lngLat: [e.lngLat.lng, e.lngLat.lat],
|
||||
terrainHeight: terrainAlt,
|
||||
heightOffset: 1,
|
||||
}])
|
||||
setSelectedId(null)
|
||||
} else if (modeRef.current === 'select') {
|
||||
setSelectedId(null)
|
||||
}
|
||||
// 'strip' mode clicks are handled by useAccelerationStrips
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
|
||||
// 'strip' mode clicks handled by useAccelerationStrips
|
||||
}
|
||||
|
||||
// RIGHT_CLICK – undo / remove selected
|
||||
handler.setInputAction(() => {
|
||||
// Right-click on canvas → undo / delete selected
|
||||
const onContextMenu = () => {
|
||||
if (selectedRef.current) {
|
||||
const id = selectedRef.current
|
||||
setAnchors(prev => prev.filter(a => a.id !== id))
|
||||
@ -308,33 +339,26 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
|
||||
} else {
|
||||
setAnchors(prev => prev.slice(0, -1))
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
|
||||
}
|
||||
canvas.addEventListener('contextmenu', onContextMenu)
|
||||
|
||||
map.on('mousedown', onMouseDown)
|
||||
map.on('mousemove', onMouseMove)
|
||||
map.on('mouseup', onMouseUp)
|
||||
map.on('click', onClick)
|
||||
|
||||
return () => {
|
||||
if (!handler.isDestroyed()) handler.destroy()
|
||||
canvas.removeEventListener('contextmenu', suppressCtx)
|
||||
if (!viewer.isDestroyed()) {
|
||||
enableCam()
|
||||
viewer.scene.canvas.style.cursor = 'default'
|
||||
canvas.removeEventListener('contextmenu', onContextMenu)
|
||||
map.off('mousedown', onMouseDown)
|
||||
map.off('mousemove', onMouseMove)
|
||||
map.off('mouseup', onMouseUp)
|
||||
map.off('click', onClick)
|
||||
map.dragPan.enable()
|
||||
canvas.style.cursor = ''
|
||||
}
|
||||
}
|
||||
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [map]) // 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 }
|
||||
return { anchors, pathPts, selectedId, mode, setMode,
|
||||
updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import type { Map } from 'maplibre-gl'
|
||||
import * as THREE from 'three'
|
||||
import type { CoasterSimulationResult } from '../types/api'
|
||||
import { lngLatAltToECEF, ecefToEnu } from '../maplibre/geoUtils'
|
||||
|
||||
// ── Public types ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -10,7 +11,7 @@ export interface TerrainCaptureData {
|
||||
tileBbox: [number, number, number, number]
|
||||
/** Stitched satellite imagery */
|
||||
imageBitmap: ImageBitmap
|
||||
/** 64×64 grid of ENU positions (X=East, Y=Up, Z=−North) with terrain heights */
|
||||
/** 64×64 grid of ENU positions (X=East, Y=North, Z=Up → remapped to Three.js) */
|
||||
terrainVertices: THREE.Vector3[]
|
||||
gridSize: 64
|
||||
/** Geodetic origin used for ENU conversions (shared across all patches) */
|
||||
@ -22,34 +23,126 @@ export interface TerrainCaptureData {
|
||||
export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
// ── Patch sizing ───────────────────────────────────────────────────────────────
|
||||
// Each patch covers PATCH_RADIUS_M metres in each direction from the track centre.
|
||||
// Patches are spaced PATCH_INTERVAL_M apart along the track arc-length.
|
||||
// At z19 a 1 km × 1 km bbox fits in 4×4 tiles → ~0.85 m/px (vs ~9 m/px for an
|
||||
// 8 km bbox at the z15 forced by the old single-capture approach).
|
||||
// Patches overlap by ~150 m at the midpoint between centres so swaps are seamless.
|
||||
|
||||
const PATCH_RADIUS_M = 500 // ±500 m → 1 km × 1 km per patch
|
||||
const PATCH_INTERVAL_M = 700 // one patch centre every 700 m of track
|
||||
const PATCH_RADIUS_M = 500
|
||||
const PATCH_INTERVAL_M = 700
|
||||
|
||||
// ── Tile math ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function lngLatToTile(lon: number, lat: number, z: number): { x: number; y: number } {
|
||||
const n = Math.pow(2, z)
|
||||
const x = Math.floor((lon + 180) / 360 * n)
|
||||
const latR = lat * Math.PI / 180
|
||||
const y = Math.floor((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * n)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function tileToLng(x: number, z: number): number {
|
||||
return x / Math.pow(2, z) * 360 - 180
|
||||
}
|
||||
|
||||
function tileToLat(y: number, z: number): number {
|
||||
const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z)
|
||||
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
|
||||
}
|
||||
|
||||
// ── Geo → Three.js ENU (X=East, Y=Up, Z=−North) ───────────────────────────────
|
||||
// Note: RideRenderer expects Y=Up, Z=−North (Three.js-friendly ENU).
|
||||
|
||||
export function geoToEnu(
|
||||
lon: number, lat: number, alt: number,
|
||||
origin: [number, number, number],
|
||||
): THREE.Vector3 {
|
||||
const pt = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
|
||||
const org = Cesium.Cartesian3.fromDegrees(origin[0], origin[1], origin[2])
|
||||
const enuFrame = Cesium.Transforms.eastNorthUpToFixedFrame(org)
|
||||
const inv = Cesium.Matrix4.inverseTransformation(enuFrame, new Cesium.Matrix4())
|
||||
const enu = Cesium.Matrix4.multiplyByPoint(inv, pt, new Cesium.Cartesian3())
|
||||
// ENU: x=East, y=North, z=Up → Three.js: x=East, y=Up, z=−North
|
||||
const pt = lngLatAltToECEF(lon, lat, alt)
|
||||
const enu = ecefToEnu(pt, origin) // X=East, Y=North, Z=Up
|
||||
// Remap to Three.js convention used by RideRenderer: X=East, Y=Up, Z=−North
|
||||
return new THREE.Vector3(enu.x, enu.z, -enu.y)
|
||||
}
|
||||
|
||||
// ── Terrarium tile elevation decoding ─────────────────────────────────────────
|
||||
|
||||
async function fetchTerrariumTile(z: number, x: number, y: number): Promise<ImageBitmap | null> {
|
||||
const url = `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) return null
|
||||
return createImageBitmap(await resp.blob())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function decodeTerrarium(r: number, g: number, b: number): number {
|
||||
return r * 256 + g + b / 256 - 32768
|
||||
}
|
||||
|
||||
async function sampleElevationGrid(
|
||||
tileBbox: [number, number, number, number],
|
||||
gridSize: number,
|
||||
): Promise<number[]> {
|
||||
const [west, south, east, north] = tileBbox
|
||||
|
||||
// Use zoom 14 for terrain sampling — 4× better resolution than z13 (~5 m/px),
|
||||
// much closer to MapLibre's z15 queryTerrainElevation samples, so track altitudes
|
||||
// and mesh heights agree within 1-3 m instead of 10+ m on varied terrain.
|
||||
const ELEV_ZOOM = 14
|
||||
|
||||
const swTile = lngLatToTile(west, south, ELEV_ZOOM)
|
||||
const neTile = lngLatToTile(east, north, ELEV_ZOOM)
|
||||
const tileXMin = swTile.x
|
||||
const tileXMax = neTile.x
|
||||
const tileYMin = neTile.y
|
||||
const tileYMax = swTile.y
|
||||
const nx = tileXMax - tileXMin + 1
|
||||
const ny = tileYMax - tileYMin + 1
|
||||
|
||||
// Fetch and stitch terrain tiles
|
||||
const tileImages = await Promise.all(
|
||||
Array.from({ length: ny }, (_, tj) =>
|
||||
Array.from({ length: nx }, async (_, ti) => {
|
||||
const img = await fetchTerrariumTile(ELEV_ZOOM, tileXMin + ti, tileYMin + tj)
|
||||
return { ti, tj, img }
|
||||
}),
|
||||
).flat(),
|
||||
)
|
||||
|
||||
const TILE_PX = 256
|
||||
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
|
||||
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
|
||||
for (const { ti, tj, img } of tileImages) {
|
||||
if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX)
|
||||
}
|
||||
|
||||
// Actual geographic extent covered by stitched tiles
|
||||
const tileWest = tileToLng(tileXMin, ELEV_ZOOM)
|
||||
const tileEast = tileToLng(tileXMax + 1, ELEV_ZOOM)
|
||||
const tileNorth = tileToLat(tileYMin, ELEV_ZOOM)
|
||||
const tileSouth = tileToLat(tileYMax + 1, ELEV_ZOOM)
|
||||
const totalW = nx * TILE_PX
|
||||
const totalH = ny * TILE_PX
|
||||
|
||||
const elevations: number[] = []
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const lon = west + (east - west) * i / (gridSize - 1)
|
||||
const lat = south + (north - south) * j / (gridSize - 1)
|
||||
const px = Math.max(0, Math.min(totalW - 1, Math.round(
|
||||
(lon - tileWest) / (tileEast - tileWest) * totalW,
|
||||
)))
|
||||
// Lat is top-to-bottom (north=0, south=height)
|
||||
const py = Math.max(0, Math.min(totalH - 1, Math.round(
|
||||
(tileNorth - lat) / (tileNorth - tileSouth) * totalH,
|
||||
)))
|
||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data
|
||||
elevations.push(decodeTerrarium(r, g, b))
|
||||
}
|
||||
}
|
||||
return elevations
|
||||
}
|
||||
|
||||
// ── Single-patch capture ───────────────────────────────────────────────────────
|
||||
|
||||
async function captureTerrainPatch(
|
||||
viewer: Cesium.Viewer,
|
||||
origin: [number, number, number],
|
||||
minLon: number,
|
||||
maxLon: number,
|
||||
@ -57,91 +150,72 @@ async function captureTerrainPatch(
|
||||
maxLat: number,
|
||||
trackFrac: number,
|
||||
): Promise<TerrainCaptureData> {
|
||||
// ── Use Cesium's imagery provider (same tiles the viewer already shows) ───
|
||||
const provider = viewer.imageryLayers.get(0).imageryProvider
|
||||
const tilingScheme = provider.tilingScheme
|
||||
const maxLevel = Math.min((provider as { maximumLevel?: number }).maximumLevel ?? 19, 21)
|
||||
|
||||
// ── Find the highest zoom where tile count stays ≤ 25 ────────────────────
|
||||
let level = maxLevel
|
||||
// ── Satellite imagery (ESRI World Imagery, {z}/{y}/{x} order) ─────────────
|
||||
// Use z19 max; find highest zoom where tile count ≤ 25
|
||||
let level = 19
|
||||
while (level > 5) {
|
||||
const sw = tilingScheme.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
|
||||
)
|
||||
const ne = tilingScheme.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
|
||||
)
|
||||
if (!sw || !ne) { level--; continue }
|
||||
const sw = lngLatToTile(minLon, minLat, level)
|
||||
const ne = lngLatToTile(maxLon, maxLat, level)
|
||||
const nx = ne.x - sw.x + 1
|
||||
const ny = sw.y - ne.y + 1
|
||||
if (nx >= 1 && ny >= 1 && nx * ny <= 25) break
|
||||
level--
|
||||
}
|
||||
|
||||
const swTile = tilingScheme.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(minLon, minLat), level, new Cesium.Cartesian2(),
|
||||
)!
|
||||
const neTile = tilingScheme.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(maxLon, maxLat), level, new Cesium.Cartesian2(),
|
||||
)!
|
||||
|
||||
const swTile = lngLatToTile(minLon, minLat, level)
|
||||
const neTile = lngLatToTile(maxLon, maxLat, level)
|
||||
const tileXMin = swTile.x
|
||||
const tileXMax = neTile.x
|
||||
const tileYMin = neTile.y
|
||||
const tileYMin = neTile.y // north tiles have smaller y
|
||||
const tileYMax = swTile.y
|
||||
const nx = tileXMax - tileXMin + 1
|
||||
const ny = tileYMax - tileYMin + 1
|
||||
const TILE_PX = provider.tileWidth
|
||||
const TILE_PX = 256
|
||||
|
||||
// ── Fetch tiles via the provider ─────────────────────────────────────────
|
||||
const tileImages = await Promise.all(
|
||||
// ESRI uses {z}/{y}/{x}
|
||||
const satImages = await Promise.all(
|
||||
Array.from({ length: ny }, (_, tj) =>
|
||||
Array.from({ length: nx }, (_, ti) => {
|
||||
Array.from({ length: nx }, async (_, ti) => {
|
||||
const x = tileXMin + ti
|
||||
const y = tileYMin + tj
|
||||
const result = provider.requestImage(x, y, level)
|
||||
const p: Promise<Cesium.ImageryTypes | undefined> =
|
||||
result instanceof Promise ? result : Promise.resolve(result ?? undefined)
|
||||
return p.then(img => ({ ti, tj, img: img ?? null }))
|
||||
const url = `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${level}/${y}/${x}`
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) return { ti, tj, img: null }
|
||||
const img = await createImageBitmap(await resp.blob())
|
||||
return { ti, tj, img }
|
||||
} catch {
|
||||
return { ti, tj, img: null }
|
||||
}
|
||||
}),
|
||||
).flat(),
|
||||
)
|
||||
|
||||
// ── Stitch into a single OffscreenCanvas ─────────────────────────────────
|
||||
const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX)
|
||||
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
|
||||
for (const { ti, tj, img } of tileImages) {
|
||||
for (const { ti, tj, img } of satImages) {
|
||||
if (img) ctx.drawImage(img as CanvasImageSource, ti * TILE_PX, tj * TILE_PX)
|
||||
}
|
||||
const imageBitmap = await createImageBitmap(canvas)
|
||||
// Three.js ignores `flipY` for ImageBitmap; flip at creation time so UV v=0 (South row)
|
||||
// maps to the bottom of the satellite image (South), not top (North).
|
||||
const imageBitmap = await createImageBitmap(canvas, { imageOrientation: 'flipY' })
|
||||
|
||||
// ── Derive geographic bbox from actual tile edges ─────────────────────────
|
||||
const nwRect = tilingScheme.tileXYToRectangle(tileXMin, tileYMin, level, new Cesium.Rectangle())
|
||||
const seRect = tilingScheme.tileXYToRectangle(tileXMax, tileYMax, level, new Cesium.Rectangle())
|
||||
// Geographic bbox from actual tile edges
|
||||
const tileBbox: [number, number, number, number] = [
|
||||
Cesium.Math.toDegrees(nwRect.west),
|
||||
Cesium.Math.toDegrees(seRect.south),
|
||||
Cesium.Math.toDegrees(seRect.east),
|
||||
Cesium.Math.toDegrees(nwRect.north),
|
||||
tileToLng(tileXMin, level),
|
||||
tileToLat(tileYMax + 1, level),
|
||||
tileToLng(tileXMax + 1, level),
|
||||
tileToLat(tileYMin, level),
|
||||
]
|
||||
|
||||
// ── Sample terrain heights on a 64×64 grid ───────────────────────────────
|
||||
const GRID = 64
|
||||
const cartographics: Cesium.Cartographic[] = []
|
||||
for (let j = 0; j < GRID; j++) {
|
||||
for (let i = 0; i < GRID; i++) {
|
||||
const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * i / (GRID - 1)
|
||||
const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * j / (GRID - 1)
|
||||
cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat))
|
||||
}
|
||||
}
|
||||
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, cartographics)
|
||||
// ── Terrain elevation grid ────────────────────────────────────────────────
|
||||
const GRID = 64 as const
|
||||
const elevations = await sampleElevationGrid(tileBbox, GRID)
|
||||
|
||||
// ── Convert to ENU Three.js vectors ──────────────────────────────────────
|
||||
const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => {
|
||||
const terrainVertices: THREE.Vector3[] = elevations.map((elev, idx) => {
|
||||
const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * (idx % GRID) / (GRID - 1)
|
||||
const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * Math.floor(idx / GRID) / (GRID - 1)
|
||||
return geoToEnu(lon, lat, c.height ?? 0, origin)
|
||||
return geoToEnu(lon, lat, elev, origin)
|
||||
})
|
||||
|
||||
return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin, trackFrac }
|
||||
@ -150,7 +224,6 @@ async function captureTerrainPatch(
|
||||
// ── Multi-patch prefetch ───────────────────────────────────────────────────────
|
||||
|
||||
async function captureAllPatches(
|
||||
viewer: Cesium.Viewer,
|
||||
simResult: CoasterSimulationResult,
|
||||
): Promise<TerrainCaptureData[]> {
|
||||
const origin = simResult.origin
|
||||
@ -159,13 +232,11 @@ async function captureAllPatches(
|
||||
const r1 = simResult.rail_1 as [number, number, number][]
|
||||
const r2 = simResult.rail_2 as [number, number, number][]
|
||||
|
||||
// ── Sample N patch centres evenly along arc-length ────────────────────────
|
||||
const N = Math.max(1, Math.round(totalLength / PATCH_INTERVAL_M) + 1)
|
||||
const patchFracs = Array.from({ length: N }, (_, i) =>
|
||||
N === 1 ? 0.5 : i / (N - 1),
|
||||
)
|
||||
|
||||
// Geographic midpoint between both rails at a given s_frac
|
||||
function geoAt(frac: number): [number, number] {
|
||||
let idx = sFracs.length - 2
|
||||
for (let j = 0; j < sFracs.length - 1; j++) {
|
||||
@ -175,14 +246,13 @@ async function captureAllPatches(
|
||||
return [(r1[idx][0] + r2[idx][0]) / 2, (r1[idx][1] + r2[idx][1]) / 2]
|
||||
}
|
||||
|
||||
// ── Fetch all patches in parallel ─────────────────────────────────────────
|
||||
return Promise.all(
|
||||
patchFracs.map(frac => {
|
||||
const [lon, lat] = geoAt(frac)
|
||||
const rLat = PATCH_RADIUS_M / 111320
|
||||
const rLon = PATCH_RADIUS_M / (111320 * Math.cos(lat * Math.PI / 180))
|
||||
return captureTerrainPatch(
|
||||
viewer, origin,
|
||||
origin,
|
||||
lon - rLon, lon + rLon,
|
||||
lat - rLat, lat + rLat,
|
||||
frac,
|
||||
@ -191,10 +261,10 @@ async function captureAllPatches(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTerrainCapture(
|
||||
viewer: Cesium.Viewer,
|
||||
_map: Map, // kept for API compatibility; tile fetching is now direct
|
||||
simResult: CoasterSimulationResult | null,
|
||||
) {
|
||||
const [status, setStatus] = useState<CaptureStatus>('idle')
|
||||
@ -207,13 +277,13 @@ export function useTerrainCapture(
|
||||
|
||||
if (!simResult) {
|
||||
setStatus('idle')
|
||||
return
|
||||
return () => { abortRef.current = true }
|
||||
}
|
||||
|
||||
abortRef.current = false
|
||||
setStatus('loading')
|
||||
|
||||
captureAllPatches(viewer, simResult)
|
||||
captureAllPatches(simResult)
|
||||
.then(patches => {
|
||||
if (abortRef.current) return
|
||||
setCaptureData(patches)
|
||||
@ -224,7 +294,9 @@ export function useTerrainCapture(
|
||||
console.error('Terrain capture failed:', err)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [simResult, viewer])
|
||||
|
||||
return () => { abortRef.current = true }
|
||||
}, [simResult]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { status, captureData }
|
||||
}
|
||||
|
||||
116
web/src/maplibre/MapLibreViewer.tsx
Normal file
116
web/src/maplibre/MapLibreViewer.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { MapLibreContext } from './maplibreContext'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Satellite imagery: ESRI World Imagery (free, no key needed).
|
||||
* 3-D terrain DEM: AWS Open Terrain in Terrarium format (public domain).
|
||||
* OSM labels/roads overlay: OpenStreetMap raster at reduced opacity.
|
||||
*/
|
||||
function buildMapStyle(): maplibregl.StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
// ESRI uses {z}/{y}/{x} tile order
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© Esri, Maxar, Earthstar Geographics',
|
||||
},
|
||||
'terrain-dem': {
|
||||
type: 'raster-dem',
|
||||
encoding: 'terrarium',
|
||||
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 15,
|
||||
attribution: '© Mapzen, USGS, SRTM',
|
||||
},
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-layer',
|
||||
type: 'raster',
|
||||
source: 'satellite',
|
||||
},
|
||||
{
|
||||
id: 'osm-overlay',
|
||||
type: 'raster',
|
||||
source: 'osm',
|
||||
paint: { 'raster-opacity': 0.25 },
|
||||
},
|
||||
],
|
||||
// sky is set programmatically after load
|
||||
}
|
||||
}
|
||||
|
||||
export function MapLibreViewer({ children }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || map) return
|
||||
|
||||
const m = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: buildMapStyle(),
|
||||
center: [10.0, 51.0],
|
||||
zoom: 7,
|
||||
pitch: 50,
|
||||
bearing: 0,
|
||||
antialias: true,
|
||||
maxPitch: 80,
|
||||
})
|
||||
|
||||
m.on('load', () => {
|
||||
// Enable 3D terrain
|
||||
m.setTerrain({ source: 'terrain-dem', exaggeration: 1.5 })
|
||||
|
||||
// Sky atmosphere
|
||||
m.setSky({
|
||||
'sky-color': '#199EF3',
|
||||
'sky-horizon-blend': 0.5,
|
||||
'horizon-color': '#ffffff',
|
||||
'horizon-fog-blend': 0.5,
|
||||
'fog-color': '#0000ff',
|
||||
'fog-ground-blend': 0.9,
|
||||
'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 1, 10, 0] as maplibregl.ExpressionSpecification,
|
||||
})
|
||||
|
||||
// Navigation control (zoom + compass)
|
||||
m.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right')
|
||||
|
||||
setMap(m)
|
||||
})
|
||||
|
||||
return () => {
|
||||
m.remove()
|
||||
setMap(null)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} style={{ position: 'fixed', top: 52, left: 0, right: 0, bottom: 0 }} />
|
||||
{map && (
|
||||
<MapLibreContext.Provider value={map}>
|
||||
{children}
|
||||
</MapLibreContext.Provider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
279
web/src/maplibre/custom3DLayer.ts
Normal file
279
web/src/maplibre/custom3DLayer.ts
Normal file
@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Custom MapLibre layer that renders 3D tube geometry above terrain.
|
||||
*
|
||||
* MapLibre 4.x drapes all built-in layer types (line, circle, fill) on the terrain
|
||||
* surface regardless of their GeoJSON Z coordinate. The only escape hatch is a
|
||||
* `type: 'custom'` layer whose `render()` callback receives the Mercator projection
|
||||
* matrix and can draw arbitrary WebGL. We use Three.js for the geometry, reusing
|
||||
* MapLibre's WebGL context so the output lands in the same frame.
|
||||
*
|
||||
* Altitude alignment: MapLibre passes the `mercatorMatrix` to custom layers, which
|
||||
* does NOT include the center-elevation offset baked into the standard tile matrix.
|
||||
* To align with the visual terrain surface (rendered at DEM × exaggeration), we must:
|
||||
* 1. Multiply stored raw-MSL altitudes by TERRAIN_EXAGGERATION when building geometry.
|
||||
* 2. Subtract `transform.elevation` (the exaggerated center elevation) from the
|
||||
* origin Z each frame so objects track the camera-relative terrain position.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { MercatorCoordinate } from 'maplibre-gl'
|
||||
import type { CustomLayerInterface, Map } from 'maplibre-gl'
|
||||
|
||||
// Must match the exaggeration value in MapLibreViewer.tsx.
|
||||
const TERRAIN_EXAGGERATION = 1.5
|
||||
|
||||
// ── Public types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Line3D {
|
||||
/** Geographic [lon, lat, altMSL] triples, in order. */
|
||||
pts: [number, number, number][]
|
||||
/** Three.js colour hex (e.g. 0xfbbf24). */
|
||||
color: number
|
||||
/** Physical tube radius in metres. */
|
||||
radiusMeters: number
|
||||
}
|
||||
|
||||
export interface Point3D {
|
||||
/** Stable ID returned by hitTestPoints(). */
|
||||
id: string
|
||||
/** Geographic [lon, lat, altMSL] position. */
|
||||
pt: [number, number, number]
|
||||
/** Three.js colour hex. */
|
||||
color: number
|
||||
/** Physical sphere radius in metres. */
|
||||
radiusMeters: number
|
||||
}
|
||||
|
||||
/** Handle returned by createCustom3DLayer. */
|
||||
export interface Layer3DHandle {
|
||||
/** Replace all rendered tube lines and trigger a repaint. */
|
||||
update: (lines: Line3D[]) => void
|
||||
/** Replace all rendered sphere points and trigger a repaint. */
|
||||
updatePoints: (points: Point3D[]) => void
|
||||
/**
|
||||
* Screen-space hit test against rendered spheres.
|
||||
* @param x CSS-pixel x coordinate
|
||||
* @param y CSS-pixel y coordinate
|
||||
* @param w Canvas CSS-pixel width
|
||||
* @param h Canvas CSS-pixel height
|
||||
* @returns The `id` of the closest intersected sphere, or null.
|
||||
*/
|
||||
hitTestPoints: (x: number, y: number, w: number, h: number) => string | null
|
||||
/** Remove the layer from the map and dispose GPU resources. */
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createCustom3DLayer(id: string, map: Map): Layer3DHandle {
|
||||
const threeCamera = new THREE.Camera()
|
||||
const scene = new THREE.Scene()
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
|
||||
let origin: MercatorCoordinate | null = null
|
||||
let originLngLat: [number, number] | null = null
|
||||
let dirty = true
|
||||
let currentLines: Line3D[] = []
|
||||
let currentPoints: Point3D[] = []
|
||||
|
||||
// GPU resource tracking (lines)
|
||||
const geos: THREE.BufferGeometry[] = []
|
||||
const mats: THREE.Material[] = []
|
||||
const meshes: THREE.Mesh[] = []
|
||||
|
||||
// Sphere meshes for hit testing (parallel arrays)
|
||||
const sphereMeshes: THREE.Mesh[] = []
|
||||
const sphereIds: string[] = []
|
||||
|
||||
// ── Geometry helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function disposeGeometry() {
|
||||
meshes.forEach(m => scene.remove(m))
|
||||
geos.forEach(g => g.dispose())
|
||||
mats.forEach(m => m.dispose())
|
||||
geos.length = 0
|
||||
mats.length = 0
|
||||
meshes.length = 0
|
||||
sphereMeshes.length = 0
|
||||
sphereIds.length = 0
|
||||
origin = null
|
||||
originLngLat = null
|
||||
}
|
||||
|
||||
function rebuildGeometry() {
|
||||
disposeGeometry()
|
||||
dirty = false
|
||||
|
||||
// Determine origin from the first available point (line or sphere).
|
||||
const firstLinePt = currentLines.find(l => l.pts.length >= 2)?.pts[0]
|
||||
const firstSphPt = currentPoints[0]?.pt
|
||||
const firstPt = firstLinePt ?? firstSphPt
|
||||
if (!firstPt) return
|
||||
|
||||
const [lon0, lat0, alt0] = firstPt
|
||||
originLngLat = [lon0, lat0]
|
||||
// Use exaggerated altitude so geometry aligns with visual terrain.
|
||||
origin = MercatorCoordinate.fromLngLat([lon0, lat0], alt0 * TERRAIN_EXAGGERATION)
|
||||
const mpu = origin.meterInMercatorCoordinateUnits()
|
||||
|
||||
// ── Tube geometry for lines ─────────────────────────────────────────────
|
||||
|
||||
for (const { pts, color, radiusMeters } of currentLines) {
|
||||
if (pts.length < 2) continue
|
||||
|
||||
const step = Math.max(1, Math.floor(pts.length / 400))
|
||||
const sampled = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
|
||||
if (sampled.length < 2) continue
|
||||
|
||||
const threePts = sampled.map(([lon, lat, alt]) => {
|
||||
const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION)
|
||||
return new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z)
|
||||
})
|
||||
|
||||
const curve = new THREE.CatmullRomCurve3(threePts)
|
||||
const segments = Math.min(threePts.length * 4, 800)
|
||||
const geo = new THREE.TubeGeometry(curve, segments, radiusMeters * mpu, 8, false)
|
||||
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false })
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.frustumCulled = false
|
||||
|
||||
scene.add(mesh)
|
||||
meshes.push(mesh)
|
||||
geos.push(geo)
|
||||
mats.push(mat)
|
||||
}
|
||||
|
||||
// ── Sphere geometry for points ──────────────────────────────────────────
|
||||
|
||||
for (const { id, pt, color, radiusMeters } of currentPoints) {
|
||||
const [lon, lat, alt] = pt
|
||||
const mc = MercatorCoordinate.fromLngLat([lon, lat], alt * TERRAIN_EXAGGERATION)
|
||||
const pos = new THREE.Vector3(mc.x - origin!.x, mc.y - origin!.y, mc.z - origin!.z)
|
||||
|
||||
const geo = new THREE.SphereGeometry(radiusMeters * mpu, 14, 10)
|
||||
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false, depthWrite: false })
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
mesh.position.copy(pos)
|
||||
mesh.frustumCulled = false
|
||||
|
||||
scene.add(mesh)
|
||||
meshes.push(mesh)
|
||||
geos.push(geo)
|
||||
mats.push(mat)
|
||||
sphereMeshes.push(mesh)
|
||||
sphereIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── MapLibre custom layer ───────────────────────────────────────────────────
|
||||
|
||||
const layer: CustomLayerInterface = {
|
||||
id,
|
||||
type: 'custom',
|
||||
renderingMode: '3d',
|
||||
|
||||
onAdd(_m: Map, gl: WebGLRenderingContext) {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas: map.getCanvas(),
|
||||
context: gl as unknown as WebGL2RenderingContext,
|
||||
antialias: false,
|
||||
})
|
||||
renderer.autoClear = false
|
||||
},
|
||||
|
||||
onRemove() {
|
||||
disposeGeometry()
|
||||
renderer?.dispose()
|
||||
renderer = null
|
||||
},
|
||||
|
||||
render(_gl: WebGLRenderingContext, matrix: number[]) {
|
||||
if (!renderer) return
|
||||
|
||||
if (dirty) rebuildGeometry()
|
||||
|
||||
if (!origin || meshes.length === 0) {
|
||||
renderer.resetState()
|
||||
return
|
||||
}
|
||||
|
||||
// Compensate for the center-elevation offset that is present in
|
||||
// modelViewProjectionMatrix (used by terrain tiles) but absent from
|
||||
// mercatorMatrix (passed to custom layers). Subtracting it here makes
|
||||
// our exaggerated-altitude objects align with the visual terrain surface.
|
||||
//
|
||||
// Use MercatorCoordinate.fromLngLat (not origin.meterInMercatorCoordinateUnits)
|
||||
// because altitude z uses 1/(circumference*cos(lat)) while mpu gives
|
||||
// cos(lat)/circumference — they differ by cos²(lat), causing ~2× error at lat 50°.
|
||||
const centerElevM = (map as unknown as { transform: { elevation: number } }).transform?.elevation ?? 0
|
||||
const centerElevMerc = originLngLat
|
||||
? MercatorCoordinate.fromLngLat(originLngLat, centerElevM).z
|
||||
: centerElevM * origin.meterInMercatorCoordinateUnits()
|
||||
|
||||
const m = new THREE.Matrix4().fromArray(matrix)
|
||||
const t = new THREE.Matrix4().makeTranslation(
|
||||
origin.x,
|
||||
origin.y,
|
||||
origin.z - centerElevMerc,
|
||||
)
|
||||
threeCamera.projectionMatrix = m.multiply(t)
|
||||
// Keep inverse in sync so Raycaster can unproject correctly.
|
||||
threeCamera.projectionMatrixInverse.copy(threeCamera.projectionMatrix).invert()
|
||||
|
||||
renderer.resetState()
|
||||
renderer.render(scene, threeCamera)
|
||||
},
|
||||
}
|
||||
|
||||
map.addLayer(layer)
|
||||
|
||||
// ── Public handle ───────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
update(lines: Line3D[]) {
|
||||
currentLines = lines
|
||||
dirty = true
|
||||
map.triggerRepaint()
|
||||
},
|
||||
|
||||
updatePoints(points: Point3D[]) {
|
||||
currentPoints = points
|
||||
dirty = true
|
||||
map.triggerRepaint()
|
||||
},
|
||||
|
||||
hitTestPoints(x: number, y: number, w: number, h: number): string | null {
|
||||
if (sphereMeshes.length === 0) return null
|
||||
|
||||
const ndcX = (2 * x) / w - 1
|
||||
const ndcY = 1 - (2 * y) / h
|
||||
|
||||
// Unproject screen point into local scene space.
|
||||
const projInv = threeCamera.projectionMatrix.clone().invert()
|
||||
const near = new THREE.Vector3(ndcX, ndcY, -1).applyMatrix4(projInv)
|
||||
const far = new THREE.Vector3(ndcX, ndcY, 1).applyMatrix4(projInv)
|
||||
const dir = far.clone().sub(near).normalize()
|
||||
|
||||
const raycaster = new THREE.Raycaster(near, dir)
|
||||
|
||||
let closestDist = Infinity
|
||||
let closestIdx = -1
|
||||
|
||||
for (let i = 0; i < sphereMeshes.length; i++) {
|
||||
const hits = raycaster.intersectObject(sphereMeshes[i])
|
||||
if (hits.length > 0 && hits[0].distance < closestDist) {
|
||||
closestDist = hits[0].distance
|
||||
closestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
return closestIdx >= 0 ? sphereIds[closestIdx] : null
|
||||
},
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
if (map.getLayer(id)) map.removeLayer(id)
|
||||
} catch { /* map may already be destroyed */ }
|
||||
},
|
||||
}
|
||||
}
|
||||
162
web/src/maplibre/geoUtils.ts
Normal file
162
web/src/maplibre/geoUtils.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
// WGS-84 ellipsoid constants
|
||||
const A = 6378137.0 // semi-major axis (m)
|
||||
const E2 = 0.00669437999014 // first eccentricity squared
|
||||
|
||||
/**
|
||||
* Convert geographic coordinates to ECEF (Earth-Centred Earth-Fixed) metres.
|
||||
*/
|
||||
export function lngLatAltToECEF(lon: number, lat: number, alt: number): THREE.Vector3 {
|
||||
const lonR = lon * Math.PI / 180
|
||||
const latR = lat * Math.PI / 180
|
||||
const sinLat = Math.sin(latR)
|
||||
const cosLat = Math.cos(latR)
|
||||
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
|
||||
return new THREE.Vector3(
|
||||
(N + alt) * cosLat * Math.cos(lonR),
|
||||
(N + alt) * cosLat * Math.sin(lonR),
|
||||
(N * (1 - E2) + alt) * sinLat,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ECEF metres back to [longitude, latitude, altitude].
|
||||
* Uses Bowring's iterative method (5 iterations → sub-mm accuracy).
|
||||
*/
|
||||
export function ecefToLngLatAlt(v: THREE.Vector3): [number, number, number] {
|
||||
const p = Math.sqrt(v.x * v.x + v.y * v.y)
|
||||
const lon = Math.atan2(v.y, v.x) * 180 / Math.PI
|
||||
let lat = Math.atan2(v.z, p * (1 - E2))
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const sinLat = Math.sin(lat)
|
||||
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
|
||||
lat = Math.atan2(v.z + E2 * N * sinLat, p)
|
||||
}
|
||||
const sinLat = Math.sin(lat)
|
||||
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
|
||||
const alt = p / Math.cos(lat) - N
|
||||
return [lon, lat * 180 / Math.PI, alt]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a point from ECEF to ENU coordinates relative to an origin.
|
||||
* ENU: X=East, Y=North, Z=Up
|
||||
*/
|
||||
export function ecefToEnu(
|
||||
point: THREE.Vector3,
|
||||
origin: [number, number, number],
|
||||
): THREE.Vector3 {
|
||||
const [oLon, oLat, oAlt] = origin
|
||||
const org = lngLatAltToECEF(oLon, oLat, oAlt)
|
||||
const diff = point.clone().sub(org)
|
||||
|
||||
const oLonR = oLon * Math.PI / 180
|
||||
const oLatR = oLat * Math.PI / 180
|
||||
// ENU basis vectors in ECEF
|
||||
const east = new THREE.Vector3(-Math.sin(oLonR), Math.cos(oLonR), 0)
|
||||
const north = new THREE.Vector3(
|
||||
-Math.sin(oLatR) * Math.cos(oLonR),
|
||||
-Math.sin(oLatR) * Math.sin(oLonR),
|
||||
Math.cos(oLatR),
|
||||
)
|
||||
const up = new THREE.Vector3(
|
||||
Math.cos(oLatR) * Math.cos(oLonR),
|
||||
Math.cos(oLatR) * Math.sin(oLonR),
|
||||
Math.sin(oLatR),
|
||||
)
|
||||
|
||||
return new THREE.Vector3(diff.dot(east), diff.dot(north), diff.dot(up))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Three.js Matrix4 that positions and orients a local scene
|
||||
* at the given geographic coordinate (ENU → ECEF transform + heading rotation).
|
||||
*
|
||||
* @param lon Longitude in degrees
|
||||
* @param lat Latitude in degrees
|
||||
* @param alt Altitude in metres above WGS-84 ellipsoid
|
||||
* @param headingDeg Clockwise heading in degrees (0 = North)
|
||||
*/
|
||||
export function buildSplatWorldMatrix(
|
||||
lon: number,
|
||||
lat: number,
|
||||
alt: number,
|
||||
headingDeg: number,
|
||||
): THREE.Matrix4 {
|
||||
const lonR = lon * Math.PI / 180
|
||||
const latR = lat * Math.PI / 180
|
||||
|
||||
// ENU basis vectors in ECEF
|
||||
const east = new THREE.Vector3(-Math.sin(lonR), Math.cos(lonR), 0)
|
||||
const north = new THREE.Vector3(
|
||||
-Math.sin(latR) * Math.cos(lonR),
|
||||
-Math.sin(latR) * Math.sin(lonR),
|
||||
Math.cos(latR),
|
||||
)
|
||||
const up = new THREE.Vector3(
|
||||
Math.cos(latR) * Math.cos(lonR),
|
||||
Math.cos(latR) * Math.sin(lonR),
|
||||
Math.sin(latR),
|
||||
)
|
||||
|
||||
// Apply heading rotation (clockwise from North = negative rotation around Up/Z-ENU)
|
||||
const hRad = -headingDeg * Math.PI / 180
|
||||
const cosH = Math.cos(hRad)
|
||||
const sinH = Math.sin(hRad)
|
||||
|
||||
// Rotated ENU: X' = cosH*East + sinH*North, Y' = -sinH*East + cosH*North, Z' = Up
|
||||
const xAxis = east.clone().multiplyScalar(cosH).addScaledVector(north, sinH)
|
||||
const yAxis = east.clone().multiplyScalar(-sinH).addScaledVector(north, cosH)
|
||||
const zAxis = up.clone()
|
||||
|
||||
const pos = lngLatAltToECEF(lon, lat, alt)
|
||||
|
||||
// Column-major Matrix4 (Three.js): each column is [x, y, z, 0], last col is translation
|
||||
return new THREE.Matrix4().set(
|
||||
xAxis.x, yAxis.x, zAxis.x, pos.x,
|
||||
xAxis.y, yAxis.y, zAxis.y, pos.y,
|
||||
xAxis.z, yAxis.z, zAxis.z, pos.z,
|
||||
0, 0, 0, 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate MapLibre camera altitude above terrain in metres.
|
||||
* Falls back to zoom-based approximation when transform internals are unavailable.
|
||||
*/
|
||||
export function getMapCameraAltitude(map: { getZoom(): number; getCenter(): { lat: number }; transform?: unknown }): number {
|
||||
const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number; altitude?: number } }).transform
|
||||
if (t) {
|
||||
if (typeof t.altitude === 'number') return t.altitude
|
||||
if (t.cameraToCenterDistance && t.pixelsPerMeter) {
|
||||
return t.cameraToCenterDistance / t.pixelsPerMeter
|
||||
}
|
||||
}
|
||||
// Fallback: approximate from zoom (rough but covers our visibility thresholds)
|
||||
const zoom = map.getZoom()
|
||||
const lat = map.getCenter().lat
|
||||
return (40075016.7 * Math.cos(lat * Math.PI / 180)) / Math.pow(2, zoom + 8) * 600
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove MapLibre layers and sources without throwing if the map was already
|
||||
* destroyed (map.remove() sets this.style to undefined internally).
|
||||
*/
|
||||
export function safeRemoveLayers(
|
||||
map: {
|
||||
getLayer: (id: string) => unknown
|
||||
removeLayer: (id: string) => void
|
||||
getSource: (id: string) => unknown
|
||||
removeSource:(id: string) => void
|
||||
},
|
||||
layerIds: string[],
|
||||
sourceIds: string[],
|
||||
) {
|
||||
try {
|
||||
for (const id of layerIds) { if (map.getLayer(id)) map.removeLayer(id) }
|
||||
for (const id of sourceIds) { if (map.getSource(id)) map.removeSource(id) }
|
||||
} catch {
|
||||
// Map was already destroyed before this cleanup ran — safe to ignore.
|
||||
}
|
||||
}
|
||||
12
web/src/maplibre/maplibreContext.ts
Normal file
12
web/src/maplibre/maplibreContext.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Map } from 'maplibre-gl'
|
||||
|
||||
export const MapLibreContext = createContext<Map | null>(null)
|
||||
|
||||
export function useMapLibreMap(): Map {
|
||||
const map = useContext(MapLibreContext)
|
||||
if (!map) {
|
||||
throw new Error('useMapLibreMap must be used inside <MapLibreViewer>')
|
||||
}
|
||||
return map
|
||||
}
|
||||
36
web/src/maplibre/useMapLibreCamera.ts
Normal file
36
web/src/maplibre/useMapLibreCamera.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMapLibreMap } from './maplibreContext'
|
||||
import { useMapStore } from '../store/mapStore'
|
||||
import { getMapCameraAltitude } from './geoUtils'
|
||||
|
||||
/**
|
||||
* Tracks MapLibre camera state (altitude + view bbox) and writes it to mapStore.
|
||||
* Throttled to at most once per 200 ms so expensive API queries are not spammed.
|
||||
*/
|
||||
export function useMapLibreCamera() {
|
||||
const map = useMapLibreMap()
|
||||
const setCameraState = useMapStore((s) => s.setCameraState)
|
||||
|
||||
useEffect(() => {
|
||||
let lastFired = 0
|
||||
const THROTTLE_MS = 200
|
||||
|
||||
function onMove() {
|
||||
const now = Date.now()
|
||||
if (now - lastFired < THROTTLE_MS) return
|
||||
lastFired = now
|
||||
|
||||
const b = map.getBounds()
|
||||
const bbox: [number, number, number, number] = [
|
||||
b.getWest(), b.getSouth(), b.getEast(), b.getNorth(),
|
||||
]
|
||||
const altitude = getMapCameraAltitude(map)
|
||||
setCameraState(altitude, bbox)
|
||||
}
|
||||
|
||||
map.on('move', onMove)
|
||||
onMove() // fire once immediately so store is populated before first render
|
||||
|
||||
return () => { map.off('move', onMove) }
|
||||
}, [map, setCameraState])
|
||||
}
|
||||
@ -1,41 +1,40 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import { useCesiumCamera } from '../cesium/useCesiumCamera'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { useMapLibreCamera } from '../maplibre/useMapLibreCamera'
|
||||
import { useMapStore } from '../store/mapStore'
|
||||
import { fetchSplats } from '../api/splats'
|
||||
import type { BBox } from '../types/geo'
|
||||
import type { SplatMapProperties } from '../types/api'
|
||||
|
||||
// Show splat pins when camera is below this altitude (metres)
|
||||
const SPLAT_VISIBLE_HEIGHT = 50_000
|
||||
const MAX_BBOX_DEGREES = 1.0
|
||||
|
||||
export function SplatLayer() {
|
||||
const viewer = useCesiumViewer()
|
||||
useCesiumCamera()
|
||||
const map = useMapLibreMap()
|
||||
useMapLibreCamera()
|
||||
|
||||
const { bbox, cameraHeight, setLoadedSplats, setActiveSplatId } = useMapStore()
|
||||
const entityMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
|
||||
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map())
|
||||
const lastBboxRef = useRef<BBox | null>(null)
|
||||
|
||||
// Fetch and sync splat entities whenever the bbox changes meaningfully
|
||||
// Fetch and sync splat markers whenever the bbox changes meaningfully
|
||||
useEffect(() => {
|
||||
if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT) {
|
||||
// Clear all splat pins when zoomed out
|
||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||
entityMapRef.current.clear()
|
||||
const bboxTooLarge = bbox && (
|
||||
(bbox[2] - bbox[0]) > MAX_BBOX_DEGREES || (bbox[3] - bbox[1]) > MAX_BBOX_DEGREES
|
||||
)
|
||||
if (!bbox || cameraHeight > SPLAT_VISIBLE_HEIGHT || bboxTooLarge) {
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
setLoadedSplats([])
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid re-fetching if bbox moved less than 0.01° (noise reduction)
|
||||
const last = lastBboxRef.current
|
||||
if (last) {
|
||||
const delta = Math.max(
|
||||
Math.abs(bbox[0] - last[0]),
|
||||
Math.abs(bbox[1] - last[1]),
|
||||
Math.abs(bbox[2] - last[2]),
|
||||
Math.abs(bbox[3] - last[3]),
|
||||
Math.abs(bbox[0] - last[0]), Math.abs(bbox[1] - last[1]),
|
||||
Math.abs(bbox[2] - last[2]), Math.abs(bbox[3] - last[3]),
|
||||
)
|
||||
if (delta < 0.01) return
|
||||
}
|
||||
@ -44,75 +43,48 @@ export function SplatLayer() {
|
||||
fetchSplats(bbox).then((fc) => {
|
||||
const incoming = new Set(fc.features.map((f: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => f.properties.id))
|
||||
|
||||
// Remove entities that are no longer in view
|
||||
entityMapRef.current.forEach((entity, id) => {
|
||||
if (!incoming.has(id)) {
|
||||
viewer.entities.remove(entity)
|
||||
entityMapRef.current.delete(id)
|
||||
}
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!incoming.has(id)) { marker.remove(); markersRef.current.delete(id) }
|
||||
})
|
||||
|
||||
// Add new entities
|
||||
fc.features.forEach((feature: GeoJSON.Feature<GeoJSON.Point, SplatMapProperties>) => {
|
||||
const id = feature.properties.id
|
||||
if (entityMapRef.current.has(id)) return
|
||||
if (markersRef.current.has(id)) return
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates
|
||||
const alt = feature.properties.altitude ?? 0
|
||||
const el = createSplatPinElement()
|
||||
el.addEventListener('click', () => setActiveSplatId(id))
|
||||
|
||||
const entity = viewer.entities.add({
|
||||
id: `splat-${id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
||||
billboard: {
|
||||
image: createSplatPinSvg(),
|
||||
width: 32,
|
||||
height: 32,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
properties: { splatId: id },
|
||||
})
|
||||
entityMapRef.current.set(id, entity)
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([lon, lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(id, marker)
|
||||
})
|
||||
|
||||
setLoadedSplats(fc.features)
|
||||
}).catch(console.error)
|
||||
}, [bbox, cameraHeight]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Wire up entity selection → activeSplatId in store
|
||||
useEffect(() => {
|
||||
const remove = viewer.selectedEntityChanged.addEventListener((entity) => {
|
||||
if (!entity) {
|
||||
setActiveSplatId(null)
|
||||
return
|
||||
}
|
||||
const splatId = entity.properties?.splatId?.getValue()
|
||||
if (splatId) {
|
||||
setActiveSplatId(splatId)
|
||||
}
|
||||
})
|
||||
return () => remove()
|
||||
}, [viewer, setActiveSplatId])
|
||||
|
||||
// Clean up all entities on unmount
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewer.isDestroyed()) return
|
||||
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
|
||||
entityMapRef.current.clear()
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [viewer])
|
||||
}, [])
|
||||
|
||||
return null // no DOM — everything is imperative Cesium entities
|
||||
return null
|
||||
}
|
||||
|
||||
function createSplatPinSvg(): string {
|
||||
const svg = `
|
||||
function createSplatPinElement(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:32px;height:32px;cursor:pointer'
|
||||
el.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="14" r="10" fill="#6366f1" stroke="#fff" stroke-width="2"/>
|
||||
<polygon points="16,28 10,18 22,18" fill="#6366f1"/>
|
||||
<text x="16" y="18" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">3D</text>
|
||||
</svg>
|
||||
`
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`
|
||||
return el
|
||||
}
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import * as THREE from 'three'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { useMapStore } from '../store/mapStore'
|
||||
import { useSplatStore } from '../store/splatStore'
|
||||
import { syncSplatCamera } from './useSplatCamera'
|
||||
import { getSplatDownloadUrl } from './splatLoader'
|
||||
import { buildSplatWorldMatrix } from '../cesium/geoUtils'
|
||||
import { buildSplatWorldMatrix } from '../maplibre/geoUtils'
|
||||
import { fetchSplatDetail } from '../api/splats'
|
||||
|
||||
// Only render the splat when the camera is below this altitude
|
||||
// Only render the splat when the camera is below this altitude (metres)
|
||||
const RENDER_HEIGHT = 500
|
||||
|
||||
export function SplatRenderer() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const splatViewerRef = useRef<unknown>(null)
|
||||
const camerRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera())
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera())
|
||||
const activeSplatId = useMapStore((s) => s.activeSplatId)
|
||||
const cameraHeight = useMapStore((s) => s.cameraHeight)
|
||||
const { setSplatDetail, splatCache } = useSplatStore()
|
||||
|
||||
// Keep canvas dimensions in sync with Cesium canvas
|
||||
// Keep overlay canvas dimensions in sync with the MapLibre canvas
|
||||
useEffect(() => {
|
||||
const cesiumCanvas = viewer.canvas
|
||||
const mapCanvas = map.getCanvas()
|
||||
const overlayCanvas = canvasRef.current
|
||||
if (!overlayCanvas) return
|
||||
|
||||
function syncSize() {
|
||||
overlayCanvas!.width = cesiumCanvas.width
|
||||
overlayCanvas!.height = cesiumCanvas.height
|
||||
camerRef.current.aspect = cesiumCanvas.width / cesiumCanvas.height
|
||||
camerRef.current.updateProjectionMatrix()
|
||||
overlayCanvas!.width = mapCanvas.width
|
||||
overlayCanvas!.height = mapCanvas.height
|
||||
cameraRef.current.aspect = mapCanvas.width / mapCanvas.height
|
||||
cameraRef.current.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
syncSize()
|
||||
const observer = new ResizeObserver(syncSize)
|
||||
observer.observe(cesiumCanvas)
|
||||
observer.observe(mapCanvas)
|
||||
return () => observer.disconnect()
|
||||
}, [viewer])
|
||||
}, [map])
|
||||
|
||||
// Load / unload splat when activeSplatId changes
|
||||
useEffect(() => {
|
||||
@ -52,7 +52,6 @@ export function SplatRenderer() {
|
||||
async function loadSplat() {
|
||||
if (!activeSplatId) return
|
||||
|
||||
// Fetch detail if not cached
|
||||
let detail = splatCache.get(activeSplatId)
|
||||
if (!detail) {
|
||||
detail = await fetchSplatDetail(activeSplatId)
|
||||
@ -65,7 +64,6 @@ export function SplatRenderer() {
|
||||
const url = await getSplatDownloadUrl(activeSplatId)
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamically import the library to keep initial bundle lean
|
||||
const { Viewer: GaussianViewer } = await import('@mkkellogg/gaussian-splats-3d')
|
||||
if (cancelled) return
|
||||
|
||||
@ -78,10 +76,9 @@ export function SplatRenderer() {
|
||||
selfDrivenMode: false,
|
||||
useBuiltInControls: false,
|
||||
renderer: new THREE.WebGLRenderer({ canvas, alpha: true }),
|
||||
camera: camerRef.current,
|
||||
camera: cameraRef.current,
|
||||
})
|
||||
|
||||
// Geo-anchor the splat
|
||||
const [lon, lat] = detail.location.coordinates
|
||||
const alt = detail.altitude ?? 0
|
||||
const heading = detail.heading ?? 0
|
||||
@ -89,15 +86,11 @@ export function SplatRenderer() {
|
||||
|
||||
await gViewer.addSplatScene(url, {
|
||||
progressiveLoad: true,
|
||||
onProgress: () => { /* optional: update progress UI */ },
|
||||
onProgress: () => { /* optional progress UI */ },
|
||||
})
|
||||
|
||||
if (cancelled) {
|
||||
gViewer.dispose()
|
||||
return
|
||||
}
|
||||
if (cancelled) { gViewer.dispose(); return }
|
||||
|
||||
// Apply geo-anchor transform to the loaded scene
|
||||
const scene = gViewer.splatMesh
|
||||
if (scene) {
|
||||
scene.matrixAutoUpdate = false
|
||||
@ -109,34 +102,32 @@ export function SplatRenderer() {
|
||||
}
|
||||
|
||||
loadSplat().catch(console.error)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [activeSplatId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Drive the splat render loop from Cesium's postRender event
|
||||
// Drive the splat render loop from MapLibre's 'render' event
|
||||
useEffect(() => {
|
||||
const remove = viewer.scene.postRender.addEventListener(() => {
|
||||
function onRender() {
|
||||
const gViewer = splatViewerRef.current as import('@mkkellogg/gaussian-splats-3d').Viewer | null
|
||||
if (!gViewer || cameraHeight > RENDER_HEIGHT) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
syncSplatCamera(viewer, camerRef.current, canvas)
|
||||
syncSplatCamera(map, cameraRef.current, canvas)
|
||||
gViewer.update()
|
||||
gViewer.render()
|
||||
})
|
||||
return () => remove()
|
||||
}, [viewer, cameraHeight])
|
||||
}
|
||||
|
||||
map.on('render', onRender)
|
||||
return () => { map.off('render', onRender) }
|
||||
}, [map, cameraHeight])
|
||||
|
||||
function disposeSplatViewer() {
|
||||
const gViewer = splatViewerRef.current as { dispose?: () => void } | null
|
||||
if (gViewer?.dispose) gViewer.dispose()
|
||||
splatViewerRef.current = null
|
||||
|
||||
// Clear the overlay canvas
|
||||
const canvas = canvasRef.current
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
@ -144,7 +135,6 @@ export function SplatRenderer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay canvas portal — sits above the Cesium canvas, no pointer events
|
||||
const overlayCanvas = (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
|
||||
@ -1,41 +1,78 @@
|
||||
import * as Cesium from 'cesium'
|
||||
import type { Map } from 'maplibre-gl'
|
||||
import * as THREE from 'three'
|
||||
import { lngLatAltToECEF } from '../maplibre/geoUtils'
|
||||
|
||||
/**
|
||||
* Synchronise a Three.js PerspectiveCamera to the current Cesium camera.
|
||||
* Synchronise a Three.js PerspectiveCamera to the current MapLibre camera.
|
||||
*
|
||||
* Both cameras work in ECEF space (metres from Earth centre).
|
||||
* The splat scene's Object3D has its matrixWorld set to the ECEF transform
|
||||
* of the capture point (see geoUtils.buildSplatWorldMatrix), so the camera
|
||||
* and the scene are in the same coordinate space.
|
||||
* MapLibre camera state (center, pitch, bearing, altitude) is converted to
|
||||
* an ECEF position + orientation that matches the splat scene, which is also
|
||||
* positioned in ECEF via buildSplatWorldMatrix.
|
||||
*
|
||||
* Call this inside a Cesium scene.postRender listener, before splatViewer.render().
|
||||
* Call this inside a MapLibre 'render' or requestAnimationFrame listener,
|
||||
* before splatViewer.render().
|
||||
*/
|
||||
export function syncSplatCamera(
|
||||
cesiumViewer: Cesium.Viewer,
|
||||
map: Map,
|
||||
threeCamera: THREE.PerspectiveCamera,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
const cam = cesiumViewer.camera
|
||||
const center = map.getCenter()
|
||||
const lon0 = center.lng
|
||||
const lat0 = center.lat
|
||||
const lon0r = lon0 * Math.PI / 180
|
||||
const lat0r = lat0 * Math.PI / 180
|
||||
|
||||
// Position (ECEF, metres)
|
||||
const pos = cam.positionWC
|
||||
threeCamera.position.set(pos.x, pos.y, pos.z)
|
||||
const pitchRad = map.getPitch() * Math.PI / 180
|
||||
const bearingRad = map.getBearing() * Math.PI / 180
|
||||
|
||||
// LookAt target: position + direction
|
||||
const dir = cam.directionWC
|
||||
const target = new THREE.Vector3(pos.x + dir.x, pos.y + dir.y, pos.z + dir.z)
|
||||
// Camera-to-ground distance in metres
|
||||
const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number } }).transform
|
||||
const D = (t?.cameraToCenterDistance && t?.pixelsPerMeter)
|
||||
? t.cameraToCenterDistance / t.pixelsPerMeter
|
||||
: (40075016.7 * Math.cos(lat0r)) / Math.pow(2, map.getZoom() + 8) * 600
|
||||
|
||||
// Up vector
|
||||
const up = cam.upWC
|
||||
threeCamera.up.set(up.x, up.y, up.z)
|
||||
threeCamera.lookAt(target)
|
||||
// Ground altitude at center from terrain (0 if not available)
|
||||
const groundAlt = map.queryTerrainElevation(center) ?? 0
|
||||
|
||||
// FOV and aspect
|
||||
const frustum = cam.frustum as Cesium.PerspectiveFrustum
|
||||
if (frustum.fovy != null) {
|
||||
threeCamera.fov = Cesium.Math.toDegrees(frustum.fovy)
|
||||
}
|
||||
// ENU basis vectors in ECEF at the center point
|
||||
const east = new THREE.Vector3(-Math.sin(lon0r), Math.cos(lon0r), 0)
|
||||
const north = new THREE.Vector3(
|
||||
-Math.sin(lat0r) * Math.cos(lon0r),
|
||||
-Math.sin(lat0r) * Math.sin(lon0r),
|
||||
Math.cos(lat0r),
|
||||
)
|
||||
const up = new THREE.Vector3(
|
||||
Math.cos(lat0r) * Math.cos(lon0r),
|
||||
Math.cos(lat0r) * Math.sin(lon0r),
|
||||
Math.sin(lat0r),
|
||||
)
|
||||
|
||||
// Camera position in ENU relative to the center ground point:
|
||||
// East = −D·sin(pitch)·sin(bearing)
|
||||
// North = −D·sin(pitch)·cos(bearing)
|
||||
// Up = D·cos(pitch)
|
||||
// (camera looks *toward* center, so it is on the opposite side)
|
||||
const eastOff = -D * Math.sin(pitchRad) * Math.sin(bearingRad)
|
||||
const northOff = -D * Math.sin(pitchRad) * Math.cos(bearingRad)
|
||||
const upOff = D * Math.cos(pitchRad)
|
||||
|
||||
const groundECEF = lngLatAltToECEF(lon0, lat0, groundAlt)
|
||||
const camPos = groundECEF.clone()
|
||||
.addScaledVector(east, eastOff)
|
||||
.addScaledVector(north, northOff)
|
||||
.addScaledVector(up, upOff)
|
||||
|
||||
threeCamera.position.copy(camPos)
|
||||
threeCamera.up.copy(up)
|
||||
threeCamera.lookAt(groundECEF)
|
||||
|
||||
// FOV: read from MapLibre transform if available, fall back to 45°
|
||||
const fovDeg = (() => {
|
||||
const fov = (map as { transform?: { fov?: number } }).transform?.fov
|
||||
return fov ? fov * 180 / Math.PI : 45
|
||||
})()
|
||||
threeCamera.fov = fovDeg
|
||||
threeCamera.aspect = canvas.width / canvas.height
|
||||
threeCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
50
web/src/ui/BlenderInput.module.css
Normal file
50
web/src/ui/BlenderInput.module.css
Normal file
@ -0,0 +1,50 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.root:hover .display {
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 64px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.6);
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
text-align: right;
|
||||
}
|
||||
112
web/src/ui/BlenderInput.tsx
Normal file
112
web/src/ui/BlenderInput.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import styles from './BlenderInput.module.css'
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
/** How much the value changes per pixel dragged. Default 0.1 */
|
||||
step?: number
|
||||
/** Decimal places shown. Default 1 */
|
||||
decimals?: number
|
||||
/** Optional suffix shown after the number, e.g. "%" or "m/s²" */
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 4 // px before we treat motion as a drag
|
||||
|
||||
function clamp(v: number, min: number | undefined, max: number | undefined) {
|
||||
if (min !== undefined && v < min) return min
|
||||
if (max !== undefined && v > max) return max
|
||||
return v
|
||||
}
|
||||
|
||||
export function BlenderInput({ value, onChange, min, max, step = 0.1, decimals = 1, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dragState = useRef<{
|
||||
startX: number
|
||||
startValue: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
// ── Focus the real input when we enter edit mode ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
// ── Mouse-down: begin drag tracking ────────────────────────────────────────
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (editing) return
|
||||
e.preventDefault()
|
||||
dragState.current = { startX: e.clientX, startValue: value, moved: false }
|
||||
|
||||
function onMove(me: MouseEvent) {
|
||||
if (!dragState.current) return
|
||||
const dx = me.clientX - dragState.current.startX
|
||||
if (Math.abs(dx) >= DRAG_THRESHOLD) {
|
||||
dragState.current.moved = true
|
||||
const next = clamp(
|
||||
dragState.current.startValue + dx * step,
|
||||
min,
|
||||
max,
|
||||
)
|
||||
onChange(next)
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (!dragState.current) return
|
||||
if (!dragState.current.moved) {
|
||||
// treat as a click → enter edit mode
|
||||
setText(value.toFixed(decimals))
|
||||
setEditing(true)
|
||||
}
|
||||
dragState.current = null
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [editing, value, step, min, max, decimals, onChange])
|
||||
|
||||
// ── Commit text input ───────────────────────────────────────────────────────
|
||||
const commit = useCallback(() => {
|
||||
const parsed = parseFloat(text)
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(clamp(parsed, min, max))
|
||||
}
|
||||
setEditing(false)
|
||||
}, [text, min, max, onChange])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') commit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}, [commit])
|
||||
|
||||
return (
|
||||
<span className={styles.root} onMouseDown={handleMouseDown}>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.display}>
|
||||
{value.toFixed(decimals)}
|
||||
{suffix && <span className={styles.suffix}>{suffix}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -1,78 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import type { Map } from 'maplibre-gl'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import { safeRemoveLayers } from '../maplibre/geoUtils'
|
||||
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
|
||||
tiles: string[]
|
||||
tileSize: 256 | 512
|
||||
opacity: 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,
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
opacity: 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,
|
||||
tiles: ['https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
opacity: 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,
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
opacity: 0.85,
|
||||
},
|
||||
]
|
||||
|
||||
function addOverlay(map: Map, overlay: OverlayDef) {
|
||||
const srcId = `overlay-src-${overlay.id}`
|
||||
const lyrId = `overlay-lyr-${overlay.id}`
|
||||
map.addSource(srcId, { type: 'raster', tiles: overlay.tiles, tileSize: overlay.tileSize })
|
||||
map.addLayer({ id: lyrId, type: 'raster', source: srcId,
|
||||
paint: { 'raster-opacity': overlay.opacity } })
|
||||
}
|
||||
|
||||
function removeOverlay(map: Map, overlay: OverlayDef) {
|
||||
safeRemoveLayers(map,
|
||||
[`overlay-lyr-${overlay.id}`],
|
||||
[`overlay-src-${overlay.id}`],
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayControls() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
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())
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
|
||||
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)
|
||||
removeOverlay(map, overlay)
|
||||
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)
|
||||
addOverlay(map, overlay)
|
||||
next.add(overlay.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up all layers if the component unmounts
|
||||
// Clean up all active overlays on unmount
|
||||
useEffect(() => {
|
||||
const refs = layerRefs.current
|
||||
return () => {
|
||||
refs.forEach((layer) => {
|
||||
if (!viewer.isDestroyed()) viewer.imageryLayers.remove(layer, true)
|
||||
})
|
||||
for (const id of activeRef.current) {
|
||||
const overlay = OVERLAYS.find(o => o.id === id)
|
||||
if (overlay) removeOverlay(map, overlay)
|
||||
}
|
||||
}, [viewer])
|
||||
}
|
||||
}, [map])
|
||||
|
||||
return (
|
||||
<div className={styles.group}>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import * as Cesium from 'cesium'
|
||||
import { useCesiumViewer } from '../cesium/cesiumContext'
|
||||
import { useMapLibreMap } from '../maplibre/maplibreContext'
|
||||
import styles from './SearchBar.module.css'
|
||||
|
||||
interface NominatimResult {
|
||||
@ -12,7 +11,7 @@ interface NominatimResult {
|
||||
}
|
||||
|
||||
export function SearchBar() {
|
||||
const viewer = useCesiumViewer()
|
||||
const map = useMapLibreMap()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<NominatimResult[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -57,11 +56,7 @@ export function SearchBar() {
|
||||
|
||||
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,
|
||||
})
|
||||
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { duration: 1500, padding: 40 })
|
||||
setQuery(result.display_name.split(',')[0])
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import cesium from 'vite-plugin-cesium'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), cesium()],
|
||||
plugins: [react()],
|
||||
|
||||
optimizeDeps: {
|
||||
// Prevent Vite from pre-bundling Cesium — it ships its own worker scripts
|
||||
// that must be loaded from a specific public path, not inlined.
|
||||
exclude: ['cesium'],
|
||||
// Force pre-bundling of @mkkellogg/gaussian-splats-3d and all its CJS
|
||||
// transitive dependencies. The 'pkg > dep' syntax tells Vite's esbuild
|
||||
// step to convert each dep to ESM when reached through that package.
|
||||
// All CJS transitive dependencies of cesium and @mkkellogg/gaussian-splats-3d.
|
||||
// Because cesium is in exclude, Vite won't crawl its deps automatically —
|
||||
// every CJS package it touches must be listed here for ESM conversion.
|
||||
// List generated via: node -e "..." (see vite.config.ts comments above)
|
||||
include: [
|
||||
'@mkkellogg/gaussian-splats-3d',
|
||||
'@mkkellogg/gaussian-splats-3d > mersenne-twister',
|
||||
@ -41,8 +30,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// cesium is handled by vite-plugin-cesium (served from /Cesium/ public path)
|
||||
// and must NOT appear here.
|
||||
maplibre: ['maplibre-gl'],
|
||||
splat: ['@mkkellogg/gaussian-splats-3d', 'three'],
|
||||
},
|
||||
},
|
||||
@ -52,8 +40,6 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Proxy API calls to Django — avoids CORS in development.
|
||||
// Django dev server runs on the host at :8000.
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user