Port to CeliumJS #16
54 changed files with 3792 additions and 2009 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -25,3 +25,10 @@ vite.config.ts.timestamp-*
|
|||
# AI tools
|
||||
.claude
|
||||
tmpclaude*
|
||||
|
||||
# generated from node_modules/cesium by scripts/copy-cesium.js
|
||||
static/cesium/
|
||||
|
||||
# Playwright output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
|
|
|||
157
docs/POLAR_FINDINGS.md
Normal file
157
docs/POLAR_FINDINGS.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Predictor behaviour at the poles
|
||||
|
||||
Measured 2026-08-03 against the full local stack (`./run-stack.py`): Go predictor
|
||||
on :8080 with GFS dataset `2026-08-03T00:00:00Z` (`gfs-0p50-3h`, 130/130 units,
|
||||
2.05 GB), Django on :8000, the Cesium build of this SPA on :5173.
|
||||
|
||||
Reproduce the browser half with:
|
||||
|
||||
```bash
|
||||
npx playwright test tests/e2e/polar.spec.ts --reporter=list
|
||||
```
|
||||
|
||||
That spec requires the real stack (it overrides `baseURL` to `http://localhost:5173`
|
||||
— see "CSRF" below).
|
||||
|
||||
## 1. What the predictor returns near the poles
|
||||
|
||||
Direct `GET /api/v1/prediction`, launch longitude 0.1, 2026-08-03T12:00:00Z,
|
||||
ascent 5 m/s, burst 30 km, descent 5 m/s:
|
||||
|
||||
| launch lat | points | latitude range | longitude range | NaN/Inf | above 85.051129° | frozen |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 62.1234 | 148 | 62.1219 … 62.3342 | 0.100 … 1.737 | 0 | no | no |
|
||||
| 85.0 | 148 | 85.0000 … 85.1535 | 0.100 … 5.560 | 0 | **yes** | no |
|
||||
| 89.5 | 148 | 88.8092 … 89.5000 | 0.100 … 55.922 | 0 | **yes** | no |
|
||||
| 89.99 | 148 | 88.9926 … 89.9900 | 0.100 … 79.279 | 0 | **yes** | no |
|
||||
|
||||
**No NaN, no Inf, no frozen tracks.** Today's polar wind blows southward, so
|
||||
every balloon drifted away from the pole and never reached latitude 90°, where
|
||||
the defects below would trigger. The failure modes are latent, not constant.
|
||||
|
||||
Note the longitude amplification: at 89.99°N the track sweeps 79° of longitude
|
||||
while moving 1° of latitude. That is **physically correct**, not a bug — the
|
||||
parallel at 89.99°N is only ~7 km long, so a few km of drift is a large change
|
||||
in longitude.
|
||||
|
||||
## 2. What the globe now renders
|
||||
|
||||
`tests/e2e/polar.spec.ts` reads back the coordinates Cesium actually holds in
|
||||
the rendered workspace polyline:
|
||||
|
||||
```
|
||||
[89.5N] vertices=148 max=89.50000 min=88.80919
|
||||
[89.99N] vertices=148 max=89.99000 min=88.99262
|
||||
```
|
||||
|
||||
These match the predictor's API output exactly. Under the previous
|
||||
MapLibre renderer every one of these vertices would have been clamped to
|
||||
85.051129° by `@maplibre/geojson-vt`'s `projectY()` and the track would have
|
||||
rendered as a straight line along that parallel.
|
||||
|
||||
Screenshots: `test-results/polar-89.5N.png`, `test-results/polar-89.99N.png`.
|
||||
|
||||
## 3. Known limitation: no basemap above 85.051129°
|
||||
|
||||
The polar cap renders as a featureless surface. Both configured basemaps —
|
||||
OpenStreetMap raster and Esri World Imagery — are Web Mercator tile pyramids,
|
||||
which do not extend past ±85.051129°. The trajectory, markers and bounding
|
||||
boxes draw correctly there (they are geographic vector data, not tiles), but
|
||||
there is no map detail underneath. The same limitation means **Antarctica can
|
||||
never be drawn in full** from these sources; its true extent reaches −90°.
|
||||
|
||||
Fixing this needs a polar-projection basemap (NASA GIBS serves EPSG:3413 for
|
||||
the Arctic and EPSG:3031 for the Antarctic), which is a separate piece of work.
|
||||
|
||||
## 4. Predictor defects found by reading the source
|
||||
|
||||
These live in the `predictor` repo and are **not fixed**. Verified by source
|
||||
reading plus a unit probe run against the real `Axis` geometry (GFS 0p50:
|
||||
lat −90…90 step 0.5, N=361).
|
||||
|
||||
### 4.1 The wind error is swallowed into a zero derivative — worst of the three
|
||||
|
||||
`internal/engine/models.go:85-88`
|
||||
|
||||
```go
|
||||
sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude)
|
||||
if err != nil {
|
||||
return State{} // zero derivative; error discarded
|
||||
}
|
||||
```
|
||||
|
||||
`internal/numerics/grid.go:40` (`Axis.Locate`) is correct — it returns an
|
||||
explicit error rather than reading out of bounds:
|
||||
|
||||
```
|
||||
lat=89 OK lat=90 ERROR: lat=90 out of range
|
||||
lat=89.5 OK lat=90.05 ERROR: lat=90.05 out of range
|
||||
lat=89.99 OK lat=91 ERROR: lat=91 out of range
|
||||
```
|
||||
|
||||
But the caller drops it, so the propagator returns a zero derivative and the
|
||||
balloon **silently freezes horizontally** with no error and no event in
|
||||
`events[]`:
|
||||
|
||||
```
|
||||
lat=89.99 dLat=8.95e-05 dLng=0.5128576395811134
|
||||
lat=90 dLat=0 dLng=0 <-- silently zeroed
|
||||
lat=90.05 dLat=0 dLng=0 <-- silently zeroed
|
||||
lat=91 dLat=0 dLng=0 <-- silently zeroed
|
||||
```
|
||||
|
||||
A plausible-looking wrong answer is more dangerous than a crash.
|
||||
|
||||
### 4.2 Latitude is never bounded, so there is no pole crossing
|
||||
|
||||
`internal/numerics/vec.go:71-73` — `GeoAdd` wraps longitude through `PyMod` but
|
||||
adds latitude unguarded:
|
||||
|
||||
```
|
||||
step 1: lat=89.9500 step 3: lat=90.0500 step 5: lat=90.1500
|
||||
step 2: lat=90.0000 step 4: lat=90.1000 step 6: lat=90.2000
|
||||
```
|
||||
|
||||
The state leaves the sphere. Correct pole crossing is `lat → 180 − lat`,
|
||||
`lng → lng + 180`; that logic does not exist.
|
||||
|
||||
### 4.3 1/cos(lat) divergence against a fixed integration step
|
||||
|
||||
`internal/numerics/vec.go:87`
|
||||
|
||||
```go
|
||||
dLng = degPerRad * u / (r * math.Cos(lat*piOver180))
|
||||
```
|
||||
|
||||
For a 10 m/s eastward wind at 30 km:
|
||||
|
||||
| lat | cos(lat) | dLng °/s | ° per 60 s step |
|
||||
|---|---|---|---|
|
||||
| 0 | 1 | 8.951054359255286e-05 | 0.0054 |
|
||||
| 60 | 0.5000000000000001 | 0.00017902108718510567 | 0.0107 |
|
||||
| 85.051129 | 0.08626673450528127 | 0.0010376020850432562 | 0.062 |
|
||||
| 89.99 | 1.745329251907294e-05 | 5.128576370030803 | 307.7 |
|
||||
| 90 | 6.123233995736757e-17 | 1.461818112044611e+12 | 8.77e+13 |
|
||||
| 90.0000001 | −1.7453291532541063e-09 | −51285.766599190494 | −3.08e+06 |
|
||||
|
||||
At exactly 90° the result is **large but finite, not NaN** — `math.Cos(π/2)` in
|
||||
float64 is `6.123233995736757e-17`, not zero. Past 90° the cosine goes negative
|
||||
and the drift direction inverts.
|
||||
|
||||
`internal/numerics/ode.go:15` integrates with classical fixed-step RK4 — no
|
||||
adaptive step, no error control — so truncation error grows without bound as the
|
||||
derivative stiffens. At 89.999° a single 60 s step advances longitude by more
|
||||
than a full revolution. Scaling the step by `cos(lat)` would bound it.
|
||||
|
||||
## 5. Environment notes
|
||||
|
||||
**CSRF.** `run-stack.py` binds Vite to `127.0.0.1` but never sets
|
||||
`CSRF_TRUSTED_ORIGINS`, whose default (`stratoflights/settings.py:195`) is
|
||||
`http://localhost:5173, http://localhost:8000`. Reaching the app as
|
||||
`127.0.0.1:5173` therefore gets **403 on every POST**, including
|
||||
`/api/predictions/`. Use `localhost:5173`, or add the origin to the env block in
|
||||
`run-stack.py`. (The default value also has a leading space in its second entry
|
||||
after `split(',')`.)
|
||||
|
||||
`ALLOWED_HOSTS` has the same shape: it defaults to `localhost` only, so
|
||||
`curl 127.0.0.1:8000` returns 400 while `curl localhost:8000` returns 200.
|
||||
700
docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
Normal file
700
docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
# CesiumJS Globe Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the MapLibre/Mercator renderer with a CesiumJS globe so trajectories above 85.05° latitude render correctly, enabling verification of predictor behaviour at the poles.
|
||||
|
||||
**Architecture:** The app already isolates the renderer behind the `IMap` interface (`src/lib/map/core.ts`). After the wind layer is deleted, `maplibre-gl` is imported in exactly one file (`src/lib/map/maplibre.ts`) and nothing calls `getRawInstance()`. So the migration is: add `src/lib/map/cesium.ts` implementing the same `IMap`/`Scene` contract, then change one import in `Map.svelte`. MapLibre stays on disk until the Cesium path is proven, then is removed.
|
||||
|
||||
**Tech Stack:** SvelteKit 2 + adapter-static, Svelte 5 runes, TypeScript, Vite 6, CesiumJS 1.129.0.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Node is 18.19.1 and `.npmrc` sets `engine-strict=true`.** Cesium MUST be pinned to `1.129.0` — the last release declaring `node >=18.18.0`. Cesium 1.130.0 requires `node >=20.19.0`; 1.141.0+ requires `node >=22.0.0`. `npm install` will hard-fail (not warn) on a newer version.
|
||||
- Vite stays at 6.x. Vite 7 requires Node 20+.
|
||||
- **No Cesium Ion access token.** Do not use `Cesium.Ion`, `createWorldTerrainAsync`, or the default Ion imagery. Use `UrlTemplateImageryProvider` with the existing OSM/Esri tile URLs and `EllipsoidTerrainProvider`. Any Ion code path fails without a token.
|
||||
- Existing basemap URLs must be preserved verbatim:
|
||||
- osm: `https://a.tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||
- satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
|
||||
- The `IMap`, `Scene`, `Marker`, `MapLayer` interfaces in `src/lib/map/core.ts` are the contract. Do not change their signatures — features depend on them.
|
||||
- Coordinate order convention in this codebase: `LngLatTuple = [lng, lat]`, `LatLngTuple = [lat, lng]`. `LineOptions.coords` is `LatLngTuple[]`. Getting this backwards is the most likely bug in this migration.
|
||||
- Commit after each task.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Delete the wind layer and unused dependencies
|
||||
|
||||
Removes the only `getRawInstance()` consumer, leaving a clean seam for the renderer swap. The user has approved dropping wind entirely.
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/lib/features/wind/ParticleField.ts`, `src/lib/features/wind/WindRenderer.svelte`, `src/lib/features/wind/index.ts`, `src/lib/features/wind/store.ts`
|
||||
- Delete: `src/lib/domain/wind.ts`, `src/lib/api/wind.ts`
|
||||
- Modify: `src/routes/predict/+page.svelte` (remove `WindRenderer` import + usage)
|
||||
- Modify: `src/lib/domain/index.ts`, `src/lib/api/index.ts` (drop wind re-exports)
|
||||
- Modify: `src/lib/features/settings/schema.ts` (remove the wind settings section, ~lines 87-130)
|
||||
- Modify: `src/lib/features/settings/store.ts` (remove `wind.*` defaults if present)
|
||||
- Modify: `src/lib/i18n/locales/en.json`, `src/lib/i18n/locales/ru.json` (remove `settings.wind*` keys)
|
||||
- Modify: `package.json` (remove `@sakitam-gis/maplibre-wind`, `svelte5-chartjs`)
|
||||
|
||||
- [ ] **Step 1: Confirm the blast radius before deleting**
|
||||
|
||||
```bash
|
||||
cd /home/anton/stratoflights/leaflet_svelte
|
||||
grep -rn "features/wind\|\$features/wind\|windCache\|api/wind\|WindRenderer\|WindInterpolator\|windSettings\|domain/wind" src/
|
||||
```
|
||||
Expected: hits only in the files listed above. If anything else appears, add it to the list before proceeding.
|
||||
|
||||
- [ ] **Step 2: Delete the wind files**
|
||||
|
||||
```bash
|
||||
rm -r src/lib/features/wind src/lib/domain/wind.ts src/lib/api/wind.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the references**
|
||||
|
||||
Edit each Modify-listed file to drop wind imports, `<WindRenderer />` usage, wind re-exports, the wind settings section, and the `settings.wind*` i18n keys. `@sakitam-gis/maplibre-wind` and `svelte5-chartjs` are already imported nowhere (verified) — remove them from `package.json` dependencies.
|
||||
|
||||
```bash
|
||||
npm uninstall @sakitam-gis/maplibre-wind svelte5-chartjs
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify nothing dangles**
|
||||
|
||||
```bash
|
||||
grep -rn "wind\|Wind" src/ | grep -viE "window|rewind"
|
||||
npm run check
|
||||
```
|
||||
Expected: no wind references remain; `npm run check` reports 0 errors.
|
||||
|
||||
- [ ] **Step 5: Confirm the seam is clean**
|
||||
|
||||
```bash
|
||||
grep -rn "from 'maplibre-gl'" src/
|
||||
grep -rn "getRawInstance" src/
|
||||
```
|
||||
Expected: `maplibre-gl` imported ONLY in `src/lib/map/maplibre.ts`. `getRawInstance` appears only in `core.ts` (declaration) and `maplibre.ts` (implementation) — no callers.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "refactor: remove wind particle layer and unused deps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Serve Cesium's static assets
|
||||
|
||||
Cesium loads Workers, Assets, Widgets and ThirdParty files at runtime by URL. They must be served from a known base path. `static/` is copied verbatim by adapter-static, so copying there works in both dev and build with zero plugins.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/copy-cesium.js`
|
||||
- Modify: `package.json` (`prepare` script)
|
||||
- Modify: `.gitignore` (ignore the generated `static/cesium/`)
|
||||
- Modify: `src/app.html` (set `CESIUM_BASE_URL`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Cesium assets served at `/cesium/`, and `window.CESIUM_BASE_URL === '/cesium/'` set before any Cesium module loads.
|
||||
|
||||
- [ ] **Step 1: Write the copy script**
|
||||
|
||||
Create `scripts/copy-cesium.js`:
|
||||
|
||||
```js
|
||||
// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
|
||||
// Cesium resolves Workers/Assets/Widgets at runtime via CESIUM_BASE_URL; they
|
||||
// cannot be bundled. Regenerated on `npm install` via the prepare script.
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = join(root, 'node_modules', 'cesium', 'Build', 'Cesium');
|
||||
const dest = join(root, 'static', 'cesium');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.error(`[copy-cesium] missing ${src} — is cesium installed?`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const dir of ['Assets', 'ThirdParty', 'Widgets', 'Workers']) {
|
||||
cpSync(join(src, dir), join(dest, dir), { recursive: true });
|
||||
}
|
||||
console.log('[copy-cesium] assets copied to static/cesium/');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire it into `prepare` and ignore the output**
|
||||
|
||||
In `package.json`, change the `prepare` script to:
|
||||
|
||||
```json
|
||||
"prepare": "svelte-kit sync || echo '' && node scripts/copy-cesium.js"
|
||||
```
|
||||
|
||||
Append to `.gitignore`:
|
||||
|
||||
```
|
||||
# generated from node_modules/cesium by scripts/copy-cesium.js
|
||||
static/cesium/
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Set the base URL before Cesium loads**
|
||||
|
||||
In `src/app.html`, add inside `<head>` before `%sveltekit.head%`:
|
||||
|
||||
```html
|
||||
<script>window.CESIUM_BASE_URL = '/cesium/';</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it and verify**
|
||||
|
||||
```bash
|
||||
node scripts/copy-cesium.js
|
||||
ls static/cesium/
|
||||
du -sh static/cesium/
|
||||
```
|
||||
Expected: `Assets ThirdParty Widgets Workers` present, ~20 MB total.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "build: serve Cesium runtime assets from static/cesium"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement `IMap` on Cesium — camera, events, basemaps
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/map/cesium.ts`
|
||||
- Test: `tests/e2e/globe.spec.ts` (added in Task 5)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `IMap`, `MapInit`, `MapEvent`, `MapEventPayload`, `MapClickEvent`, `Scene` from `./core`; `LatLngTuple`, `LngLatTuple` from `$domain`.
|
||||
- Produces: `export function createCesiumMap(init: MapInit): IMap` — same shape as `createMapLibreMap`, so `Map.svelte` swaps one import.
|
||||
- Produces: `zoomToHeight(zoom: number): number` and `heightToZoom(height: number): number` — the camera-height ↔ zoom-level bridge, exported for tests.
|
||||
|
||||
**Zoom note:** Cesium has no discrete zoom levels; it has camera height. `getZoom`/`setZoom` have **zero consumers outside the map implementation** (verified), so a simple monotonic mapping is sufficient. Use the standard Web-Mercator-equivalent relation at the equator: `height = 40075017 / 2^zoom`, inverted as `zoom = log2(40075017 / height)`.
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
```ts
|
||||
import {
|
||||
Cartesian2,
|
||||
Cartesian3,
|
||||
Color,
|
||||
EllipsoidTerrainProvider,
|
||||
Math as CesiumMath,
|
||||
Rectangle,
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType,
|
||||
UrlTemplateImageryProvider,
|
||||
Viewer,
|
||||
} from 'cesium';
|
||||
import 'cesium/Build/Cesium/Widgets/widgets.css';
|
||||
import type {
|
||||
IMap,
|
||||
MapEvent,
|
||||
MapEventHandler,
|
||||
MapInit,
|
||||
MapClickEvent,
|
||||
Scene as MapScene,
|
||||
} from './core';
|
||||
import type { LatLngTuple, LngLatTuple } from '$domain';
|
||||
import { CesiumScene } from './cesium-scene';
|
||||
|
||||
/** Equatorial circumference in metres — the zoom<->height reference. */
|
||||
const EQUATOR_M = 40075017;
|
||||
|
||||
export function zoomToHeight(zoom: number): number {
|
||||
return EQUATOR_M / Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
export function heightToZoom(height: number): number {
|
||||
return Math.log2(EQUATOR_M / Math.max(height, 1));
|
||||
}
|
||||
|
||||
const IMAGERY: Record<NonNullable<MapInit['baseLayer']>, string> = {
|
||||
osm: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
satellite:
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{reverseY}/{x}',
|
||||
};
|
||||
|
||||
class CesiumMap implements IMap {
|
||||
readonly ready: Promise<void>;
|
||||
private viewer: Viewer;
|
||||
private handler: ScreenSpaceEventHandler;
|
||||
private scenes = new Map<string, CesiumScene>();
|
||||
|
||||
constructor(init: MapInit) {
|
||||
this.viewer = new Viewer(init.container, {
|
||||
// No Ion: explicit imagery + ellipsoid terrain, no token required.
|
||||
baseLayer: false,
|
||||
terrainProvider: new EllipsoidTerrainProvider(),
|
||||
baseLayerPicker: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
navigationInstructionsInitiallyVisible: false,
|
||||
});
|
||||
this.viewer.imageryLayers.addImageryProvider(
|
||||
new UrlTemplateImageryProvider({ url: IMAGERY[init.baseLayer ?? 'osm'], maximumLevel: 19 }),
|
||||
);
|
||||
// Scale/navigation controls: Cesium has no direct equivalents, and the
|
||||
// existing flags only ever hid MapLibre chrome we no longer render.
|
||||
this.setCenter(init.center, init.zoom);
|
||||
this.ready = Promise.resolve();
|
||||
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
|
||||
}
|
||||
|
||||
/** Screen point -> lng/lat on the globe, or null if the click missed the globe. */
|
||||
private pick(position: Cartesian2): { lat: number; lng: number } | null {
|
||||
const ray = this.viewer.camera.getPickRay(position);
|
||||
if (!ray) return null;
|
||||
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
|
||||
if (!hit) return null;
|
||||
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
|
||||
return {
|
||||
lat: CesiumMath.toDegrees(c.latitude),
|
||||
lng: CesiumMath.toDegrees(c.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
|
||||
if (event === 'load') {
|
||||
(handler as MapEventHandler<'load'>)(undefined);
|
||||
return () => {};
|
||||
}
|
||||
if (event === 'click' || event === 'mousemove') {
|
||||
const type =
|
||||
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
|
||||
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
|
||||
const pos = e.position ?? e.endPosition;
|
||||
if (!pos) return;
|
||||
const lngLat = this.pick(pos);
|
||||
if (!lngLat) return; // clicked space, not the globe
|
||||
(handler as MapEventHandler<'click'>)({
|
||||
lngLat,
|
||||
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
|
||||
} as MapClickEvent);
|
||||
};
|
||||
this.handler.setInputAction(cb, type);
|
||||
return () => this.handler.removeInputAction(type);
|
||||
}
|
||||
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
|
||||
const listener = () => {
|
||||
const c = this.viewer.camera.positionCartographic;
|
||||
const zoom = heightToZoom(c.height);
|
||||
if (event === 'move') {
|
||||
(handler as MapEventHandler<'move'>)({
|
||||
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
|
||||
zoom,
|
||||
});
|
||||
} else {
|
||||
(handler as MapEventHandler<'zoom'>)({ zoom });
|
||||
}
|
||||
};
|
||||
this.viewer.camera.changed.addEventListener(listener);
|
||||
return () => this.viewer.camera.changed.removeEventListener(listener);
|
||||
}
|
||||
|
||||
setCenter(pos: LngLatTuple, zoom?: number): void {
|
||||
this.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
pos[0],
|
||||
pos[1],
|
||||
zoomToHeight(zoom ?? this.getZoom()),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
panTo(pos: LngLatTuple, durationMs = 500): void {
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cartesian3.fromDegrees(pos[0], pos[1], this.viewer.camera.positionCartographic.height),
|
||||
duration: durationMs / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
|
||||
if (coords.length === 0) return;
|
||||
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
|
||||
const lats = coords.map((c) => c[0]);
|
||||
const lngs = coords.map((c) => c[1]);
|
||||
const rect = Rectangle.fromDegrees(
|
||||
Math.min(...lngs),
|
||||
Math.min(...lats),
|
||||
Math.max(...lngs),
|
||||
Math.max(...lats),
|
||||
);
|
||||
// A degenerate rectangle (single point) makes Cesium fly to the centre of
|
||||
// the earth; pad it so there is always area to frame.
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
const pad = CesiumMath.toRadians(0.05);
|
||||
rect.west -= pad;
|
||||
rect.east += pad;
|
||||
rect.south -= pad;
|
||||
rect.north += pad;
|
||||
}
|
||||
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
|
||||
void paddingPx; // Cesium frames the rectangle itself; no pixel padding knob.
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return heightToZoom(this.viewer.camera.positionCartographic.height);
|
||||
}
|
||||
|
||||
setZoom(zoom: number): void {
|
||||
const c = this.viewer.camera.positionCartographic;
|
||||
this.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
CesiumMath.toDegrees(c.longitude),
|
||||
CesiumMath.toDegrees(c.latitude),
|
||||
zoomToHeight(zoom),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setCursor(cursor: string | null): void {
|
||||
this.viewer.canvas.style.cursor = cursor ?? '';
|
||||
}
|
||||
|
||||
scene(name: string): MapScene {
|
||||
let s = this.scenes.get(name);
|
||||
if (!s) {
|
||||
s = new CesiumScene(name, this.viewer);
|
||||
this.scenes.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
disposeScene(name: string): void {
|
||||
const s = this.scenes.get(name);
|
||||
if (!s) return;
|
||||
s.dispose();
|
||||
this.scenes.delete(name);
|
||||
}
|
||||
|
||||
getRawInstance(): unknown {
|
||||
return this.viewer;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const s of this.scenes.values()) s.dispose();
|
||||
this.scenes.clear();
|
||||
this.handler.destroy();
|
||||
this.viewer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createCesiumMap(init: MapInit): IMap {
|
||||
return new CesiumMap(init);
|
||||
}
|
||||
```
|
||||
|
||||
Note the Esri URL uses `{reverseY}` — Cesium's `UrlTemplateImageryProvider` template for the ArcGIS `{z}/{y}/{x}` tile order.
|
||||
|
||||
- [ ] **Step 2: Verify it type-checks (Scene not yet written, so expect one error)**
|
||||
|
||||
```bash
|
||||
npm run check 2>&1 | head -20
|
||||
```
|
||||
Expected: errors only about the missing `./cesium-scene` module. Task 4 supplies it.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/map/cesium.ts && git commit -m "feat: Cesium IMap implementation — camera, events, basemaps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement `Scene` on Cesium — lines, circles, markers
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/map/cesium-scene.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Scene`, `MapLayer`, `Marker`, `LineOptions`, `CircleOptions`, `MarkerOptions` from `./core`.
|
||||
- Produces: `export class CesiumScene implements Scene` with constructor `(name: string, viewer: Viewer)` — consumed by `cesium.ts` Task 3.
|
||||
|
||||
**Why this is the whole point:** these entities take `Cartesian3.fromDegrees(lng, lat)` directly. Nothing routes through a Mercator tiler, so latitude 89.99° and 90° render correctly — which is what MapLibre could not do.
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
```ts
|
||||
import {
|
||||
Cartesian2,
|
||||
Cartesian3,
|
||||
Color,
|
||||
Entity,
|
||||
HorizontalOrigin,
|
||||
PolylineDashMaterialProperty,
|
||||
VerticalOrigin,
|
||||
type Viewer,
|
||||
} from 'cesium';
|
||||
import type {
|
||||
CircleOptions,
|
||||
LineOptions,
|
||||
MapLayer,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
Scene,
|
||||
} from './core';
|
||||
|
||||
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on a bad string. */
|
||||
function toColor(css: string | undefined, opacity = 1): Color {
|
||||
try {
|
||||
return Color.fromCssColorString(css ?? '#000').withAlpha(opacity);
|
||||
} catch {
|
||||
return Color.BLACK.withAlpha(opacity);
|
||||
}
|
||||
}
|
||||
|
||||
export class CesiumScene implements Scene {
|
||||
private entities = new Map<string, Entity>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private viewer: Viewer,
|
||||
) {}
|
||||
|
||||
private scopeId(id: string): string {
|
||||
return `${this.name}__${id}`;
|
||||
}
|
||||
|
||||
private add(id: string, entity: Entity): Entity {
|
||||
this.remove(id);
|
||||
const added = this.viewer.entities.add(entity);
|
||||
this.entities.set(id, added);
|
||||
return added;
|
||||
}
|
||||
|
||||
addLine(id: string, options: LineOptions): MapLayer {
|
||||
// LineOptions.coords is [lat, lng][] — Cesium wants a flat lng,lat list.
|
||||
const degrees = options.coords.flatMap((c) => [c[1], c[0]]);
|
||||
const color = toColor(options.color, options.opacity ?? 1);
|
||||
const entity = this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
polyline: {
|
||||
positions: Cartesian3.fromDegreesArray(degrees),
|
||||
width: options.width ?? 3,
|
||||
material: options.dashArray
|
||||
? new PolylineDashMaterialProperty({ color })
|
||||
: color,
|
||||
// Draw on the globe surface without clamping to terrain, and
|
||||
// keep the line visible when it passes behind the horizon.
|
||||
clampToGround: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
void entity;
|
||||
return { id, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||
// radiusPx is screen-space, matching the MapLibre circle-layer semantics;
|
||||
// Cesium's PointGraphics.pixelSize is the direct equivalent.
|
||||
this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
|
||||
point: {
|
||||
pixelSize: (options.radiusPx ?? 5) * 2,
|
||||
color: toColor(options.color, options.opacity ?? 1),
|
||||
outlineColor: toColor(options.strokeColor, 1),
|
||||
outlineWidth: options.strokeWidth ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { id, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
addMarker(id: string, options: MarkerOptions): Marker {
|
||||
const entity = this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1]),
|
||||
...(options.iconUrl
|
||||
? {
|
||||
billboard: {
|
||||
image: options.iconUrl,
|
||||
width: options.iconSize?.[0],
|
||||
height: options.iconSize?.[1],
|
||||
horizontalOrigin: HorizontalOrigin.CENTER,
|
||||
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||
},
|
||||
}
|
||||
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
|
||||
...(options.popupHtml ? { description: options.popupHtml } : {}),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
setLngLat: (pos) => {
|
||||
entity.position = Cartesian3.fromDegrees(pos[0], pos[1]) as never;
|
||||
},
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const e = this.entities.get(id);
|
||||
if (!e) return;
|
||||
this.viewer.entities.remove(e);
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const e of this.entities.values()) this.viewer.entities.remove(e);
|
||||
this.entities.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Type-check**
|
||||
|
||||
```bash
|
||||
npm run check 2>&1 | head -20
|
||||
```
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/map/cesium-scene.ts && git commit -m "feat: Cesium Scene — geodesic lines, points, billboards"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Swap the renderer and get the app green
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/map/Map.svelte:5,44` (import + factory call)
|
||||
- Modify: `src/lib/map/index.ts` (export the Cesium factory)
|
||||
- Create: `tests/e2e/globe.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Swap the factory**
|
||||
|
||||
In `src/lib/map/Map.svelte`, change line 5 from `import { createMapLibreMap } from './maplibre';` to `import { createCesiumMap } from './cesium';`, and line 44 from `map = createMapLibreMap({` to `map = createCesiumMap({`.
|
||||
|
||||
- [ ] **Step 2: Write the pole e2e test**
|
||||
|
||||
Create `tests/e2e/globe.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('globe renders a polar trajectory above the Mercator limit', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto('/predict');
|
||||
// The dev build exposes the raw Cesium Viewer on window._lsvMap.
|
||||
await page.waitForFunction(() => (window as any)._lsvMap?.scene !== undefined, { timeout: 30_000 });
|
||||
|
||||
// Place a polyline crossing 89.99N and read back what Cesium stored.
|
||||
const readback = await page.evaluate(() => {
|
||||
const viewer = (window as any)._lsvMap;
|
||||
const Cesium = (window as any).Cesium;
|
||||
const e = viewer.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([0, 89.0, 30, 89.99, 60, 89.5]),
|
||||
width: 3,
|
||||
},
|
||||
});
|
||||
const carto = Cesium.Cartographic.fromCartesian(e.polyline.positions.getValue()[1]);
|
||||
return Cesium.Math.toDegrees(carto.latitude);
|
||||
});
|
||||
|
||||
// MapLibre's tiler clamped anything above 85.051129; Cesium must not.
|
||||
expect(readback).toBeGreaterThan(85.051129);
|
||||
expect(readback).toBeCloseTo(89.99, 2);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it**
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npx playwright test tests/e2e/globe.spec.ts
|
||||
```
|
||||
Expected: `npm run check` clean; the globe test passes, proving latitude survives past 85.051129°.
|
||||
|
||||
- [ ] **Step 4: Run the existing suite and fix fallout**
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
Expected: `auth`, `settings`, `saved-points` pass unchanged. `smoke`, `track`, `workspaces` may assert on MapLibre-specific DOM (`.maplibregl-*` classes) — update those selectors to the Cesium canvas. Fix each failure; do not delete assertions.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "feat: switch map renderer to CesiumJS globe"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify predictor behaviour at the poles
|
||||
|
||||
The actual objective. The globe is the instrument; this task is the measurement.
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/POLAR_FINDINGS.md`
|
||||
|
||||
- [ ] **Step 1: Bring up the full stack**
|
||||
|
||||
```bash
|
||||
cd /home/anton/stratoflights
|
||||
# predictor already holds a complete 2026-08-03T00:00:00Z dataset in .stack-data/gfs
|
||||
./run-stack.py
|
||||
```
|
||||
Expected: `stack up — predictor :8080 django :8000 svelte http://127.0.0.1:5173 …`. If port 8080 is already held by a standalone predictor, stop that first.
|
||||
|
||||
- [ ] **Step 2: Run polar predictions and record the output**
|
||||
|
||||
```bash
|
||||
for lat in 85 89.5 89.99; do
|
||||
curl -s "http://127.0.0.1:8080/api/v1/prediction?launch_latitude=$lat&launch_longitude=0.1&launch_datetime=2026-08-03T12:00:00Z&ascent_rate=5&burst_altitude=30000&descent_rate=5&launch_altitude=0"
|
||||
done
|
||||
```
|
||||
|
||||
- [ ] **Step 3: View each on the globe**
|
||||
|
||||
Open `http://127.0.0.1:5173/predict`, run the same three launches through the UI, and confirm the trajectory renders as a track over the polar region rather than a line pinned along the 85.05° parallel.
|
||||
|
||||
- [ ] **Step 4: Write up the findings**
|
||||
|
||||
Create `docs/POLAR_FINDINGS.md` recording, for each latitude: whether the track rendered, whether longitude behaved plausibly, and the three predictor defects listed under "Known predictor defects" below with their file:line references.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/POLAR_FINDINGS.md && git commit -m "docs: polar behaviour findings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known predictor defects (out of scope — for a follow-up decision)
|
||||
|
||||
Verified by source reading plus a unit-level probe against the real `Axis` geometry. These live in the `predictor` repo, not this one, and are **not** fixed by this plan:
|
||||
|
||||
1. **`internal/engine/models.go:85-88`** — `WindTransport` swallows the wind-field error and returns `State{}`, a zero derivative. At latitude ≥ 90° the balloon silently freezes horizontally with no error and no event emitted. This is the most dangerous defect: a wrong answer that looks valid.
|
||||
2. **`internal/numerics/vec.go:71-73`** — `GeoAdd` wraps longitude via `PyMod` but never bounds latitude. Latitude walks past 90° (90.05, 90.10, …) off the sphere. Correct pole-crossing behaviour is `lat → 180 − lat`, `lng → lng + 180`.
|
||||
3. **`internal/numerics/vec.go:87`** — `dLng = degPerRad * u / (r * cos(lat))` diverges as `cos(lat) → 0` (0.00089 °/s at the equator → 0.513 °/s at 89.99° → 1.46e12 °/s at exactly 90°; finite, not NaN, because `math.Cos(π/2)` is 6.12e-17). Combined with the **fixed-step RK4** in `internal/numerics/ode.go:15` (no adaptive step, no error control), truncation error grows without bound near the pole. A step size scaled by `cos(lat)` would bound it.
|
||||
|
||||
`internal/numerics/grid.go:40` (`Axis.Locate`) is correct — it returns an explicit `lat=90 out of range` error rather than reading out of bounds. The bug is purely that the caller discards it.
|
||||
133
docs/superpowers/specs/2026-08-03-restricted-area-box-design.md
Normal file
133
docs/superpowers/specs/2026-08-03-restricted-area-box-design.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Restricted-area box: rectangle in kilometres, not in degrees
|
||||
|
||||
**Goal:** the no-fly area filed with the regulator must be a usable rectangle at
|
||||
every latitude, the poles included.
|
||||
|
||||
## Problem
|
||||
|
||||
The area was a rectangle in latitude/longitude, padded by a margin in kilometres.
|
||||
It cannot be made to work near a pole, and the failure is geometric, not a bug:
|
||||
|
||||
> Every meridian passes through the pole, so any lat/lon rectangle that contains
|
||||
> a pole spans all 360 degrees of longitude.
|
||||
|
||||
Measured on a real launch from 89.99 N, 0 E (GFS 2026-08-03 06Z, 5 km margin):
|
||||
|
||||
| Form | Area |
|
||||
| --- | --- |
|
||||
| lat/lon rectangle (south 88.93, north 90, west -180, east 180) | **44 200 km²** |
|
||||
| corridor actually flown, 13.7 × 123.4 km | **1 695 km²** |
|
||||
|
||||
A 26× over-claim. The balloon also sweeps 79 degrees of longitude while
|
||||
descending one degree of latitude, because near a pole a short displacement
|
||||
crosses many meridians — so even without the pole clamp the degree form is wide.
|
||||
|
||||
There is no latitude threshold below which the degree form is safe. Above it the
|
||||
box contains the pole and balloons; below it the box fails to contain the
|
||||
trajectory. The form itself is what fails.
|
||||
|
||||
Rendering made this visible: at the pole the box drew as a circle following a
|
||||
parallel. That was a correct picture of a wrong shape.
|
||||
|
||||
## Decision
|
||||
|
||||
Define the area as a **rectangle in kilometres**, axis-aligned to east/north at
|
||||
its own centre, filed as four lat/lon corners joined by great circles.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Rectangle in degrees plus a circle near the pole.** A circle (centre +
|
||||
radius) is the standard ICAO NOTAM form, but this needs two filing forms and a
|
||||
latitude threshold to switch between them — the crutch this replaces.
|
||||
- **Circle everywhere.** One form, always filable, but a circle around a long
|
||||
corridor claims far more area than a rectangle.
|
||||
- **Rotate the rectangle to the track's heading.** Tighter for a diagonal track,
|
||||
up to 2×, but it puts an azimuth in the filing and the corners stop reading as
|
||||
north/south/east/west. Not taken; revisit only if area pressure appears.
|
||||
|
||||
## Design
|
||||
|
||||
```ts
|
||||
interface BoundingBox {
|
||||
corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple]; // NW, NE, SE, SW
|
||||
centre: LatLngTuple;
|
||||
widthKm: number;
|
||||
heightKm: number;
|
||||
}
|
||||
```
|
||||
|
||||
`computeBoundingBox(path, marginKm)` keeps its signature.
|
||||
|
||||
1. **Local frame.** Earth-centred radial/east/north unit vectors at a point. All
|
||||
three are unit length and orthogonal at every latitude including the poles, so
|
||||
nothing divides by `cos(latitude)`. Same construction as the predictor's
|
||||
integrator, `internal/numerics/spherical.go`, for the same reason.
|
||||
2. **Project** each path point to kilometres east/north by azimuthal
|
||||
equidistant: exact in distance from the origin at any range.
|
||||
3. **Two passes.** The first frame, on the track's mean direction, only locates
|
||||
the box centre; the second frames on that centre, which makes the corners
|
||||
symmetric about it and keeps projection error smallest where the corners are.
|
||||
4. **Pad** the half-extents by the margin and unproject the four corners.
|
||||
|
||||
### Margin is a floor
|
||||
|
||||
A great-circle edge bows **away** from the frame origin relative to its chord in
|
||||
this projection: `y_mid = hy · (1 + ρ² sin²β / 3)`, positive. Check by
|
||||
inspection — the equator in an azimuthal-equidistant projection centred on the
|
||||
pole is a circle at `R·π/2`, while the chord between two of its points 90° apart
|
||||
would sag to `0.707·R·π/2`. So the filed quad **contains** the projected
|
||||
rectangle and the requested clearance is never eaten. No correction is applied.
|
||||
|
||||
(The first draft of this design asserted the opposite sign and specified a
|
||||
sagitta correction. It would have inflated the area for no reason.)
|
||||
|
||||
### The box may cross a pole, and must
|
||||
|
||||
A launch 1.1 km from the pole with a 5 km margin needs coverage 3.9 km past the
|
||||
pole. The north edge therefore passes over it and comes down the far side, which
|
||||
puts the two north corners ~180° apart in longitude — for the measured case,
|
||||
-43.87 and -158.44. Correct, and unreadable from the corners alone, so the panel
|
||||
also reports `width × height @ centre`.
|
||||
|
||||
### Drawing
|
||||
|
||||
`boundingBoxRing` samples 16 points per edge along the great circle (slerp
|
||||
between corner vectors). The drawn shape is then the filed shape and does not
|
||||
depend on the renderer's interpolation mode, and no single segment is long enough
|
||||
to land degenerate on the antimeridian — which is what previously stopped
|
||||
Cesium's render loop with "All attribute lists must have the same number of
|
||||
attributes" in its `splitLongitude` pass.
|
||||
|
||||
## Removed
|
||||
|
||||
`KM_PER_DEG_LAT` and the `cos(latitude)` margin division, the latitude clamps,
|
||||
the `circumpolar` branch, `LineOptions.arc`, and the `ArcType.RHUMB` branch in
|
||||
`cesium-scene.ts` — no parallels remain in the box, so rhumb lines have no
|
||||
remaining caller. Net less code, and no branch on latitude anywhere.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/unit/boundingBox.spec.ts` runs in Node against the pure module:
|
||||
|
||||
- every trajectory point clears all four filed edges by the full margin, measured
|
||||
as distance to the edge's great circle — at 52.2 N over 500 km (where a
|
||||
wrong-signed bow would have been 5.1 km, the whole margin) and on the real
|
||||
polar track;
|
||||
- the polar box is under 3 000 km² where the degree form gave 44 200;
|
||||
- a meridional track of the same length in kilometres gives the same box at
|
||||
52.2 N and at 89.99 N — the test that fails if any latitude branch returns;
|
||||
- the drawn ring closes and no segment spans 90° of longitude.
|
||||
|
||||
`tests/e2e/bbox.spec.ts` covers what only a browser answers: `scene.renderError`
|
||||
stays empty while a polar box is drawn, and the panel reports a corridor-sized
|
||||
area rather than a cap.
|
||||
|
||||
Measured after the change: polar 13.3 × 123.4 km @ 89.4835, 78.3014; mid-latitude
|
||||
61.4 × 68.9 km @ 52.4654, 0.4651, with the northernmost track point clearing the
|
||||
north edge by 4.98 km against 5.0 asked.
|
||||
|
||||
## Not addressed
|
||||
|
||||
- **WGS84.** Everything here uses a spherical earth, R = 6371 km, matching the
|
||||
predictor. The ellipsoid remains deferred there too.
|
||||
- **Altitude.** The box is the lat/lon footprint; it does not encode a ceiling.
|
||||
671
package-lock.json
generated
671
package-lock.json
generated
|
|
@ -8,16 +8,14 @@
|
|||
"name": "app4",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"cesium": "1.129.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"chartjs-plugin-dragdata": "^2.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.6.1",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"svelte5-chartjs": "^1.0.0"
|
||||
"luxon": "^3.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
|
|
@ -45,6 +43,57 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cesium/engine": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-17.0.0.tgz",
|
||||
"integrity": "sha512-bvLWmWVY4QX9rfcx/zfBzA8R1xR8KzmaCDOVL6pFkNZeYmRtt8JN//IICYR3P45lznlcb0Dklw1iCb37t4tvLA==",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@zip.js/zip.js": "^2.7.34",
|
||||
"autolinker": "^4.0.0",
|
||||
"bitmap-sdf": "^1.0.3",
|
||||
"dompurify": "^3.0.2",
|
||||
"draco3d": "^1.5.1",
|
||||
"earcut": "^3.0.0",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"jsep": "^1.3.8",
|
||||
"kdbush": "^4.0.1",
|
||||
"ktx-parse": "^1.0.0",
|
||||
"lerc": "^2.0.0",
|
||||
"mersenne-twister": "^1.1.0",
|
||||
"meshoptimizer": "^0.23.0",
|
||||
"pako": "^2.0.4",
|
||||
"protobufjs": "^7.1.0",
|
||||
"rbush": "3.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"urijs": "^1.19.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cesium/engine/node_modules/@zip.js/zip.js": {
|
||||
"version": "2.7.73",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.73.tgz",
|
||||
"integrity": "sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==",
|
||||
"engines": {
|
||||
"bun": ">=0.7.0",
|
||||
"deno": ">=1.0.0",
|
||||
"node": ">=16.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cesium/widgets": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-12.0.0.tgz",
|
||||
"integrity": "sha512-5Re06LU8lRPEZInHrpOCGBiLoj9VNJ0JJtPrzdnw1qhIDpf3v1W75OJf59k8CCFvUYkZzuTTi8qTVbpzmcbOuw==",
|
||||
"dependencies": {
|
||||
"@cesium/engine": "^17.0.0",
|
||||
"nosleep.js": "^0.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
|
||||
|
|
@ -493,81 +542,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"node_modules/@mapbox/geojson-rewind": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
|
||||
"dependencies": {
|
||||
"get-stream": "^6.0.1",
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"geojson-rewind": "geojson-rewind"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
|
||||
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug=="
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
|
||||
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "20.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
|
||||
"integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^2.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
|
|
@ -598,6 +572,54 @@
|
|||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
|
||||
|
|
@ -858,47 +880,6 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sakitam-gis/maplibre-wind": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@sakitam-gis/maplibre-wind/-/maplibre-wind-2.0.3.tgz",
|
||||
"integrity": "sha512-KeBlh2EJ13+MsFck2l8sKXKz/ogezvnontarSCTmpfzNzB3b9nA+ydzXLFfqqUMrnZwEhsuEG+pKTFMyPv1shg==",
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@sakitam-gis/rbush": "3.1.2",
|
||||
"@sakitam-gis/vis-engine": "^1.5.3",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"wind-gl-core": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"maplibre-gl": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sakitam-gis/rbush": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@sakitam-gis/rbush/-/rbush-3.1.2.tgz",
|
||||
"integrity": "sha512-pnNaLnxFBBMnHgGjFX+h2jkpZQg2vXquvDv1BUKfU72uJzJqPcS8smaLydJqcbXp8p7GruoPrQzUpqYG0MYyIg==",
|
||||
"dependencies": {
|
||||
"quickselect": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sakitam-gis/rbush/node_modules/quickselect": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||
},
|
||||
"node_modules/@sakitam-gis/vis-engine": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@sakitam-gis/vis-engine/-/vis-engine-1.5.3.tgz",
|
||||
"integrity": "sha512-IpuZwi0XRflJiP1mNTwOSjlAJZRCczOuVh6s/feVOpXctiAoSWrAuhK0HVITLpCWAQF1bN6CRKA3LW0z1nCr0g==",
|
||||
"dependencies": {
|
||||
"colord": "^2.9.3",
|
||||
"gl-matrix": "^3.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.18.1",
|
||||
"npm": ">= 6.14.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
|
||||
|
|
@ -997,6 +978,11 @@
|
|||
"svelte": "^4.0.0 || ^5.0.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
|
|
@ -1008,61 +994,25 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
|
||||
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mapbox__point-geometry": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
|
||||
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
|
||||
},
|
||||
"node_modules/@types/mapbox__vector-tile": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
|
||||
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/mapbox__point-geometry": "*",
|
||||
"@types/pbf": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pbf": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vincjo/datatables": {
|
||||
"version": "2.5.0",
|
||||
|
|
@ -1092,6 +1042,17 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/autolinker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz",
|
||||
"integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=10.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
|
@ -1100,6 +1061,11 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/bitmap-sdf": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz",
|
||||
"integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg=="
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||
|
|
@ -1115,6 +1081,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/cesium": {
|
||||
"version": "1.129.0",
|
||||
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.129.0.tgz",
|
||||
"integrity": "sha512-XDEJKLnr8s5/Q/1wcdZVEJCgx+xbgfDzkVKd9OxRZzYttazip6KffusMHexRdExVMPfDcTohuqcuHNxj9CUNFA==",
|
||||
"dependencies": {
|
||||
"@cesium/engine": "^17.0.0",
|
||||
"@cesium/widgets": "^12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
|
|
@ -1170,10 +1148,10 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/colord": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
|
||||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
|
|
@ -1244,6 +1222,19 @@
|
|||
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
|
|
@ -1302,11 +1293,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/exifr": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
|
|
@ -1335,66 +1321,10 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="
|
||||
},
|
||||
"node_modules/global-prefix": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
|
||||
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
|
||||
"dependencies": {
|
||||
"ini": "^4.1.3",
|
||||
"kind-of": "^6.0.3",
|
||||
"which": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
|
||||
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
"node_modules/grapheme-splitter": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
||||
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
|
|
@ -1404,14 +1334,6 @@
|
|||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
|
||||
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
|
|
@ -1420,24 +1342,19 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
|
||||
"node_modules/jsep": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
||||
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
|
@ -1447,11 +1364,26 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ktx-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
|
||||
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz",
|
||||
"integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg=="
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
||||
|
|
@ -1468,53 +1400,15 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
||||
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^0.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/geojson-vt": "3.2.5",
|
||||
"@types/mapbox__point-geometry": "^0.1.4",
|
||||
"@types/mapbox__vector-tile": "^1.3.4",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"earcut": "^3.0.0",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"global-prefix": "^4.0.0",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^3.3.0",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0",
|
||||
"vt-pbf": "^3.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
"node_modules/mersenne-twister": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
|
||||
"integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz",
|
||||
"integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
|
|
@ -1540,11 +1434,6 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
|
@ -1563,17 +1452,25 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
|
||||
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
|
||||
"dependencies": {
|
||||
"ieee754": "^1.1.12",
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
"node_modules/nosleep.js": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
||||
"integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
|
|
@ -1665,20 +1562,40 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
|
||||
"node_modules/rbush": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
||||
"dependencies": {
|
||||
"quickselect": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
|
||||
"node_modules/rbush/node_modules/quickselect": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
|
|
@ -1693,14 +1610,6 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.39.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
|
||||
|
|
@ -1740,11 +1649,6 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
|
|
@ -1786,14 +1690,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.34.8",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.8.tgz",
|
||||
|
|
@ -1841,15 +1737,6 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte5-chartjs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte5-chartjs/-/svelte5-chartjs-1.0.0.tgz",
|
||||
"integrity": "sha512-SMk+D5ECbsoeFurKE/Nr9sqD4H3WqZkQ4eLxwchDSh8gu7YSGN3ASXYCz9kzFhrH2QGQYpebHwLIMHg7FOI/7A==",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^3.5.0 || ^4.0.0",
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
|
|
@ -1866,10 +1753,18 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
|
|
@ -1880,6 +1775,11 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
|
|
@ -1896,8 +1796,12 @@
|
|||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
|
||||
},
|
||||
"node_modules/urijs": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz",
|
||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
|
|
@ -1987,53 +1891,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vt-pbf": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
|
||||
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "0.1.0",
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
"pbf": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
|
||||
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
|
||||
"dependencies": {
|
||||
"isexe": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wind-gl-core": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wind-gl-core/-/wind-gl-core-2.0.2.tgz",
|
||||
"integrity": "sha512-EUnUQsbucaPCFns7p6BlPE5xXiXQpb2hXMmE4t/FG4W+rKlYHjtIMWzM0wAD4M6g4Wg6JzSft7SGocPJAqjssA==",
|
||||
"dependencies": {
|
||||
"@sakitam-gis/vis-engine": "^1.5.3",
|
||||
"earcut": "^2.2.4",
|
||||
"wind-gl-worker": "2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/wind-gl-core/node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="
|
||||
},
|
||||
"node_modules/wind-gl-worker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wind-gl-worker/-/wind-gl-worker-2.0.2.tgz",
|
||||
"integrity": "sha512-uEMHjQtX5w+Kn+MT0RWGyYYqou6brZMe9BMOYAqoJh74tKGpuBx0+i+4J2XppAZmD8r7KYn/UvhjGHfpOq0UlQ==",
|
||||
"dependencies": {
|
||||
"exifr": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -7,7 +7,7 @@
|
|||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"prepare": "svelte-kit sync || echo '' ; node scripts/copy-cesium.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"test:e2e": "playwright test",
|
||||
|
|
@ -28,15 +28,16 @@
|
|||
"vite": "^6.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"cesium": "1.129.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"chartjs-plugin-dragdata": "^2.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.6.1",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"svelte5-chartjs": "^1.0.0"
|
||||
"luxon": "^3.6.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@zip.js/zip.js": "2.7.73"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { defineConfig, devices } from '@playwright/test';
|
|||
* npm run test:e2e:ui # Playwright UI mode
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node).
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: false, // the mock plugin writes to shared JSON files on disk
|
||||
|
|
|
|||
24
scripts/copy-cesium.js
Normal file
24
scripts/copy-cesium.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
|
||||
// Cesium fetches Workers/Assets/Widgets/ThirdParty at runtime by URL (see
|
||||
// CESIUM_BASE_URL in src/app.html); they cannot be bundled by Vite. static/ is
|
||||
// copied verbatim in both dev and build, so this needs no Vite plugin.
|
||||
// Regenerated on every `npm install` via the prepare script.
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = join(root, 'node_modules', 'cesium', 'Build', 'Cesium');
|
||||
const dest = join(root, 'static', 'cesium');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.error(`[copy-cesium] missing ${src} — is cesium installed?`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const dir of ['Assets', 'ThirdParty', 'Widgets', 'Workers']) {
|
||||
cpSync(join(src, dir), join(dest, dir), { recursive: true });
|
||||
}
|
||||
console.log('[copy-cesium] assets copied to static/cesium/');
|
||||
23
src/app.css
23
src/app.css
|
|
@ -9,7 +9,7 @@
|
|||
* Global application styles.
|
||||
*
|
||||
* Keep this file focused on cross-feature concerns: the navbar chrome, the
|
||||
* panel-container geometry, and overrides for third-party libs (MapLibre,
|
||||
* panel-container geometry, and overrides for third-party libs (Cesium,
|
||||
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
|
||||
*/
|
||||
|
||||
|
|
@ -93,27 +93,6 @@ body {
|
|||
right: var(--panel-left);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
|
||||
border-radius: var(--bs-border-radius) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
|
||||
border-radius: var(--bs-border-radius) !important;
|
||||
color: var(--bs-body-color);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
opacity: var(--bs-backdrop-opacity) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Cesium resolves Workers/Assets/Widgets at runtime from this base.
|
||||
Populated by scripts/copy-cesium.js; must be set before Cesium loads. -->
|
||||
<script>
|
||||
window.CESIUM_BASE_URL = '/cesium/';
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -3,5 +3,4 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
|||
export { pointsApi } from './points';
|
||||
export { profilesApi } from './profiles';
|
||||
export { scenariosApi } from './scenarios';
|
||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||
export { windApi, type WindFieldParams } from './wind';
|
||||
export { predictionsApi, buildLaunchDateTime } from './predictions';
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
import { api } from './client';
|
||||
import type { FlightParameters, RawPrediction } from '$domain';
|
||||
|
||||
/**
|
||||
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
|
||||
* Round down to the most recent available slot.
|
||||
*/
|
||||
export function getLatestDataset(now: Date = new Date()): string {
|
||||
// const rounded = new Date(now);
|
||||
// rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||
// rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||
// return rounded.toISOString();
|
||||
return "2025-04-06T00:00:00Z";
|
||||
}
|
||||
|
||||
export function buildLaunchDateTime(date: string, time: string): string {
|
||||
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
||||
return new Date(`${date}T${fullTime}Z`).toISOString();
|
||||
|
|
@ -24,11 +12,17 @@ export interface PredictionResponse {
|
|||
|
||||
export const predictionsApi = {
|
||||
run: (params: FlightParameters, launchDateTime: string) => {
|
||||
const payload: FlightParameters & { launch_datetime: string } = {
|
||||
...params,
|
||||
dataset: params.dataset || getLatestDataset(),
|
||||
// `dataset` carries only what the operator actually chose. It used to fall
|
||||
// back to a client-side guess at which GFS run the server holds — and the
|
||||
// guess had degenerated into a hardcoded 2025-04-06, over a year stale. The
|
||||
// client cannot know which runs are stored; the predictor refuses one it
|
||||
// does not have, so an unset value must stay unset and let the server pick.
|
||||
const { dataset, ...rest } = params;
|
||||
const payload = {
|
||||
...rest,
|
||||
...(dataset ? { dataset } : {}),
|
||||
launch_datetime: launchDateTime,
|
||||
};
|
||||
} as FlightParameters & { launch_datetime: string };
|
||||
if (payload.start_point === -1) delete payload.start_point;
|
||||
return api.post<PredictionResponse>('/predictions/', payload);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* Client for the predictor's wind-visualization endpoints.
|
||||
*
|
||||
* These endpoints live on the predictor service (default 127.0.0.1:8080),
|
||||
* not on the Django backend, so they bypass the shared `api` client and
|
||||
* fetch directly. No CSRF or session cookies are needed.
|
||||
*
|
||||
* Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address.
|
||||
*/
|
||||
|
||||
import type { WindField, WindMeta } from '$domain';
|
||||
|
||||
const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export interface WindFieldParams {
|
||||
altitude?: number;
|
||||
step?: number;
|
||||
time?: string;
|
||||
min_lat?: number;
|
||||
max_lat?: number;
|
||||
min_lng?: number;
|
||||
max_lng?: number;
|
||||
}
|
||||
|
||||
async function predictorFetch<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
const q = new URLSearchParams();
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) q.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const qs = q.toString();
|
||||
const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const windApi = {
|
||||
field(params: WindFieldParams = {}): Promise<WindField> {
|
||||
return predictorFetch<WindField>('/api/v1/wind/field', {
|
||||
altitude: params.altitude,
|
||||
step: params.step,
|
||||
time: params.time,
|
||||
min_lat: params.min_lat,
|
||||
max_lat: params.max_lat,
|
||||
min_lng: params.min_lng,
|
||||
max_lng: params.max_lng,
|
||||
});
|
||||
},
|
||||
|
||||
meta(): Promise<WindMeta> {
|
||||
return predictorFetch<WindMeta>('/api/v1/wind/meta');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,26 +1,134 @@
|
|||
import type { LatLngTuple } from './geo';
|
||||
|
||||
/**
|
||||
* Axis-aligned geographic bounding box around a flight path, with an optional
|
||||
* margin so recovery teams get a box that clears the trajectory by a set
|
||||
* distance rather than hugging its extreme points.
|
||||
* The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
|
||||
* to east/north at its own centre, with four lat/lon corners joined by great
|
||||
* circles.
|
||||
*
|
||||
* Deliberately not a rectangle in degrees. That form cannot be made to work near
|
||||
* a pole: every meridian passes through the pole, so any lat/lon rectangle that
|
||||
* contains one spans all 360 degrees of longitude. On a real launch from 89.99 N
|
||||
* it produced the entire cap north of 88.93 — 44 200 km^2 — in place of the
|
||||
* 13.7 x 123.4 km corridor the balloon actually flies. There is no latitude
|
||||
* threshold below which the degree form is safe: it either contains the pole and
|
||||
* balloons, or it fails to contain the trajectory.
|
||||
*
|
||||
* Working in kilometres removes the whole class of problem rather than the
|
||||
* instance. Nothing here divides by cos(latitude), clamps to a pole, or branches
|
||||
* on how far north it is.
|
||||
*/
|
||||
export interface BoundingBox {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
/**
|
||||
* The filed corners, joined by great circles, in the order the operator reads
|
||||
* them: NW, NE, SE, SW of the centre's own east/north frame.
|
||||
*/
|
||||
corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple];
|
||||
centre: LatLngTuple;
|
||||
/** East-west size in km, including the margin on both sides. */
|
||||
widthKm: number;
|
||||
/** North-south size in km, including the margin on both sides. */
|
||||
heightKm: number;
|
||||
}
|
||||
|
||||
/** Default clearance (km) between the box edge and the nearest trajectory point. */
|
||||
export const DEFAULT_BBOX_MARGIN_KM = 5;
|
||||
|
||||
// Mean length of one degree of latitude. Longitude degrees shrink toward the
|
||||
// poles, handled below via cos(latitude).
|
||||
const KM_PER_DEG_LAT = 111.32;
|
||||
const R_KM = 6371;
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
type Vec = [number, number, number];
|
||||
|
||||
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const clamp1 = (v: number) => (v > 1 ? 1 : v < -1 ? -1 : v);
|
||||
|
||||
function toVec([lat, lng]: LatLngTuple): Vec {
|
||||
const p = lat * D2R;
|
||||
const l = lng * D2R;
|
||||
return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
|
||||
}
|
||||
|
||||
function toLatLng(v: Vec): LatLngTuple {
|
||||
return [Math.asin(clamp1(v[2])) / D2R, Math.atan2(v[1], v[0]) / D2R];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
|
||||
* A local frame: the point itself plus earth-centred east and north unit vectors.
|
||||
*
|
||||
* All three are unit length and mutually orthogonal at every latitude, the poles
|
||||
* included — at 90 N, east is (-sin l, cos l, 0) and north is (-cos l, -sin l, 0),
|
||||
* both still unit. This is the same construction the predictor's integrator uses
|
||||
* (internal/numerics/spherical.go) and for the same reason: it is where the
|
||||
* cos(latitude) singularity would otherwise live.
|
||||
*/
|
||||
function frame(origin: Vec) {
|
||||
const [lat, lng] = toLatLng(origin);
|
||||
const p = lat * D2R;
|
||||
const l = lng * D2R;
|
||||
return {
|
||||
origin,
|
||||
east: [-Math.sin(l), Math.cos(l), 0] as Vec,
|
||||
north: [-Math.sin(p) * Math.cos(l), -Math.sin(p) * Math.sin(l), Math.cos(p)] as Vec,
|
||||
};
|
||||
}
|
||||
|
||||
type Frame = ReturnType<typeof frame>;
|
||||
|
||||
/**
|
||||
* Azimuthal equidistant offsets of `p` from the frame origin, in km east and
|
||||
* north. Distance from the origin is exact at any range; only the shape of
|
||||
* something far from the origin is distorted, and a flight is never far.
|
||||
*/
|
||||
function project(f: Frame, p: Vec): [number, number] {
|
||||
const e = dot(p, f.east);
|
||||
const n = dot(p, f.north);
|
||||
const t = Math.hypot(e, n);
|
||||
if (t === 0) return [0, 0]; // p is the origin itself, or its antipode
|
||||
const r = R_KM * Math.acos(clamp1(dot(p, f.origin)));
|
||||
return [(r * e) / t, (r * n) / t];
|
||||
}
|
||||
|
||||
/** Inverse of project: walk `x` km east and `y` km north of the origin. */
|
||||
function unproject(f: Frame, x: number, y: number): Vec {
|
||||
const r = Math.hypot(x, y);
|
||||
if (r === 0) return f.origin;
|
||||
const a = r / R_KM;
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
return [0, 1, 2].map(
|
||||
(i) => f.origin[i] * c + ((x / r) * f.east[i] + (y / r) * f.north[i]) * s,
|
||||
) as Vec;
|
||||
}
|
||||
|
||||
/** Centre and half-sizes, in km, of the smallest axis-aligned box in this frame. */
|
||||
function extent(f: Frame, points: Vec[]) {
|
||||
let xMin = Infinity;
|
||||
let xMax = -Infinity;
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
const [x, y] = project(f, p);
|
||||
if (x < xMin) xMin = x;
|
||||
if (x > xMax) xMax = x;
|
||||
if (y < yMin) yMin = y;
|
||||
if (y > yMax) yMax = y;
|
||||
}
|
||||
return {
|
||||
cx: (xMin + xMax) / 2,
|
||||
cy: (yMin + yMax) / 2,
|
||||
hx: (xMax - xMin) / 2,
|
||||
hy: (yMax - yMin) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mean direction of the points. Falls back to the first point if they cancel. */
|
||||
function centroid(points: Vec[]): Vec {
|
||||
const sum = points.reduce<Vec>((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]);
|
||||
const len = Math.hypot(...sum);
|
||||
return len < 1e-9 ? points[0] : (sum.map((c) => c / len) as Vec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the restricted area around a flight path, clearing it by `marginKm`.
|
||||
* Returns null for an empty path (callers treat that as "nothing to draw").
|
||||
*/
|
||||
export function computeBoundingBox(
|
||||
|
|
@ -29,52 +137,71 @@ export function computeBoundingBox(
|
|||
): BoundingBox | null {
|
||||
if (path.length === 0) return null;
|
||||
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [lat, lng] of path) {
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
if (lng < west) west = lng;
|
||||
if (lng > east) east = lng;
|
||||
}
|
||||
|
||||
const points = path.map(toVec);
|
||||
const margin = Math.max(0, marginKm);
|
||||
const dLat = margin / KM_PER_DEG_LAT;
|
||||
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
|
||||
// Size the longitude margin at the latitude nearest a pole so the box never
|
||||
// comes in tighter than requested along the whole band.
|
||||
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
|
||||
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
|
||||
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
|
||||
|
||||
// Two passes. The first, framed on the track's mean direction, only locates
|
||||
// the box centre; the second frames on that centre, which makes the four
|
||||
// corners symmetric about it and keeps the projection error smallest where
|
||||
// the corners actually are.
|
||||
const first = frame(centroid(points));
|
||||
const rough = extent(first, points);
|
||||
const f = frame(unproject(first, rough.cx, rough.cy));
|
||||
const { cx, cy, hx, hy } = extent(f, points);
|
||||
|
||||
const ex = hx + margin;
|
||||
const ey = hy + margin;
|
||||
// A great-circle edge bows away from the frame origin relative to its chord
|
||||
// in this projection, so the filed quad contains the box measured here. The
|
||||
// margin is a floor, never eaten.
|
||||
const corner = (sx: number, sy: number) => toLatLng(unproject(f, cx + sx * ex, cy + sy * ey));
|
||||
|
||||
return {
|
||||
south: south - dLat,
|
||||
north: north + dLat,
|
||||
west: west - dLng,
|
||||
east: east + dLng,
|
||||
corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
|
||||
centre: toLatLng(unproject(f, cx, cy)),
|
||||
widthKm: 2 * ex,
|
||||
heightKm: 2 * ey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
|
||||
/** Samples per edge when drawing. Keeps segments short enough for any renderer. */
|
||||
const RING_STEPS_PER_EDGE = 16;
|
||||
|
||||
/** Point a fraction `t` along the great circle from `a` to `b`. */
|
||||
function slerp(a: Vec, b: Vec, t: number): Vec {
|
||||
const w = Math.acos(clamp1(dot(a, b)));
|
||||
const s = Math.sin(w);
|
||||
if (s < 1e-12) return a;
|
||||
const wa = Math.sin((1 - t) * w) / s;
|
||||
const wb = Math.sin(t * w) / s;
|
||||
return [0, 1, 2].map((i) => a[i] * wa + b[i] * wb) as Vec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed ring for drawing the box, sampled along each great-circle edge.
|
||||
*
|
||||
* Corners alone are not enough. The drawn shape would then depend on the
|
||||
* renderer's interpolation mode rather than on the filed geometry, and a long
|
||||
* single segment landing on the antimeridian is what previously stopped Cesium's
|
||||
* render loop: its splitLongitude pass emitted mismatched attribute lists and
|
||||
* threw "All attribute lists must have the same number of attributes". Short
|
||||
* explicit samples have neither problem.
|
||||
*/
|
||||
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
|
||||
return [
|
||||
[box.south, box.west],
|
||||
[box.north, box.west],
|
||||
[box.north, box.east],
|
||||
[box.south, box.east],
|
||||
[box.south, box.west],
|
||||
];
|
||||
const v = box.corners.map(toVec);
|
||||
const ring: LatLngTuple[] = [];
|
||||
for (let e = 0; e < 4; e++) {
|
||||
const from = v[e];
|
||||
const to = v[(e + 1) % 4];
|
||||
for (let i = 0; i < RING_STEPS_PER_EDGE; i++) {
|
||||
ring.push(toLatLng(slerp(from, to, i / RING_STEPS_PER_EDGE)));
|
||||
}
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
|
||||
export function formatBoundingBox(box: BoundingBox): string {
|
||||
const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
return [
|
||||
fmt(box.north, box.west),
|
||||
fmt(box.north, box.east),
|
||||
fmt(box.south, box.east),
|
||||
fmt(box.south, box.west),
|
||||
].join('\n');
|
||||
return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n');
|
||||
}
|
||||
|
|
|
|||
148
src/lib/domain/export.ts
Normal file
148
src/lib/domain/export.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { Prediction } from './prediction';
|
||||
|
||||
/**
|
||||
* Serialize a prediction for download.
|
||||
*
|
||||
* Pure string builders — no DOM, no fetch — so they stay testable and the
|
||||
* component only has to hand the result to a Blob.
|
||||
*
|
||||
* Note the coordinate order differs by format, which is the usual source of
|
||||
* silently mirrored tracks: CSV/JSON are latitude-first (matching the API and
|
||||
* the rest of this codebase), KML is longitude-first (per the OGC spec).
|
||||
*/
|
||||
|
||||
export type ExportFormat = 'JSON' | 'CSV' | 'KML';
|
||||
|
||||
export const EXPORT_FORMATS: ExportFormat[] = ['JSON', 'CSV', 'KML'];
|
||||
|
||||
const MIME: Record<ExportFormat, string> = {
|
||||
JSON: 'application/json',
|
||||
CSV: 'text/csv',
|
||||
KML: 'application/vnd.google-earth.kml+xml',
|
||||
};
|
||||
|
||||
const EXT: Record<ExportFormat, string> = { JSON: 'json', CSV: 'csv', KML: 'kml' };
|
||||
|
||||
interface Row {
|
||||
datetime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude: number;
|
||||
}
|
||||
|
||||
/** Flatten flight_path + its parallel timestamps into plain rows. */
|
||||
function rows(p: Prediction): Row[] {
|
||||
return p.flight_path.map((c, i) => ({
|
||||
datetime: new Date(p.timestamps[i]).toISOString(),
|
||||
latitude: c[0],
|
||||
longitude: c[1],
|
||||
altitude: c.length === 3 ? c[2] : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function pointOut(pt: Prediction['launch']) {
|
||||
return {
|
||||
latitude: pt.latlng.lat,
|
||||
longitude: pt.latlng.lng,
|
||||
altitude: pt.latlng.alt ?? 0,
|
||||
datetime: pt.datetime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function predictionToJson(p: Prediction): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
profile: p.profile,
|
||||
flight_time: p.flight_time,
|
||||
launch: pointOut(p.launch),
|
||||
burst: pointOut(p.burst),
|
||||
landing: pointOut(p.landing),
|
||||
trajectory: rows(p),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function predictionToCsv(p: Prediction): string {
|
||||
const head = 'datetime,latitude,longitude,altitude';
|
||||
const body = rows(p).map(
|
||||
(r) => `${r.datetime},${r.latitude},${r.longitude},${r.altitude}`,
|
||||
);
|
||||
return [head, ...body].join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Minimal XML text escaping for the few interpolated strings. */
|
||||
function xml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function predictionToKml(p: Prediction): string {
|
||||
// lon,lat,alt — KML's order, the reverse of everywhere else here.
|
||||
const track = rows(p)
|
||||
.map((r) => `${r.longitude},${r.latitude},${r.altitude}`)
|
||||
.join('\n\t\t\t\t');
|
||||
|
||||
const mark = (name: string, pt: Prediction['launch']) => `
|
||||
<Placemark>
|
||||
<name>${name}</name>
|
||||
<description>${xml(pt.datetime.toISOString())} — ${pt.latlng.alt ?? 0} m</description>
|
||||
<Point>
|
||||
<altitudeMode>absolute</altitudeMode>
|
||||
<coordinates>${pt.latlng.lng},${pt.latlng.lat},${pt.latlng.alt ?? 0}</coordinates>
|
||||
</Point>
|
||||
</Placemark>`;
|
||||
|
||||
// The track Placemark comes first so the flight line is the document's
|
||||
// primary feature rather than one of the event markers.
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>${xml(`Flight ${p.launch.datetime.toISOString()}`)}</name>
|
||||
<Style id="track">
|
||||
<LineStyle><color>ff0000ff</color><width>2</width></LineStyle>
|
||||
</Style>
|
||||
<Placemark>
|
||||
<name>Flight path</name>
|
||||
<styleUrl>#track</styleUrl>
|
||||
<LineString>
|
||||
<!-- absolute: a balloon track is not a ground feature; without this
|
||||
Google Earth drapes the 30 km arc onto the terrain. -->
|
||||
<altitudeMode>absolute</altitudeMode>
|
||||
<coordinates>
|
||||
${track}
|
||||
</coordinates>
|
||||
</LineString>
|
||||
</Placemark>${mark('Launch', p.launch)}${mark('Burst', p.burst)}${mark('Landing', p.landing)}
|
||||
</Document>
|
||||
</kml>
|
||||
`;
|
||||
}
|
||||
|
||||
export function serializePrediction(p: Prediction, format: ExportFormat): string {
|
||||
switch (format) {
|
||||
case 'CSV':
|
||||
return predictionToCsv(p);
|
||||
case 'KML':
|
||||
return predictionToKml(p);
|
||||
default:
|
||||
return predictionToJson(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function exportMimeType(format: ExportFormat): string {
|
||||
return MIME[format];
|
||||
}
|
||||
|
||||
/** e.g. `prediction-2026-08-03T1200Z-89.0N-68.0E.csv` */
|
||||
export function exportFilename(p: Prediction, format: ExportFormat): string {
|
||||
const t = p.launch.datetime.toISOString().slice(0, 16).replace(/[:-]/g, '').replace('T', 'T');
|
||||
const { lat, lng } = p.launch.latlng;
|
||||
const ns = `${Math.abs(lat).toFixed(1)}${lat >= 0 ? 'N' : 'S'}`;
|
||||
const ew = `${Math.abs(lng).toFixed(1)}${lng >= 0 ? 'E' : 'W'}`;
|
||||
return `prediction-${t}Z-${ns}-${ew}.${EXT[format]}`;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Geographic primitives used by map layers and predictions.
|
||||
*
|
||||
* LngLat convention matches MapLibre (longitude first) for on-map work;
|
||||
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
|
||||
* LngLat convention is longitude-first for on-map work, matching Cesium's
|
||||
* Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
|
||||
* legacy Leaflet-era code paths.
|
||||
*/
|
||||
|
||||
export interface LatLng {
|
||||
|
|
@ -11,6 +12,45 @@ export interface LatLng {
|
|||
alt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pole. This is geographic reality, not a workaround.
|
||||
*
|
||||
* An earlier revision capped launches at 89.999° because the predictor's
|
||||
* longitude rate went as 1/cos(lat) against a fixed step and became
|
||||
* unreproducible near the pole. That formulation is gone — it now integrates
|
||||
* along great circles and handles 90° exactly — so the cap is the real limit.
|
||||
*/
|
||||
export const MAX_LAUNCH_LATITUDE = 90;
|
||||
|
||||
/**
|
||||
* Constrain a latitude to ±90. NaN passes through so a half-typed input does
|
||||
* not jump under the user's cursor.
|
||||
*
|
||||
* Latitude clamps rather than wraps: a pole is a barrier, and 91°N is not a
|
||||
* place. Longitude is the opposite case — see wrapLongitude.
|
||||
*/
|
||||
export function clampLaunchLatitude(lat: number): number {
|
||||
if (!Number.isFinite(lat)) return lat;
|
||||
return Math.min(MAX_LAUNCH_LATITUDE, Math.max(-MAX_LAUNCH_LATITUDE, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a longitude into [-180, 180).
|
||||
*
|
||||
* Wraps rather than clamps because a meridian is not a barrier: 200°E is the
|
||||
* same place as -160°, so wrapping keeps the point the user meant, while
|
||||
* clamping to 180 would silently move it 20° away.
|
||||
*
|
||||
* Distinct from normalizeLng, which only folds the 0..360 convention the API
|
||||
* sometimes returns and is not a general wrap.
|
||||
*/
|
||||
export function wrapLongitude(lng: number): number {
|
||||
if (!Number.isFinite(lng)) return lng;
|
||||
const wrapped = ((lng + 180) % 360 + 360) % 360 - 180;
|
||||
// -180 and 180 are the same meridian; pick the lower bound consistently.
|
||||
return wrapped === -0 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
export type LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
|
||||
|
||||
export type LatLngExpression = LatLng | LatLngTuple;
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ export * from './math';
|
|||
export * from './scenario';
|
||||
export * from './prediction';
|
||||
export * from './telemetry';
|
||||
export * from './wind';
|
||||
export * from './boundingBox';
|
||||
export * from './export';
|
||||
|
|
|
|||
|
|
@ -1,214 +0,0 @@
|
|||
/**
|
||||
* Wind field types matching the wind-js-server / leaflet-velocity format
|
||||
* produced by the predictor's GET /api/v1/wind/field endpoint.
|
||||
*
|
||||
* The response is a two-element array [U, V] where U is the eastward and V
|
||||
* the northward wind component, each stored as a regular lat/lng grid
|
||||
* described by a GRIB-style header.
|
||||
*/
|
||||
|
||||
export interface WindHeader {
|
||||
parameterUnit: string;
|
||||
parameterNumberName: string;
|
||||
/** Grid points in the longitude direction. */
|
||||
nx: number;
|
||||
/** Grid points in the latitude direction. */
|
||||
ny: number;
|
||||
lo1: number; // longitude of first grid point (degrees)
|
||||
la1: number; // latitude of first grid point (degrees)
|
||||
lo2: number; // longitude of last grid point
|
||||
la2: number; // latitude of last grid point
|
||||
/**
|
||||
* Grid increments in degrees. Both are reported as positive magnitudes by
|
||||
* the predictor regardless of scan direction, so the scan direction must be
|
||||
* inferred from the extent (la1/la2, lo1/lo2) — see decodeWindField.
|
||||
*/
|
||||
dx: number;
|
||||
dy: number;
|
||||
refTime: string; // ISO 8601 reference time
|
||||
}
|
||||
|
||||
export interface WindComponent {
|
||||
header: WindHeader;
|
||||
/** Flat row-major array: data[j * nx + i] = value at row j, column i. */
|
||||
data: number[];
|
||||
}
|
||||
|
||||
/** [U-component (eastward m/s), V-component (northward m/s)] */
|
||||
export type WindField = [WindComponent, WindComponent];
|
||||
|
||||
export interface WindMeta {
|
||||
source: string;
|
||||
epoch: string;
|
||||
altitudes: number[];
|
||||
bbox: {
|
||||
min_lat: number;
|
||||
max_lat: number;
|
||||
min_lng: number;
|
||||
max_lng: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Decoded wind vector at a single grid cell. */
|
||||
export interface WindVector {
|
||||
lat: number;
|
||||
lng: number;
|
||||
u: number; // eastward component (m/s)
|
||||
v: number; // northward component (m/s)
|
||||
speed: number; // magnitude (m/s)
|
||||
/**
|
||||
* Direction the wind blows TO, degrees clockwise from north.
|
||||
* 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate.
|
||||
*
|
||||
* Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3).
|
||||
*/
|
||||
bearing: number;
|
||||
}
|
||||
|
||||
export interface WindSettings {
|
||||
/** Master toggle — off by default. */
|
||||
enabled: boolean;
|
||||
/** Grid resolution for static display (degrees). */
|
||||
step: number;
|
||||
/** Grid resolution when synced to a trajectory (degrees). */
|
||||
trajectoryStep: number;
|
||||
/** Time interval between pre-fetched trajectory frames (minutes). */
|
||||
prefetchIntervalMinutes: number;
|
||||
/** Trajectory sync is skipped when flight duration exceeds this (hours). */
|
||||
maxFlightDurationHours: number;
|
||||
/**
|
||||
* Trajectory sync is skipped when the bounding box exceeds this in either
|
||||
* dimension (degrees).
|
||||
*/
|
||||
maxRegionDegrees: number;
|
||||
/** Padding added to the trajectory bounding box on each side (degrees). */
|
||||
trajectoryMarginDegrees: number;
|
||||
/** Particle count scalar (particles per screen pixel). Higher = denser. */
|
||||
particleDensity: number;
|
||||
/** Advection speed multiplier — how fast particles flow. */
|
||||
particleSpeed: number;
|
||||
/** Trail persistence in [0,1): fraction of each trail kept per frame. */
|
||||
trailPersistence: number;
|
||||
/** Wind speed (m/s) mapped to the top of the colour scale. */
|
||||
maxVelocity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WIND_SETTINGS: WindSettings = {
|
||||
enabled: false,
|
||||
step: 2.0,
|
||||
trajectoryStep: 1.0,
|
||||
prefetchIntervalMinutes: 15,
|
||||
maxFlightDurationHours: 4,
|
||||
maxRegionDegrees: 20,
|
||||
trajectoryMarginDegrees: 1.0,
|
||||
particleDensity: 1.0,
|
||||
particleSpeed: 1.0,
|
||||
trailPersistence: 0.92,
|
||||
maxVelocity: 30,
|
||||
};
|
||||
|
||||
/** Wrap a longitude into the (-180, 180] range MapLibre renders. */
|
||||
function wrapLng(lng: number): number {
|
||||
let x = ((lng + 180) % 360) - 180;
|
||||
if (x <= -180) x += 360;
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize a WindField into an array of wind vectors — one per grid cell.
|
||||
*
|
||||
* Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2)
|
||||
* rather than the raw dx/dy increments, because the predictor reports:
|
||||
* • longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and
|
||||
* • a *positive* dy even when the grid scans north→south (la1 = 90,
|
||||
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
|
||||
*
|
||||
* Stepping from the first point toward the last (la1→la2, lo1→lo2) and
|
||||
* wrapping longitudes into (-180, 180] places every arrow at its true
|
||||
* geographic position regardless of scan direction or longitude convention.
|
||||
*/
|
||||
export function decodeWindField(field: WindField): WindVector[] {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const vectors: WindVector[] = [];
|
||||
|
||||
// Per-step deltas taken from the grid extent so the last row/column lands
|
||||
// exactly on la2/lo2. Longitude span is taken the short way around the
|
||||
// globe to stay correct for boxes that cross the 0/360 seam.
|
||||
const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
for (let j = 0; j < ny; j++) {
|
||||
const lat = la1 + j * latDelta;
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const idx = j * nx + i;
|
||||
const u = uComp.data[idx];
|
||||
const v = vComp.data[idx];
|
||||
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
|
||||
const lng = wrapLng(lo1 + i * lngDelta);
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
const bearing = (Math.atan2(u, v) * 180) / Math.PI;
|
||||
vectors.push({ lat, lng, u, v, speed, bearing });
|
||||
}
|
||||
}
|
||||
|
||||
return vectors;
|
||||
}
|
||||
|
||||
/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
|
||||
export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
|
||||
|
||||
/**
|
||||
* Build a bilinear interpolator over a WindField. Used by the particle
|
||||
* renderer to advect points through a continuous [u, v] field.
|
||||
*
|
||||
* Coordinate handling mirrors decodeWindField: longitudes are taken in the
|
||||
* grid's native 0..360 frame (so a query lng is brought into that frame),
|
||||
* and the per-step increments come from the grid extent so scan direction is
|
||||
* handled implicitly.
|
||||
*/
|
||||
export function createWindInterpolator(field: WindField): WindInterpolator {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const u = uComp.data;
|
||||
const v = vComp.data;
|
||||
|
||||
const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
return (lng, lat) => {
|
||||
if (lngDelta === 0 || latDelta === 0) return null;
|
||||
|
||||
const rj = (lat - la1) / latDelta;
|
||||
if (rj < 0 || rj > ny - 1) return null;
|
||||
|
||||
// Eastward offset from lo1 in the grid's 0..360 frame.
|
||||
const dLon = (((lng - lo1) % 360) + 360) % 360;
|
||||
const ci = dLon / lngDelta;
|
||||
if (ci < 0 || ci > nx - 1) return null;
|
||||
|
||||
const i0 = Math.floor(ci);
|
||||
const j0 = Math.floor(rj);
|
||||
const i1 = Math.min(i0 + 1, nx - 1);
|
||||
const j1 = Math.min(j0 + 1, ny - 1);
|
||||
const fi = ci - i0;
|
||||
const fj = rj - j0;
|
||||
|
||||
const a = (1 - fi) * (1 - fj);
|
||||
const b = fi * (1 - fj);
|
||||
const c = (1 - fi) * fj;
|
||||
const d = fi * fj;
|
||||
|
||||
const k00 = j0 * nx + i0;
|
||||
const k10 = j0 * nx + i1;
|
||||
const k01 = j1 * nx + i0;
|
||||
const k11 = j1 * nx + i1;
|
||||
|
||||
const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
|
||||
const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
|
||||
if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
|
||||
return [ui, vi];
|
||||
};
|
||||
}
|
||||
77
src/lib/features/mapchrome/MapChrome.svelte
Normal file
77
src/lib/features/mapchrome/MapChrome.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Map chrome driven by user settings: the lat/lon graticule and the base
|
||||
* imagery layer.
|
||||
*
|
||||
* Lives in features/ rather than map/ because `map/` must not depend on
|
||||
* settings (see docs/ARCHITECTURE.md). Place it as a child of <Map />, the
|
||||
* same way WorkspaceRenderer is.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getMap } from '$map';
|
||||
import type { LatLngTuple } from '$domain';
|
||||
import { settingsStore } from '$features/settings';
|
||||
|
||||
const map = getMap();
|
||||
if (!map) throw new Error('MapChrome must be a descendant of <Map />');
|
||||
|
||||
const SCENE = 'graticule';
|
||||
|
||||
// Deliberately sparse: few enough lines to stay readable at any zoom.
|
||||
const MERIDIAN_STEP_DEG = 30;
|
||||
const PARALLEL_STEP_DEG = 15;
|
||||
/**
|
||||
* Meridians run the full pole to pole, so they all meet at a single point at
|
||||
* each end. Their ground spacing does shrink toward the pole, but what you
|
||||
* see depends on zoom — closing in on the pole spreads them back across the
|
||||
* screen, so convergence reads as a clean star rather than a smear.
|
||||
*/
|
||||
const MERIDIAN_LIMIT_LAT = 90;
|
||||
/** Parallels stop short: nearer the pole they shrink to invisible circles. */
|
||||
const PARALLEL_LIMIT_LAT = 75;
|
||||
/** Vertex spacing along each line, in degrees. */
|
||||
const SAMPLE_DEG = 5;
|
||||
// Mid-tone so it stays legible on both the light OSM map and dark imagery.
|
||||
const COLOR = '#8a97a5';
|
||||
const OPACITY = 0.35;
|
||||
const WIDTH = 1;
|
||||
|
||||
function drawGraticule(): void {
|
||||
const scene = map!.scene(SCENE);
|
||||
scene.clear();
|
||||
|
||||
for (let lng = -180; lng < 180; lng += MERIDIAN_STEP_DEG) {
|
||||
const coords: LatLngTuple[] = [];
|
||||
for (let lat = -MERIDIAN_LIMIT_LAT; lat <= MERIDIAN_LIMIT_LAT; lat += SAMPLE_DEG) {
|
||||
coords.push([lat, lng]);
|
||||
}
|
||||
scene.addLine(`m${lng}`, { coords, color: COLOR, width: WIDTH, opacity: OPACITY });
|
||||
}
|
||||
|
||||
for (let lat = -PARALLEL_LIMIT_LAT; lat <= PARALLEL_LIMIT_LAT; lat += PARALLEL_STEP_DEG) {
|
||||
const coords: LatLngTuple[] = [];
|
||||
// A parallel is not a geodesic, so it needs dense sampling: two
|
||||
// endpoints alone would be joined by a great-circle arc bowing poleward.
|
||||
for (let lng = -180; lng <= 180; lng += SAMPLE_DEG) {
|
||||
coords.push([lat, lng]);
|
||||
}
|
||||
scene.addLine(`p${lat}`, { coords, color: COLOR, width: WIDTH, opacity: OPACITY });
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
map!.setBaseLayer($settingsStore.map.baseLayer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// `persisted` does not merge defaults, so settings stored before the
|
||||
// graticule existed have no key at all — treat that as on.
|
||||
if ($settingsStore.map.graticule ?? true) {
|
||||
drawGraticule();
|
||||
} else {
|
||||
map!.disposeScene(SCENE);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => map?.disposeScene(SCENE));
|
||||
</script>
|
||||
1
src/lib/features/mapchrome/index.ts
Normal file
1
src/lib/features/mapchrome/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as MapChrome } from './MapChrome.svelte';
|
||||
|
|
@ -27,7 +27,10 @@
|
|||
import { pointsApi } from '$api';
|
||||
import {
|
||||
DEFAULT_FLIGHT_PARAMETERS,
|
||||
MAX_LAUNCH_LATITUDE,
|
||||
PROFILE_IDENTIFIERS,
|
||||
clampLaunchLatitude,
|
||||
wrapLongitude,
|
||||
toFixedNumber,
|
||||
type FlightParameters,
|
||||
type ProfileIdentifier,
|
||||
|
|
@ -75,7 +78,17 @@
|
|||
|
||||
function patchActive(patch: Partial<FlightParameters>) {
|
||||
if (!active) return;
|
||||
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
|
||||
// Single choke point for launch latitude: typing it, clicking the map
|
||||
// (updateLaunchPosition) and picking a saved point all land here, so one
|
||||
// clamp covers every path.
|
||||
const safe = { ...patch };
|
||||
if (safe.launch_latitude !== undefined) {
|
||||
safe.launch_latitude = clampLaunchLatitude(safe.launch_latitude);
|
||||
}
|
||||
if (safe.launch_longitude !== undefined) {
|
||||
safe.launch_longitude = wrapLongitude(safe.launch_longitude);
|
||||
}
|
||||
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...safe });
|
||||
}
|
||||
|
||||
function handlePointSelection(newPointId: number | null) {
|
||||
|
|
@ -225,6 +238,8 @@
|
|||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min={-MAX_LAUNCH_LATITUDE}
|
||||
max={MAX_LAUNCH_LATITUDE}
|
||||
value={params.launch_latitude}
|
||||
oninput={(e) =>
|
||||
patchActive({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,15 @@
|
|||
} from '@sveltestrap/sveltestrap';
|
||||
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
|
||||
import { scenariosApi } from '$api';
|
||||
import { PREDICTION_MODES, type SavedScenario } from '$domain';
|
||||
import {
|
||||
EXPORT_FORMATS,
|
||||
PREDICTION_MODES,
|
||||
exportFilename,
|
||||
exportMimeType,
|
||||
serializePrediction,
|
||||
type ExportFormat,
|
||||
type SavedScenario,
|
||||
} from '$domain';
|
||||
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||
import { t } from '$i18n';
|
||||
import { scenariosStore } from './pointsStore';
|
||||
|
|
@ -18,8 +26,32 @@
|
|||
|
||||
let selectedScenarioId = $state<number>(-1);
|
||||
let editorRef: ScenarioEditor | null = $state(null);
|
||||
let exportFormat = $state<ExportFormat>('JSON');
|
||||
|
||||
let active = $derived(getActiveWorkspace($workspacesStore));
|
||||
|
||||
function handleExport() {
|
||||
const result = active?.result;
|
||||
if (!result) {
|
||||
addToast({
|
||||
header: $t('scenario.export'),
|
||||
body: $t('scenario.exportNoResult'),
|
||||
color: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const text = serializePrediction(result, exportFormat);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([text], { type: exportMimeType(exportFormat) }),
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = exportFilename(result, exportFormat);
|
||||
a.click();
|
||||
// Revoking immediately can abort the download in some browsers; let the
|
||||
// navigation start first.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
let scenarioUnsaved = $derived.by(() => {
|
||||
if (!active) return false;
|
||||
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
||||
|
|
@ -172,12 +204,12 @@
|
|||
<FormGroup spacing="mb-0">
|
||||
<Label class="form-label">{$t('scenario.export')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" class="form-control-sm">
|
||||
<option>JSON</option>
|
||||
<option>CSV</option>
|
||||
<option>KML</option>
|
||||
<Input type="select" class="form-control-sm" bind:value={exportFormat}>
|
||||
{#each EXPORT_FORMATS as f (f)}
|
||||
<option value={f}>{f}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
<Button color="primary">
|
||||
<Button color="primary" disabled={!active?.result} onclick={handleExport}>
|
||||
<span>{$t('scenario.exportBtn')}</span>
|
||||
<Icon name="file-earmark-arrow-down" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { settingsStore, DEFAULT_SETTINGS } from './store';
|
||||
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store';
|
||||
export type { AppSettings, MapSettings, UnitsSettings } from './store';
|
||||
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||
export { SETTINGS_SCHEMA } from './schema';
|
||||
export type { SettingsField, SettingsSection } from './schema';
|
||||
|
|
|
|||
|
|
@ -61,10 +61,12 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
|
|||
path: 'map.baseLayer',
|
||||
labelKey: 'settings.baseLayer',
|
||||
options: [
|
||||
{ value: 'osm', labelKey: 'settings.baseLayer' },
|
||||
{ value: 'satellite', labelKey: 'settings.baseLayer' },
|
||||
{ value: 'osm', labelKey: 'settings.baseLayerOsm' },
|
||||
{ value: 'satellite', labelKey: 'settings.baseLayerSatellite' },
|
||||
{ value: 'polar', labelKey: 'settings.baseLayerPolar' },
|
||||
],
|
||||
},
|
||||
{ kind: 'boolean', path: 'map.graticule', labelKey: 'settings.graticule' },
|
||||
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
|
||||
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
|
||||
],
|
||||
|
|
@ -83,90 +85,4 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.wind',
|
||||
fields: [
|
||||
{ kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' },
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.step',
|
||||
labelKey: 'settings.windStep',
|
||||
min: 0.25,
|
||||
max: 10,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trajectoryStep',
|
||||
labelKey: 'settings.windTrajectoryStep',
|
||||
min: 0.25,
|
||||
max: 5,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.prefetchIntervalMinutes',
|
||||
labelKey: 'settings.windPrefetchInterval',
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxFlightDurationHours',
|
||||
labelKey: 'settings.windMaxDuration',
|
||||
min: 1,
|
||||
max: 8,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxRegionDegrees',
|
||||
labelKey: 'settings.windMaxRegion',
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trajectoryMarginDegrees',
|
||||
labelKey: 'settings.windMargin',
|
||||
min: 0.5,
|
||||
max: 5,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.particleDensity',
|
||||
labelKey: 'settings.windParticleDensity',
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.particleSpeed',
|
||||
labelKey: 'settings.windParticleSpeed',
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trailPersistence',
|
||||
labelKey: 'settings.windTrailPersistence',
|
||||
min: 0.7,
|
||||
max: 0.98,
|
||||
step: 0.02,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxVelocity',
|
||||
labelKey: 'settings.windMaxVelocity',
|
||||
min: 10,
|
||||
max: 80,
|
||||
step: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { persisted } from '$state';
|
||||
import type { Locale } from '$i18n';
|
||||
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
||||
|
||||
export type { WindSettings };
|
||||
import type { BaseLayerId } from '$map';
|
||||
|
||||
export interface MapSettings {
|
||||
baseLayer: 'osm' | 'satellite';
|
||||
baseLayer: BaseLayerId;
|
||||
showScale: boolean;
|
||||
showNavigation: boolean;
|
||||
/**
|
||||
* Optional because `persisted` does not merge defaults into an existing
|
||||
* stored payload — settings saved before the graticule existed simply lack
|
||||
* the key, so read sites default it to on.
|
||||
*/
|
||||
graticule?: boolean;
|
||||
}
|
||||
|
||||
export interface UnitsSettings {
|
||||
|
|
@ -18,14 +22,12 @@ export interface AppSettings {
|
|||
locale: Locale;
|
||||
map: MapSettings;
|
||||
units: UnitsSettings;
|
||||
wind: WindSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: 'ru',
|
||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true, graticule: true },
|
||||
units: { system: 'metric' },
|
||||
wind: { ...DEFAULT_WIND_SETTINGS },
|
||||
};
|
||||
|
||||
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
|
||||
|
|
|
|||
|
|
@ -1,335 +0,0 @@
|
|||
/**
|
||||
* ParticleField — an animated wind-flow layer rendered to a 2D canvas
|
||||
* overlaid on the MapLibre container, in the spirit of leaflet-velocity /
|
||||
* cambecc's "earth".
|
||||
*
|
||||
* Particles live in CSS-pixel space. Each frame, every particle is unprojected
|
||||
* to lng/lat, the wind [u, v] there is sampled, and that vector is pushed
|
||||
* through the map projection's local Jacobian to obtain a pixel-space velocity
|
||||
* (so motion is correct at any zoom/latitude). Trails are faded by compositing
|
||||
* a translucent clear over the previous frame, leaving the basemap visible.
|
||||
*
|
||||
* The wind field can change every frame (the renderer interpolates between
|
||||
* pre-fetched trajectory frames over time); only the lightweight interpolator
|
||||
* closure is swapped, so particle motion stays continuous. See
|
||||
* docs/wind-vis-math.tex §"Particle Advection".
|
||||
*/
|
||||
|
||||
import type { Map as MLMap } from 'maplibre-gl';
|
||||
import type { WindInterpolator } from '$domain';
|
||||
|
||||
export interface ParticleOptions {
|
||||
/** Particles per screen pixel (scaled by the base multiplier). */
|
||||
density: number;
|
||||
/** Advection speed multiplier. */
|
||||
speed: number;
|
||||
/** Trail persistence in [0,1): fraction of the trail kept each frame. */
|
||||
trailPersistence: number;
|
||||
/** Max frames a particle lives before it is respawned. */
|
||||
maxAge: number;
|
||||
/** Trail line width (CSS px). */
|
||||
lineWidth: number;
|
||||
/** Wind speed (m/s) at the bottom / top of the colour scale. */
|
||||
minVelocity: number;
|
||||
maxVelocity: number;
|
||||
/** Target frame rate (the field is re-evaluated at most this often). */
|
||||
frameRate: number;
|
||||
/** Colour ramp from slow → fast wind. */
|
||||
colorScale: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_SCALE = [
|
||||
'rgb(36,104,180)',
|
||||
'rgb(60,157,194)',
|
||||
'rgb(128,205,193)',
|
||||
'rgb(151,218,168)',
|
||||
'rgb(198,231,181)',
|
||||
'rgb(238,247,217)',
|
||||
'rgb(255,238,159)',
|
||||
'rgb(252,217,125)',
|
||||
'rgb(255,182,100)',
|
||||
'rgb(252,150,75)',
|
||||
'rgb(250,112,52)',
|
||||
'rgb(245,64,32)',
|
||||
'rgb(237,45,28)',
|
||||
'rgb(220,24,32)',
|
||||
'rgb(180,0,35)',
|
||||
];
|
||||
|
||||
export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = {
|
||||
density: 1.0,
|
||||
speed: 1.0,
|
||||
trailPersistence: 0.92,
|
||||
maxAge: 100,
|
||||
lineWidth: 1.4,
|
||||
minVelocity: 0,
|
||||
maxVelocity: 30,
|
||||
frameRate: 30,
|
||||
colorScale: DEFAULT_COLOR_SCALE,
|
||||
};
|
||||
|
||||
/** Base particle count = pixels × this (kept modest for performance). */
|
||||
const PARTICLE_MULTIPLIER = 1 / 350;
|
||||
const MAX_PARTICLES = 6000;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
xt: number;
|
||||
yt: number;
|
||||
age: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export class ParticleField {
|
||||
private map: MLMap;
|
||||
private host: HTMLElement;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private opts: ParticleOptions;
|
||||
private interp: WindInterpolator | null = null;
|
||||
|
||||
private particles: Particle[] = [];
|
||||
private raf = 0;
|
||||
private then = 0;
|
||||
private moving = false;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private debugLogged = false;
|
||||
|
||||
constructor(map: MLMap, opts: Partial<ParticleOptions> = {}) {
|
||||
this.map = map;
|
||||
this.opts = { ...DEFAULT_PARTICLE_OPTIONS, ...opts };
|
||||
|
||||
// Mount inside the MapLibre canvas container so the overlay sits above
|
||||
// the basemap but below the control container and the app's panels.
|
||||
this.host = map.getCanvasContainer();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'wind-particles';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '3';
|
||||
this.host.appendChild(canvas);
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
|
||||
this.map.on('movestart', this.onMoveStart);
|
||||
this.map.on('moveend', this.onMoveEnd);
|
||||
this.map.on('resize', this.onResize);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<ParticleOptions>): void {
|
||||
const densityChanged = opts.density !== undefined && opts.density !== this.opts.density;
|
||||
this.opts = { ...this.opts, ...opts };
|
||||
if (densityChanged) this.seedParticles();
|
||||
}
|
||||
|
||||
/** Swap the wind field. Pass null to clear the flow. */
|
||||
setField(interp: WindInterpolator | null): void {
|
||||
this.interp = interp;
|
||||
if (interp && this.particles.length === 0) this.seedParticles();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.raf) return;
|
||||
this.then = performance.now();
|
||||
this.raf = requestAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.map.off('movestart', this.onMoveStart);
|
||||
this.map.off('moveend', this.onMoveEnd);
|
||||
this.map.off('resize', this.onResize);
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private onMoveStart = (): void => {
|
||||
this.moving = true;
|
||||
this.clear();
|
||||
};
|
||||
|
||||
private onMoveEnd = (): void => {
|
||||
this.moving = false;
|
||||
this.seedParticles();
|
||||
};
|
||||
|
||||
private onResize = (): void => {
|
||||
this.resize();
|
||||
};
|
||||
|
||||
private resize(): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Size from the gl canvas: it always reports the true viewport size,
|
||||
// whereas the canvas-container wrapper can measure 0 in some layouts.
|
||||
const glCanvas = this.map.getCanvas();
|
||||
const w = glCanvas.clientWidth || this.map.getContainer().clientWidth;
|
||||
const h = glCanvas.clientHeight || this.map.getContainer().clientHeight;
|
||||
if (!w || !h) return;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.canvas.style.width = `${w}px`;
|
||||
this.canvas.style.height = `${h}px`;
|
||||
this.canvas.width = Math.round(w * dpr);
|
||||
this.canvas.height = Math.round(h * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space
|
||||
this.seedParticles();
|
||||
}
|
||||
|
||||
private particleCount(): number {
|
||||
const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density;
|
||||
return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n)));
|
||||
}
|
||||
|
||||
private seedParticles(): void {
|
||||
const count = this.particleCount();
|
||||
this.particles = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 };
|
||||
this.respawn(this.particles[i]);
|
||||
this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a particle at a random pixel that has wind (a few retries). */
|
||||
private respawn(p: Particle): void {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const x = Math.random() * this.width;
|
||||
const y = Math.random() * this.height;
|
||||
if (!this.interp) {
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
break;
|
||||
}
|
||||
const ll = this.map.unproject([x, y]);
|
||||
if (this.interp(ll.lng, ll.lat)) {
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
break;
|
||||
}
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
}
|
||||
p.age = 0;
|
||||
p.speed = 0;
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
this.ctx.clearRect(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
private colorIndex(speed: number): number {
|
||||
const { minVelocity, maxVelocity, colorScale } = this.opts;
|
||||
const f = (speed - minVelocity) / (maxVelocity - minVelocity);
|
||||
return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1))));
|
||||
}
|
||||
|
||||
private evolve(): void {
|
||||
const interp = this.interp;
|
||||
if (!interp) return;
|
||||
const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale
|
||||
const eps = 0.02; // degrees, for the projection Jacobian
|
||||
|
||||
for (const p of this.particles) {
|
||||
if (p.age >= this.opts.maxAge) {
|
||||
this.respawn(p);
|
||||
continue;
|
||||
}
|
||||
const ll = this.map.unproject([p.x, p.y]);
|
||||
const wind = interp(ll.lng, ll.lat);
|
||||
if (!wind) {
|
||||
p.age = this.opts.maxAge; // escaped the field → respawn next tick
|
||||
continue;
|
||||
}
|
||||
const [u, v] = wind;
|
||||
|
||||
// Local projection Jacobian: pixel deltas per degree at this point.
|
||||
const east = this.map.project([ll.lng + eps, ll.lat]);
|
||||
const north = this.map.project([ll.lng, ll.lat + eps]);
|
||||
const jxLng = (east.x - p.x) / eps;
|
||||
const jyLng = (east.y - p.y) / eps;
|
||||
const jxLat = (north.x - p.x) / eps;
|
||||
const jyLat = (north.y - p.y) / eps;
|
||||
|
||||
p.xt = p.x + (jxLng * u + jxLat * v) * scale;
|
||||
p.yt = p.y + (jyLng * u + jyLat * v) * scale;
|
||||
p.speed = Math.sqrt(u * u + v * v);
|
||||
p.age += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const ctx = this.ctx;
|
||||
|
||||
// Fade existing trails toward transparent (keeps the basemap visible).
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`;
|
||||
ctx.fillRect(0, 0, this.width, this.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Draw new trail segments, grouped by colour bucket.
|
||||
const { colorScale } = this.opts;
|
||||
ctx.lineWidth = this.opts.lineWidth;
|
||||
const buckets: Particle[][] = colorScale.map(() => []);
|
||||
for (const p of this.particles) {
|
||||
if (p.age >= this.opts.maxAge || p.speed === 0) continue;
|
||||
buckets[this.colorIndex(p.speed)].push(p);
|
||||
}
|
||||
let drawn = 0;
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const bucket = buckets[i];
|
||||
if (bucket.length === 0) continue;
|
||||
drawn += bucket.length;
|
||||
ctx.strokeStyle = colorScale[i];
|
||||
ctx.beginPath();
|
||||
for (const p of bucket) {
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.xt, p.yt);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV && !this.debugLogged) {
|
||||
this.debugLogged = true;
|
||||
// One-shot diagnostic: confirms field, canvas size, and that segments
|
||||
// are actually being drawn. Remove once the layer is verified.
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[wind] first draw', {
|
||||
hasInterp: !!this.interp,
|
||||
canvas: `${this.width}x${this.height}`,
|
||||
backing: `${this.canvas.width}x${this.canvas.height}`,
|
||||
particles: this.particles.length,
|
||||
drawnSegments: drawn,
|
||||
host: this.host.className,
|
||||
});
|
||||
}
|
||||
|
||||
// Advance positions for the next frame.
|
||||
for (const p of this.particles) {
|
||||
p.x = p.xt;
|
||||
p.y = p.yt;
|
||||
}
|
||||
}
|
||||
|
||||
private frame = (now: number): void => {
|
||||
this.raf = requestAnimationFrame(this.frame);
|
||||
if (this.moving || !this.interp) return;
|
||||
const frameTime = 1000 / this.opts.frameRate;
|
||||
if (now - this.then < frameTime) return;
|
||||
this.then = now - ((now - this.then) % frameTime);
|
||||
this.evolve();
|
||||
this.draw();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* WindRenderer — renderless component that drives an animated particle-flow
|
||||
* wind layer (ParticleField) over the shared MapLibre map.
|
||||
*
|
||||
* Two display modes:
|
||||
*
|
||||
* Static – shown whenever wind is enabled but no trajectory is available.
|
||||
* Fetches the global wind field at the active workspace's launch
|
||||
* altitude and datetime.
|
||||
*
|
||||
* Trajectory sync – activated once the active workspace has a prediction
|
||||
* result AND the timeline has a non-zero range. Pre-fetches one
|
||||
* wind field per `prefetchIntervalMinutes` along the flight path
|
||||
* (altitude matches the trajectory at each time step), then
|
||||
* linearly interpolates [u, v] between the two bracketing frames
|
||||
* as the timeline scrubs, so the flow evolves smoothly.
|
||||
*
|
||||
* Sanity guards (all configurable in settings → Wind):
|
||||
* • Flight duration > maxFlightDurationHours → trajectory sync disabled.
|
||||
* • Bounding box > maxRegionDegrees in either axis → skipped.
|
||||
* • Minimum step clamped to 0.25° (API limit).
|
||||
*
|
||||
* The actual particle rendering lives in ParticleField (a 2D canvas overlay);
|
||||
* getRawInstance() is used here deliberately because that overlay needs the
|
||||
* raw MapLibre projection/container, which the IMap/Scene abstraction does
|
||||
* not expose. See docs/wind-vis-math.tex for the advection math.
|
||||
*/
|
||||
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Map as MLMap } from 'maplibre-gl';
|
||||
import { getMap } from '$map';
|
||||
import { settingsStore } from '$features/settings';
|
||||
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||
import { timelineStore } from '$features/timeline/store';
|
||||
import {
|
||||
createWindInterpolator,
|
||||
DEFAULT_WIND_SETTINGS,
|
||||
type WindField,
|
||||
type WindComponent,
|
||||
type WindSettings,
|
||||
} from '$domain';
|
||||
import type { Prediction, LatLngTuple } from '$domain';
|
||||
import { windCache } from './store';
|
||||
import { ParticleField, type ParticleOptions } from './ParticleField';
|
||||
|
||||
// ── Map handle ───────────────────────────────────────────────────────────
|
||||
|
||||
const map = getMap();
|
||||
if (!map) throw new Error('WindRenderer must be a descendant of <Map />');
|
||||
const mlMap = map.getRawInstance() as MLMap;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WindFrame {
|
||||
flightTimeMs: number;
|
||||
field: WindField;
|
||||
}
|
||||
|
||||
let particleField: ParticleField | null = null;
|
||||
|
||||
let currentField = $state<WindField | null>(null);
|
||||
let trajectoryFrames = $state<WindFrame[]>([]);
|
||||
let prefetchKey: string | null = null; // non-reactive — tracks last pre-fetch identity
|
||||
let staticFetchSeq = 0; // monotonically incremented to cancel stale static fetches
|
||||
let prefetchSkipReason = $state<string | null>(null);
|
||||
|
||||
// ── Derived reactive values ───────────────────────────────────────────────
|
||||
|
||||
const windSettings = $derived<WindSettings>({
|
||||
...DEFAULT_WIND_SETTINGS,
|
||||
...($settingsStore.wind ?? {}),
|
||||
});
|
||||
|
||||
const activeWorkspace = $derived(getActiveWorkspace($workspacesStore));
|
||||
const activePrediction = $derived(activeWorkspace?.result ?? null);
|
||||
|
||||
const inTrajectoryMode = $derived(
|
||||
windSettings.enabled && activePrediction !== null && $timelineStore.max > 0,
|
||||
);
|
||||
|
||||
// ── Particle field ────────────────────────────────────────────────────────
|
||||
|
||||
function particleOptions(s: WindSettings): Partial<ParticleOptions> {
|
||||
return {
|
||||
density: s.particleDensity,
|
||||
speed: s.particleSpeed,
|
||||
trailPersistence: s.trailPersistence,
|
||||
maxVelocity: s.maxVelocity,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureField(): ParticleField {
|
||||
if (!particleField) {
|
||||
particleField = new ParticleField(mlMap, particleOptions(windSettings));
|
||||
}
|
||||
return particleField;
|
||||
}
|
||||
|
||||
// ── Trajectory helpers ────────────────────────────────────────────────────
|
||||
|
||||
function trajectoryBBox(path: LatLngTuple[], marginDeg: number) {
|
||||
let minLat = Infinity,
|
||||
maxLat = -Infinity,
|
||||
minLng = Infinity,
|
||||
maxLng = -Infinity;
|
||||
for (const p of path) {
|
||||
if (p[0] < minLat) minLat = p[0];
|
||||
if (p[0] > maxLat) maxLat = p[0];
|
||||
if (p[1] < minLng) minLng = p[1];
|
||||
if (p[1] > maxLng) maxLng = p[1];
|
||||
}
|
||||
return {
|
||||
min_lat: minLat - marginDeg,
|
||||
max_lat: maxLat + marginDeg,
|
||||
min_lng: minLng - marginDeg,
|
||||
max_lng: maxLng + marginDeg,
|
||||
};
|
||||
}
|
||||
|
||||
/** Binary-search the trajectory for the altitude at a given flight-time offset. */
|
||||
function altAtFlightTime(prediction: Prediction, flightTimeMs: number): number {
|
||||
const { flight_path, timestamps } = prediction;
|
||||
if (!flight_path.length) return 0;
|
||||
const targetMs = timestamps[0] + flightTimeMs;
|
||||
let lo = 0,
|
||||
hi = timestamps.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (timestamps[mid] < targetMs) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const p = flight_path[Math.min(lo, flight_path.length - 1)];
|
||||
return p[2] ?? 0;
|
||||
}
|
||||
|
||||
/** Linearly blend one wind component (u or v) of two aligned grids. */
|
||||
function lerpComponent(a: WindComponent, b: WindComponent, f: number): WindComponent {
|
||||
if (a.data.length !== b.data.length) return f < 0.5 ? a : b;
|
||||
const data = new Array<number>(a.data.length);
|
||||
for (let k = 0; k < data.length; k++) data[k] = a.data[k] + (b.data[k] - a.data[k]) * f;
|
||||
return { header: a.header, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wind field at flight-time `t`, linearly interpolated between the two
|
||||
* bracketing pre-fetched frames so the field evolves smoothly as the
|
||||
* timeline scrubs. Frames share the same bbox/step, so their grids align
|
||||
* cell-for-cell and the [u,v] arrays can be blended directly.
|
||||
*/
|
||||
function fieldAtFlightTime(t: number): WindField | null {
|
||||
// trajectoryFrames is $state — reading it here creates a reactive dependency
|
||||
const frames = trajectoryFrames;
|
||||
if (!frames.length) return null;
|
||||
if (frames.length === 1 || t <= frames[0].flightTimeMs) return frames[0].field;
|
||||
const last = frames[frames.length - 1];
|
||||
if (t >= last.flightTimeMs) return last.field;
|
||||
|
||||
let hi = 1;
|
||||
while (hi < frames.length && frames[hi].flightTimeMs < t) hi++;
|
||||
const f0 = frames[hi - 1];
|
||||
const f1 = frames[hi];
|
||||
const span = f1.flightTimeMs - f0.flightTimeMs;
|
||||
const a = span > 0 ? (t - f0.flightTimeMs) / span : 0;
|
||||
if (a <= 0) return f0.field;
|
||||
if (a >= 1) return f1.field;
|
||||
return [lerpComponent(f0.field[0], f1.field[0], a), lerpComponent(f0.field[1], f1.field[1], a)];
|
||||
}
|
||||
|
||||
function makePrefetchKey(prediction: Prediction, s: WindSettings): string {
|
||||
return [
|
||||
prediction.timestamps[0],
|
||||
prediction.flight_time,
|
||||
s.trajectoryStep,
|
||||
s.prefetchIntervalMinutes,
|
||||
s.maxFlightDurationHours,
|
||||
s.maxRegionDegrees,
|
||||
s.trajectoryMarginDegrees,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
async function prefetchTrajectory(prediction: Prediction, settings: WindSettings): Promise<void> {
|
||||
const key = makePrefetchKey(prediction, settings);
|
||||
if (key === prefetchKey) return; // nothing changed
|
||||
|
||||
const flightMs = prediction.flight_time * 1000;
|
||||
|
||||
if (flightMs > settings.maxFlightDurationHours * 3_600_000) {
|
||||
prefetchKey = key;
|
||||
trajectoryFrames = [];
|
||||
prefetchSkipReason = `wind.skippedLong`;
|
||||
return;
|
||||
}
|
||||
|
||||
const bbox = trajectoryBBox(prediction.flight_path, settings.trajectoryMarginDegrees);
|
||||
const latSpan = bbox.max_lat - bbox.min_lat;
|
||||
const lngSpan = bbox.max_lng - bbox.min_lng;
|
||||
if (latSpan > settings.maxRegionDegrees || lngSpan > settings.maxRegionDegrees) {
|
||||
prefetchKey = key;
|
||||
trajectoryFrames = [];
|
||||
prefetchSkipReason = `wind.skippedLarge`;
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchKey = key; // claim before async to prevent concurrent duplicate starts
|
||||
prefetchSkipReason = null;
|
||||
const frames: WindFrame[] = [];
|
||||
const intervalMs = settings.prefetchIntervalMinutes * 60_000;
|
||||
const launchMs = prediction.timestamps[0];
|
||||
const step = Math.max(settings.trajectoryStep, 0.25);
|
||||
|
||||
// Frame offsets: every interval, plus the landing point exactly once.
|
||||
const offsets: number[] = [];
|
||||
for (let t = 0; t < flightMs; t += intervalMs) offsets.push(t);
|
||||
offsets.push(flightMs);
|
||||
|
||||
// Sequential fetches so the cache warms predictably; concurrent bursts
|
||||
// could overwhelm the predictor.
|
||||
for (const offset of offsets) {
|
||||
const altitude = altAtFlightTime(prediction, offset);
|
||||
const time = new Date(launchMs + offset).toISOString();
|
||||
try {
|
||||
const field = await windCache.fetch({ time, altitude, step, ...bbox });
|
||||
frames.push({ flightTimeMs: offset, field });
|
||||
} catch {
|
||||
// Skip this frame and continue with others
|
||||
}
|
||||
}
|
||||
|
||||
trajectoryFrames = frames; // triggers the trajectory render effect
|
||||
}
|
||||
|
||||
// ── Effects ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-fetch trajectory wind frames when prediction or relevant settings change.
|
||||
$effect(() => {
|
||||
const prediction = activePrediction;
|
||||
const settings = windSettings;
|
||||
if (!settings.enabled || !prediction || $timelineStore.max === 0) {
|
||||
trajectoryFrames = [];
|
||||
prefetchKey = null;
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget; prefetchKey prevents duplicate starts.
|
||||
prefetchTrajectory(prediction, settings);
|
||||
});
|
||||
|
||||
// Trajectory mode: keep currentField in sync with the scrubbing timeline.
|
||||
$effect(() => {
|
||||
if (!inTrajectoryMode) return;
|
||||
// Reading trajectoryFrames ($state) makes this effect re-run when frames arrive.
|
||||
currentField = fieldAtFlightTime($timelineStore.time);
|
||||
});
|
||||
|
||||
// Static mode: fetch wind field for the active workspace's launch parameters.
|
||||
$effect(() => {
|
||||
if (!windSettings.enabled || inTrajectoryMode) {
|
||||
staticFetchSeq++; // cancel any in-flight static request
|
||||
return;
|
||||
}
|
||||
const ws = activeWorkspace;
|
||||
if (!ws) {
|
||||
currentField = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++staticFetchSeq;
|
||||
const step = Math.max(windSettings.step, 0.25);
|
||||
const { launch_altitude } = ws.flightParameters;
|
||||
const time = new Date(`${ws.launchDate}T${ws.launchTime}Z`).toISOString();
|
||||
|
||||
windCache
|
||||
.fetch({ altitude: launch_altitude, time, step })
|
||||
.then((field) => {
|
||||
if (seq !== staticFetchSeq) return; // superseded
|
||||
currentField = field;
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== staticFetchSeq) return;
|
||||
currentField = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Drive the particle field from currentField + settings.
|
||||
$effect(() => {
|
||||
const s = windSettings;
|
||||
const field = currentField;
|
||||
if (!s.enabled || !field) {
|
||||
particleField?.setField(null);
|
||||
particleField?.stop();
|
||||
return;
|
||||
}
|
||||
const pf = ensureField();
|
||||
pf.setOptions(particleOptions(s));
|
||||
pf.setField(createWindInterpolator(field));
|
||||
pf.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
staticFetchSeq++; // cancel any pending static callback
|
||||
particleField?.destroy();
|
||||
particleField = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if windSettings.enabled && prefetchSkipReason}
|
||||
<div class="wind-skip-notice">
|
||||
<i class="bi bi-wind"></i>
|
||||
{#if prefetchSkipReason === 'wind.skippedLong'}
|
||||
Wind sync skipped: flight > {windSettings.maxFlightDurationHours}h
|
||||
{:else}
|
||||
Wind sync skipped: region > {windSettings.maxRegionDegrees}°
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wind-skip-notice {
|
||||
position: absolute;
|
||||
bottom: 90px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { default as WindRenderer } from './WindRenderer.svelte';
|
||||
export { windCache } from './store';
|
||||
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* Thin cache layer for wind field responses.
|
||||
*
|
||||
* Each unique set of request parameters is keyed by a stable JSON string so
|
||||
* that the same (time, altitude, bbox, step) combination is fetched only once
|
||||
* per session even if multiple effects request it concurrently. The cache is
|
||||
* intentionally never invalidated during a session — the predictor's dataset
|
||||
* does not change while the user is working.
|
||||
*/
|
||||
|
||||
import { windApi, type WindFieldParams } from '$api';
|
||||
import type { WindField } from '$domain';
|
||||
|
||||
function cacheKey(params: WindFieldParams): string {
|
||||
return JSON.stringify({
|
||||
altitude: params.altitude ?? null,
|
||||
step: params.step ?? null,
|
||||
time: params.time ?? null,
|
||||
min_lat: params.min_lat ?? null,
|
||||
max_lat: params.max_lat ?? null,
|
||||
min_lng: params.min_lng ?? null,
|
||||
max_lng: params.max_lng ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
class WindCache {
|
||||
private readonly hits = new Map<string, WindField>();
|
||||
private readonly pending = new Map<string, Promise<WindField>>();
|
||||
|
||||
fetch(params: WindFieldParams): Promise<WindField> {
|
||||
const key = cacheKey(params);
|
||||
|
||||
const hit = this.hits.get(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = windApi
|
||||
.field(params)
|
||||
.then((field) => {
|
||||
this.hits.set(key, field);
|
||||
this.pending.delete(key);
|
||||
return field;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.pending.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.pending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hits.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const windCache = new WindCache();
|
||||
|
|
@ -73,6 +73,15 @@
|
|||
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
|
||||
const scene = map.scene(name);
|
||||
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
||||
// Frame a freshly-arrived result. A flight spans only tens of km,
|
||||
// which is a few dozen pixels at the default camera height — the
|
||||
// track ends up completely hidden under its own launch/burst/
|
||||
// landing markers and reads as a single dot. Deliberately not done
|
||||
// for colour/opacity edits, so tweaking one workspace while
|
||||
// comparing several does not yank the camera around.
|
||||
if (!cached || cached.result !== w.result) {
|
||||
map.fitBounds(w.result.flight_path, 50);
|
||||
}
|
||||
ownedPlotScenes.add(name);
|
||||
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,15 @@
|
|||
</Button>
|
||||
{#if w.bboxVisible && box}
|
||||
{@const coords = formatBoundingBox(box)}
|
||||
<!-- Size and centre, because near a pole the corners alone are
|
||||
unreadable: a box whose north edge passes over the pole comes
|
||||
back down the far side, so its two north corners land ~180
|
||||
degrees away in longitude. -->
|
||||
<div class="small text-muted mt-2 font-monospace">
|
||||
{box.widthKm.toFixed(1)} × {box.heightKm.toFixed(1)} km @ {box.centre[0].toFixed(
|
||||
4,
|
||||
)}, {box.centre[1].toFixed(4)}
|
||||
</div>
|
||||
<textarea
|
||||
class="form-control form-control-sm mt-2 font-monospace"
|
||||
style="resize: none;"
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@
|
|||
"datasetAuto": "Pick automatically",
|
||||
"modified": "modified",
|
||||
"export": "Export result",
|
||||
"exportBtn": "Export"
|
||||
"exportBtn": "Export",
|
||||
"exportNoResult": "Run a prediction first — there is nothing to export."
|
||||
},
|
||||
"predictionMode": {
|
||||
"single": "Single",
|
||||
|
|
@ -138,18 +139,10 @@
|
|||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"saved": "Settings saved",
|
||||
"wind": "Wind visualization",
|
||||
"windEnabled": "Show wind layer",
|
||||
"windStep": "Grid resolution (°)",
|
||||
"windTrajectoryStep": "Trajectory grid res. (°)",
|
||||
"windPrefetchInterval": "Pre-fetch interval (min)",
|
||||
"windMaxDuration": "Max sync duration (h)",
|
||||
"windMaxRegion": "Max region size (°)",
|
||||
"windMargin": "Trajectory margin (°)",
|
||||
"windParticleDensity": "Particle density",
|
||||
"windParticleSpeed": "Particle speed",
|
||||
"windTrailPersistence": "Trail length",
|
||||
"windMaxVelocity": "Max wind speed (m/s)"
|
||||
"baseLayerOsm": "Map (OpenStreetMap)",
|
||||
"baseLayerSatellite": "Satellite (Esri)",
|
||||
"graticule": "Lat/lon grid",
|
||||
"baseLayerPolar": "Polar map (offline, covers the poles)"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Add",
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@
|
|||
"datasetAuto": "Выбрать автоматически",
|
||||
"modified": "изменено",
|
||||
"export": "Экспортировать результат",
|
||||
"exportBtn": "Экспорт"
|
||||
"exportBtn": "Экспорт",
|
||||
"exportNoResult": "Сначала выполните расчёт — экспортировать нечего."
|
||||
},
|
||||
"predictionMode": {
|
||||
"single": "Разовый",
|
||||
|
|
@ -138,18 +139,10 @@
|
|||
"metric": "Метрические",
|
||||
"imperial": "Имперские",
|
||||
"saved": "Настройки сохранены",
|
||||
"wind": "Визуализация ветра",
|
||||
"windEnabled": "Показывать слой ветра",
|
||||
"windStep": "Шаг сетки (°)",
|
||||
"windTrajectoryStep": "Шаг сетки по траектории (°)",
|
||||
"windPrefetchInterval": "Интервал предзагрузки (мин)",
|
||||
"windMaxDuration": "Макс. длительность синхронизации (ч)",
|
||||
"windMaxRegion": "Макс. размер региона (°)",
|
||||
"windMargin": "Отступ вокруг траектории (°)",
|
||||
"windParticleDensity": "Плотность частиц",
|
||||
"windParticleSpeed": "Скорость частиц",
|
||||
"windTrailPersistence": "Длина следа",
|
||||
"windMaxVelocity": "Макс. скорость ветра (м/с)"
|
||||
"baseLayerOsm": "Карта (OpenStreetMap)",
|
||||
"baseLayerSatellite": "Спутник (Esri)",
|
||||
"graticule": "Сетка координат",
|
||||
"baseLayerPolar": "Полярная карта (офлайн, до полюсов)"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Добавить",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, type Snippet } from 'svelte';
|
||||
import type { IMap } from './core';
|
||||
import type { BaseLayerId, IMap } from './core';
|
||||
import type { LngLatTuple } from '$domain';
|
||||
import { createMapLibreMap } from './maplibre';
|
||||
import { createCesiumMap } from './cesium';
|
||||
import { setMapContext } from './context';
|
||||
|
||||
interface Props {
|
||||
center?: LngLatTuple;
|
||||
zoom?: number;
|
||||
baseLayer?: 'osm' | 'satellite';
|
||||
baseLayer?: BaseLayerId;
|
||||
showNavigationControl?: boolean;
|
||||
showScaleControl?: boolean;
|
||||
children?: Snippet;
|
||||
|
|
@ -28,9 +28,9 @@
|
|||
let container: HTMLDivElement;
|
||||
let map: IMap | null = $state(null);
|
||||
/**
|
||||
* Children must not render until the map's first `load` event. MapLibre
|
||||
* throws if addSource/addLayer is called on an unloaded style, and this
|
||||
* component is the natural gate for that invariant.
|
||||
* Children must not render until `map.ready` resolves. Cesium's Viewer is
|
||||
* usable synchronously, but features still expect this gate, so it stays as
|
||||
* the single place that decides when the map may be drawn on.
|
||||
*/
|
||||
let ready = $state(false);
|
||||
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
map = createMapLibreMap({
|
||||
map = createCesiumMap({
|
||||
container,
|
||||
center,
|
||||
zoom,
|
||||
|
|
|
|||
166
src/lib/map/cesium-scene.ts
Normal file
166
src/lib/map/cesium-scene.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import {
|
||||
Cartesian3,
|
||||
Color,
|
||||
ConstantPositionProperty,
|
||||
Entity,
|
||||
HorizontalOrigin,
|
||||
PolylineDashMaterialProperty,
|
||||
VerticalOrigin,
|
||||
type Viewer,
|
||||
} from 'cesium';
|
||||
import type {
|
||||
CircleOptions,
|
||||
LineOptions,
|
||||
MapLayer,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
Scene,
|
||||
} from './core';
|
||||
|
||||
/**
|
||||
* Cesium implementation of the Scene contract.
|
||||
*
|
||||
* Every position here goes through `Cartesian3.fromDegrees(lng, lat)`, which
|
||||
* converts geographic degrees straight to earth-centred coordinates. Nothing
|
||||
* passes through a Mercator tiler, so latitudes above 85.051129° — where the
|
||||
* MapLibre implementation silently clamped every vertex — render correctly.
|
||||
* That is the whole reason this file exists.
|
||||
*/
|
||||
|
||||
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on an unparseable string. */
|
||||
function toColor(css: string | undefined, opacity = 1): Color {
|
||||
const parsed = Color.fromCssColorString(css ?? '#000');
|
||||
// fromCssColorString returns undefined for garbage rather than throwing.
|
||||
return (parsed ?? Color.BLACK).withAlpha(opacity);
|
||||
}
|
||||
|
||||
export class CesiumScene implements Scene {
|
||||
private entities = new Map<string, Entity>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private viewer: Viewer,
|
||||
) {}
|
||||
|
||||
private scopeId(id: string): string {
|
||||
return `${this.name}__${id}`;
|
||||
}
|
||||
|
||||
private add(id: string, entity: Entity): Entity {
|
||||
this.remove(id);
|
||||
const added = this.viewer.entities.add(entity);
|
||||
this.entities.set(id, added);
|
||||
return added;
|
||||
}
|
||||
|
||||
addLine(id: string, options: LineOptions): MapLayer {
|
||||
// LatLngTuple is [lat, lng] or [lat, lng, alt]; Cesium wants lng first.
|
||||
// Swapping these is the classic bug here.
|
||||
// `c.length === 3` is what narrows the LatLngTuple union for TypeScript;
|
||||
// a `> 2` comparison does not.
|
||||
const heights: number[] = options.coords.map((c) => (c.length === 3 ? c[2] : 0));
|
||||
// Only lift the line into 3D when it actually has altitude. A track whose
|
||||
// every vertex sits at ground level (a bounding-box ring, or telemetry
|
||||
// before launch) must stay draped: at height 0 a polyline is coplanar
|
||||
// with the ellipsoid, z-fights it, and disappears entirely.
|
||||
const use3d = Math.max(...heights) > 1;
|
||||
const positions = use3d
|
||||
? Cartesian3.fromDegreesArrayHeights(
|
||||
options.coords.flatMap((c, i) => [c[1], c[0], heights[i]]),
|
||||
)
|
||||
: Cartesian3.fromDegreesArray(options.coords.flatMap((c) => [c[1], c[0]]));
|
||||
const color = toColor(options.color, options.opacity ?? 1);
|
||||
this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
polyline: {
|
||||
positions,
|
||||
width: options.width ?? 3,
|
||||
material: options.dashArray
|
||||
? new PolylineDashMaterialProperty({
|
||||
color,
|
||||
dashLength: options.dashArray[0] + options.dashArray[1],
|
||||
})
|
||||
: color,
|
||||
// arcType is left at its default, GEODESIC.
|
||||
// Draped only for ground-level geometry (see use3d above); a
|
||||
// real flight is drawn at its own altitude.
|
||||
clampToGround: !use3d,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { id, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||
// radiusPx is screen-space, matching the MapLibre circle-layer semantics
|
||||
// the callers were written against; PointGraphics.pixelSize is the direct
|
||||
// equivalent (diameter, hence the doubling).
|
||||
this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
|
||||
point: {
|
||||
pixelSize: (options.radiusPx ?? 5) * 2,
|
||||
color: toColor(options.color, options.opacity ?? 1),
|
||||
outlineColor: toColor(options.strokeColor, 1),
|
||||
outlineWidth: options.strokeWidth ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { id, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
addMarker(id: string, options: MarkerOptions): Marker {
|
||||
const alt = options.altitude ?? 0;
|
||||
const entity = this.add(
|
||||
id,
|
||||
new Entity({
|
||||
id: this.scopeId(id),
|
||||
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1], alt),
|
||||
...(options.iconUrl
|
||||
? {
|
||||
billboard: {
|
||||
image: options.iconUrl,
|
||||
width: options.iconSize?.[0],
|
||||
height: options.iconSize?.[1],
|
||||
horizontalOrigin: HorizontalOrigin.CENTER,
|
||||
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||
},
|
||||
}
|
||||
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
|
||||
// Shown by Cesium's own selection UI; the MapLibre build used a
|
||||
// hover popup, which has no direct Cesium equivalent.
|
||||
...(options.popupHtml ? { description: options.popupHtml } : {}),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
setLngLat: (pos) => {
|
||||
// LngLatTuple has no altitude, so a moved marker keeps the one it
|
||||
// was created with.
|
||||
entity.position = new ConstantPositionProperty(
|
||||
Cartesian3.fromDegrees(pos[0], pos[1], alt),
|
||||
);
|
||||
},
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const e = this.entities.get(id);
|
||||
if (!e) return;
|
||||
this.viewer.entities.remove(e);
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const e of this.entities.values()) this.viewer.entities.remove(e);
|
||||
this.entities.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
303
src/lib/map/cesium.ts
Normal file
303
src/lib/map/cesium.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import {
|
||||
ArcGisMapServerImageryProvider,
|
||||
Cartesian2,
|
||||
Cartesian3,
|
||||
EllipsoidTerrainProvider,
|
||||
Math as CesiumMath,
|
||||
OpenStreetMapImageryProvider,
|
||||
Rectangle,
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType,
|
||||
Viewer,
|
||||
TileMapServiceImageryProvider,
|
||||
type ImageryProvider,
|
||||
} from 'cesium';
|
||||
import 'cesium/Build/Cesium/Widgets/widgets.css';
|
||||
import type {
|
||||
BaseLayerId,
|
||||
IMap,
|
||||
MapClickEvent,
|
||||
MapEvent,
|
||||
MapEventHandler,
|
||||
MapInit,
|
||||
Scene as MapScene,
|
||||
} from './core';
|
||||
import type { LatLngTuple, LngLatTuple } from '$domain';
|
||||
import { CesiumScene } from './cesium-scene';
|
||||
|
||||
/**
|
||||
* CesiumJS implementation of IMap.
|
||||
*
|
||||
* Replaces the MapLibre/Mercator renderer so polar trajectories render: Cesium
|
||||
* projects geographic degrees directly onto an ellipsoid, with no Mercator tile
|
||||
* pyramid to clamp vertices at ±85.051129°.
|
||||
*
|
||||
* No Cesium Ion account is used — imagery comes from the same tile URLs the
|
||||
* MapLibre build used, and terrain is a plain ellipsoid. Any Ion code path
|
||||
* (createWorldTerrainAsync, IonImageryProvider) would require a token.
|
||||
*/
|
||||
|
||||
/** Equatorial circumference in metres — reference for the zoom<->height bridge. */
|
||||
const EQUATOR_M = 40075017;
|
||||
|
||||
/**
|
||||
* Wheel-zoom sensitivity. Cesium computes each step as
|
||||
* `zoomFactor * heightAboveEllipsoid * rangeWindowRatio`, so the step scales
|
||||
* with altitude and its default of 5.0 overshoots badly when zoomed out — one
|
||||
* notch throws the camera into space, one back slams it into the ground.
|
||||
* Lower is gentler. Tune here.
|
||||
*/
|
||||
const ZOOM_FACTOR = 2.0;
|
||||
|
||||
/**
|
||||
* Cesium has no discrete zoom levels, only camera height. These two functions
|
||||
* bridge the IMap vocabulary to it using the Web-Mercator-equivalent relation
|
||||
* at the equator. Nothing outside this file calls getZoom/setZoom, so an exact
|
||||
* match to MapLibre's scale is not required — only monotonicity.
|
||||
*/
|
||||
export function zoomToHeight(zoom: number): number {
|
||||
return EQUATOR_M / Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
export function heightToZoom(height: number): number {
|
||||
return Math.log2(EQUATOR_M / Math.max(height, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base imagery, each built by Cesium's provider for that actual protocol.
|
||||
*
|
||||
* Deliberately NOT hand-written URL templates. A template makes us restate
|
||||
* facts the service already publishes — row convention, max level, extent,
|
||||
* tiling scheme — and getting any of them wrong fails silently. A mistaken
|
||||
* `{reverseY}` here previously mirrored every Esri tile into the wrong latitude
|
||||
* band. These providers read those facts from the service instead.
|
||||
*/
|
||||
const BASE_LAYERS: Record<BaseLayerId, () => ImageryProvider | Promise<ImageryProvider>> = {
|
||||
// Knows the slippy-map convention, zoom range and attribution.
|
||||
osm: () => new OpenStreetMapImageryProvider({ url: 'https://tile.openstreetmap.org/' }),
|
||||
|
||||
// fromUrl() fetches the MapServer's own metadata (?f=json) and configures
|
||||
// tiling scheme, levels and extent from it. No token needed for a public
|
||||
// MapServer — that is only for Esri-hosted basemaps.
|
||||
satellite: () =>
|
||||
ArcGisMapServerImageryProvider.fromUrl(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
|
||||
),
|
||||
|
||||
// Natural Earth II, shipped inside the Cesium package and copied to
|
||||
// static/cesium by scripts/copy-cesium.js. EPSG:4326, so it covers ±90° —
|
||||
// the poles are real imagery instead of the blank blue that Web Mercator
|
||||
// leaves above 85.0511°.
|
||||
//
|
||||
// fromUrl() reads the set's own tilemapresource.xml for the SRS, extent,
|
||||
// tile size, zoom levels and format, so there is nothing here for us to
|
||||
// declare wrongly. Coarse (3 levels, ~19 km/px) but it always renders.
|
||||
//
|
||||
// NASA GIBS was tried first and rejected: its EPSG:4326 WMTS does serve the
|
||||
// poles, but it throttled us hard enough that tiles stalled mid-download,
|
||||
// leaving Cesium's navy base colour in wedges. A basemap that silently
|
||||
// degrades to blank is worse for flight planning than a coarse one that
|
||||
// works, so it is not wired in.
|
||||
polar: () =>
|
||||
TileMapServiceImageryProvider.fromUrl('/cesium/Assets/Textures/NaturalEarthII'),
|
||||
};
|
||||
|
||||
class CesiumMap implements IMap {
|
||||
readonly ready: Promise<void>;
|
||||
private viewer: Viewer;
|
||||
private handler: ScreenSpaceEventHandler;
|
||||
private scenes = new Map<string, CesiumScene>();
|
||||
private baseLayerSeq = 0;
|
||||
|
||||
constructor(init: MapInit) {
|
||||
this.viewer = new Viewer(init.container, {
|
||||
baseLayer: false, // valid because baseLayerPicker is false; avoids Ion
|
||||
terrainProvider: new EllipsoidTerrainProvider(),
|
||||
baseLayerPicker: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
navigationInstructionsInitiallyVisible: false,
|
||||
});
|
||||
this.setBaseLayer(init.baseLayer ?? 'osm');
|
||||
this.viewer.scene.screenSpaceCameraController.zoomFactor = ZOOM_FACTOR;
|
||||
this.setCenter(init.center, init.zoom);
|
||||
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
|
||||
// The Viewer is usable synchronously; imagery streams in afterwards.
|
||||
// Map.svelte gates children on this promise, so it must resolve.
|
||||
this.ready = Promise.resolve();
|
||||
}
|
||||
|
||||
/** Screen point -> lng/lat on the globe, or null if the pick missed the globe. */
|
||||
private pick(position: Cartesian2): { lat: number; lng: number } | null {
|
||||
const ray = this.viewer.camera.getPickRay(position);
|
||||
if (!ray) return null;
|
||||
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
|
||||
if (!hit) return null;
|
||||
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
|
||||
return { lat: CesiumMath.toDegrees(c.latitude), lng: CesiumMath.toDegrees(c.longitude) };
|
||||
}
|
||||
|
||||
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
|
||||
if (event === 'load') {
|
||||
// Cesium needs no style-load gate; fire immediately so callers proceed.
|
||||
(handler as MapEventHandler<'load'>)(undefined);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (event === 'click' || event === 'mousemove') {
|
||||
const type =
|
||||
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
|
||||
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
|
||||
const pos = e.position ?? e.endPosition;
|
||||
if (!pos) return;
|
||||
const lngLat = this.pick(pos);
|
||||
// Off-globe pointer events (empty space around the sphere) have no
|
||||
// coordinate. MapLibre never had this case; drop them.
|
||||
if (!lngLat) return;
|
||||
(handler as MapEventHandler<'click'>)({
|
||||
lngLat,
|
||||
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
|
||||
} as MapClickEvent);
|
||||
};
|
||||
this.handler.setInputAction(cb, type);
|
||||
return () => this.handler.removeInputAction(type);
|
||||
}
|
||||
|
||||
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
|
||||
const listener = () => {
|
||||
const c = this.viewer.camera.positionCartographic;
|
||||
const zoom = heightToZoom(c.height);
|
||||
if (event === 'move') {
|
||||
(handler as MapEventHandler<'move'>)({
|
||||
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
|
||||
zoom,
|
||||
});
|
||||
} else {
|
||||
(handler as MapEventHandler<'zoom'>)({ zoom });
|
||||
}
|
||||
};
|
||||
this.viewer.camera.changed.addEventListener(listener);
|
||||
return () => this.viewer.camera.changed.removeEventListener(listener);
|
||||
}
|
||||
|
||||
setCenter(pos: LngLatTuple, zoom?: number): void {
|
||||
this.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(pos[0], pos[1], zoomToHeight(zoom ?? this.getZoom())),
|
||||
});
|
||||
}
|
||||
|
||||
panTo(pos: LngLatTuple, durationMs = 500): void {
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
pos[0],
|
||||
pos[1],
|
||||
this.viewer.camera.positionCartographic.height,
|
||||
),
|
||||
duration: durationMs / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
|
||||
if (coords.length === 0) return;
|
||||
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
|
||||
const lats = coords.map((c) => c[0]);
|
||||
const lngs = coords.map((c) => c[1]);
|
||||
const rect = Rectangle.fromDegrees(
|
||||
Math.min(...lngs),
|
||||
Math.min(...lats),
|
||||
Math.max(...lngs),
|
||||
Math.max(...lats),
|
||||
);
|
||||
// A zero-area rectangle (single point, or a track that never moved) makes
|
||||
// Cesium fly to the centre of the earth. Pad so there is always area.
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
const pad = CesiumMath.toRadians(0.05);
|
||||
rect.west -= pad;
|
||||
rect.east += pad;
|
||||
rect.south -= pad;
|
||||
rect.north += pad;
|
||||
}
|
||||
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
|
||||
// ponytail: Cesium frames the rectangle itself; there is no pixel-padding
|
||||
// knob. Accepted and ignored to keep the IMap signature unchanged.
|
||||
void paddingPx;
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return heightToZoom(this.viewer.camera.positionCartographic.height);
|
||||
}
|
||||
|
||||
setZoom(zoom: number): void {
|
||||
const c = this.viewer.camera.positionCartographic;
|
||||
this.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
CesiumMath.toDegrees(c.longitude),
|
||||
CesiumMath.toDegrees(c.latitude),
|
||||
zoomToHeight(zoom),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setCursor(cursor: string | null): void {
|
||||
this.viewer.canvas.style.cursor = cursor ?? '';
|
||||
}
|
||||
|
||||
setBaseLayer(layer: BaseLayerId): void {
|
||||
// Some providers resolve asynchronously (they fetch service metadata), so
|
||||
// stamp each switch and let only the newest win — otherwise a slow earlier
|
||||
// request can land after a faster later one and show the wrong basemap.
|
||||
const seq = ++this.baseLayerSeq;
|
||||
void Promise.resolve((BASE_LAYERS[layer] ?? BASE_LAYERS.osm)())
|
||||
.then((provider) => {
|
||||
if (seq !== this.baseLayerSeq || this.viewer.isDestroyed()) return;
|
||||
// Replace rather than add: imageryLayers stack, so adding would leave
|
||||
// the previous basemap underneath and leak a layer on every change.
|
||||
this.viewer.imageryLayers.removeAll();
|
||||
this.viewer.imageryLayers.addImageryProvider(provider);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// Surfaced, not swallowed: a basemap that silently fails to appear is
|
||||
// the same failure mode as a wind field that silently reads zero.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[map] base layer "${layer}" failed to load`, e);
|
||||
});
|
||||
}
|
||||
|
||||
scene(name: string): MapScene {
|
||||
let s = this.scenes.get(name);
|
||||
if (!s) {
|
||||
s = new CesiumScene(name, this.viewer);
|
||||
this.scenes.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
disposeScene(name: string): void {
|
||||
const s = this.scenes.get(name);
|
||||
if (!s) return;
|
||||
s.dispose();
|
||||
this.scenes.delete(name);
|
||||
}
|
||||
|
||||
getRawInstance(): Viewer {
|
||||
return this.viewer;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const s of this.scenes.values()) s.dispose();
|
||||
this.scenes.clear();
|
||||
this.handler.destroy();
|
||||
this.viewer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createCesiumMap(init: MapInit): IMap {
|
||||
return new CesiumMap(init);
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ import type { LatLngTuple, LngLatTuple } from '$domain';
|
|||
* Map abstraction.
|
||||
*
|
||||
* Goals:
|
||||
* - Isolate all MapLibre-specific types inside src/lib/map/maplibre.ts.
|
||||
* - Isolate all Cesium-specific types inside src/lib/map/cesium.ts.
|
||||
* - Expose a small, map-library-agnostic vocabulary (markers, polylines,
|
||||
* icons, events) so features (workspaces, timeline, tools) can be tested
|
||||
* against the interface alone.
|
||||
* - Support "scenes" — named collections of layers owned by a feature so
|
||||
* each workspace/tool can add/remove everything it owns atomically.
|
||||
*
|
||||
* If another library ever replaces MapLibre, implementing IMap is the only
|
||||
* If another library ever replaces Cesium, implementing IMap is the only
|
||||
* file that changes.
|
||||
*/
|
||||
|
||||
|
|
@ -34,6 +34,11 @@ export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => voi
|
|||
|
||||
export interface MarkerOptions {
|
||||
lngLat: LngLatTuple;
|
||||
/**
|
||||
* Metres above the ellipsoid. Renderers that cannot show altitude ignore it.
|
||||
* Used so the burst marker sits at the apex instead of on the ground under it.
|
||||
*/
|
||||
altitude?: number;
|
||||
iconUrl?: string;
|
||||
iconSize?: [number, number];
|
||||
className?: string;
|
||||
|
|
@ -42,6 +47,7 @@ export interface MarkerOptions {
|
|||
}
|
||||
|
||||
export interface LineOptions {
|
||||
/** Joined by great circles. Callers that need another curve sample it themselves. */
|
||||
coords: LatLngTuple[];
|
||||
color?: string;
|
||||
width?: number;
|
||||
|
|
@ -95,6 +101,12 @@ export interface IMap {
|
|||
|
||||
setCursor(cursor: string | null): void;
|
||||
|
||||
/**
|
||||
* Swap the base imagery in place, keeping the current camera. Needed because
|
||||
* the base layer is a user setting that can change after the map is built.
|
||||
*/
|
||||
setBaseLayer(layer: NonNullable<MapInit['baseLayer']>): void;
|
||||
|
||||
/**
|
||||
* Get or create a named scene. Scenes are the unit of layer ownership:
|
||||
* a feature adds all its layers through a scene and calls `.clear()` to
|
||||
|
|
@ -109,11 +121,20 @@ export interface IMap {
|
|||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available base imagery.
|
||||
*
|
||||
* `satellite` (Esri) is Web Mercator: high resolution but no tiles above
|
||||
* 85.0511°, so the poles are blank. `polar` trades resolution for a geographic
|
||||
* (EPSG:4326) source that covers ±90°.
|
||||
*/
|
||||
export type BaseLayerId = 'osm' | 'satellite' | 'polar';
|
||||
|
||||
export interface MapInit {
|
||||
container: HTMLElement;
|
||||
center: LngLatTuple;
|
||||
zoom: number;
|
||||
baseLayer?: 'osm' | 'satellite';
|
||||
baseLayer?: BaseLayerId;
|
||||
showNavigationControl?: boolean;
|
||||
showScaleControl?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './core';
|
||||
export { createMapLibreMap } from './maplibre';
|
||||
export { createCesiumMap } from './cesium';
|
||||
export {
|
||||
plotPrediction,
|
||||
plotTelemetry,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { toLngLat, boundingBoxRing } from '$domain';
|
|||
import type { IMap, Scene } from './core';
|
||||
|
||||
/**
|
||||
* Plot helpers for high-level domain objects. These live outside MapLibreMap
|
||||
* so they can be reused against any IMap implementation.
|
||||
* Plot helpers for high-level domain objects. These live outside the concrete
|
||||
* map class so they can be reused against any IMap implementation.
|
||||
*
|
||||
* Icons are served from /static; pass explicit overrides if a workspace
|
||||
* should use custom markers.
|
||||
|
|
@ -62,6 +62,9 @@ export function plotPrediction(
|
|||
|
||||
scene.addMarker('burst', {
|
||||
lngLat: toLngLat(prediction.burst.latlng),
|
||||
// Burst happens at ~30 km; on a globe the marker belongs at the apex of
|
||||
// the track, not on the ground beneath it.
|
||||
altitude: prediction.burst.latlng.alt,
|
||||
iconUrl: s.burstIcon,
|
||||
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
|
||||
popupHtml: `<b>Burst</b><br>${prediction.burst.latlng.lat.toFixed(6)}, ${prediction.burst.latlng.lng.toFixed(6)}`,
|
||||
|
|
@ -143,6 +146,8 @@ export interface BoundingBoxStyle {
|
|||
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
|
||||
scene.clear();
|
||||
scene.addLine('box', {
|
||||
// Already sampled along its great-circle edges, so the drawn shape is the
|
||||
// filed shape and does not depend on the renderer's interpolation.
|
||||
coords: boundingBoxRing(box),
|
||||
color: style.color ?? '#0d6efd',
|
||||
width: style.width ?? 3,
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
import maplibregl, {
|
||||
type Map as MLMap,
|
||||
type LngLatLike,
|
||||
type MarkerOptions as MLMarkerOptions,
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
import type {
|
||||
CircleOptions,
|
||||
IMap,
|
||||
LineOptions,
|
||||
MapEvent,
|
||||
MapEventHandler,
|
||||
MapEventPayload,
|
||||
MapInit,
|
||||
MapLayer,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
Scene,
|
||||
} from './core';
|
||||
import type { LatLngTuple, LngLatTuple } from '$domain';
|
||||
|
||||
/** Map common base-layer names to MapLibre style JSON. */
|
||||
const BASE_STYLES: Record<NonNullable<MapInit['baseLayer']>, maplibregl.StyleSpecification> = {
|
||||
osm: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }],
|
||||
},
|
||||
satellite: {
|
||||
version: 8,
|
||||
sources: {
|
||||
sat: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: 'Tiles © Esri',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'sat', type: 'raster', source: 'sat', minzoom: 0, maxzoom: 19 }],
|
||||
},
|
||||
};
|
||||
|
||||
class MapLibreScene implements Scene {
|
||||
private sources = new Set<string>();
|
||||
private layers = new Set<string>();
|
||||
private markers = new Map<string, maplibregl.Marker>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private map: MLMap,
|
||||
) {}
|
||||
|
||||
private scopeId(id: string): string {
|
||||
return `${this.name}__${id}`;
|
||||
}
|
||||
|
||||
addLine(id: string, options: LineOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
const coords = options.coords.map<[number, number]>((c) => [c[1], c[0]]);
|
||||
|
||||
if (this.map.getSource(layerId)) this.remove(id);
|
||||
|
||||
this.map.addSource(layerId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
},
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: layerId,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: {
|
||||
'line-color': options.color ?? '#000',
|
||||
'line-width': options.width ?? 3,
|
||||
'line-opacity': options.opacity ?? 1,
|
||||
...(options.dashArray ? { 'line-dasharray': options.dashArray } : {}),
|
||||
},
|
||||
});
|
||||
this.sources.add(layerId);
|
||||
this.layers.add(layerId);
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
const existing = this.map.getSource(layerId) as maplibregl.GeoJSONSource | undefined;
|
||||
if (existing) {
|
||||
existing.setData({
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Point', coordinates: options.center },
|
||||
});
|
||||
return { id: layerId, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
this.map.addSource(layerId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Point', coordinates: options.center },
|
||||
},
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: layerId,
|
||||
type: 'circle',
|
||||
source: layerId,
|
||||
paint: {
|
||||
'circle-radius': options.radiusPx ?? 6,
|
||||
'circle-color': options.color ?? '#0b5ed7',
|
||||
'circle-opacity': options.opacity ?? 1,
|
||||
'circle-stroke-color': options.strokeColor ?? '#ffffff',
|
||||
'circle-stroke-width': options.strokeWidth ?? 2,
|
||||
},
|
||||
});
|
||||
this.sources.add(layerId);
|
||||
this.layers.add(layerId);
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
addMarker(id: string, options: MarkerOptions): Marker {
|
||||
const scoped = this.scopeId(id);
|
||||
const existing = this.markers.get(scoped);
|
||||
if (existing) existing.remove();
|
||||
|
||||
let mlOptions: MLMarkerOptions | undefined;
|
||||
if (options.iconUrl) {
|
||||
const el = document.createElement('div');
|
||||
el.className = options.className ?? 'lsv-marker';
|
||||
el.style.backgroundImage = `url(${options.iconUrl})`;
|
||||
const [w, h] = options.iconSize ?? [12, 12];
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.backgroundSize = '100%';
|
||||
mlOptions = { element: el };
|
||||
}
|
||||
|
||||
const marker = new maplibregl.Marker(mlOptions).setLngLat(options.lngLat as LngLatLike);
|
||||
|
||||
if (options.popupHtml) {
|
||||
const popup = new maplibregl.Popup({ offset: 16, closeButton: false }).setHTML(
|
||||
options.popupHtml,
|
||||
);
|
||||
marker.setPopup(popup);
|
||||
marker.getElement().addEventListener('mouseenter', () => marker.togglePopup());
|
||||
marker.getElement().addEventListener('mouseleave', () => marker.togglePopup());
|
||||
}
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.markers.set(scoped, marker);
|
||||
|
||||
return {
|
||||
setLngLat: (pos) => marker.setLngLat(pos as LngLatLike),
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const scoped = this.scopeId(id);
|
||||
if (this.map.getLayer(scoped)) this.map.removeLayer(scoped);
|
||||
if (this.map.getSource(scoped)) this.map.removeSource(scoped);
|
||||
this.layers.delete(scoped);
|
||||
this.sources.delete(scoped);
|
||||
const marker = this.markers.get(scoped);
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
this.markers.delete(scoped);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const id of Array.from(this.layers)) {
|
||||
if (this.map.getLayer(id)) this.map.removeLayer(id);
|
||||
}
|
||||
for (const id of Array.from(this.sources)) {
|
||||
if (this.map.getSource(id)) this.map.removeSource(id);
|
||||
}
|
||||
for (const marker of this.markers.values()) marker.remove();
|
||||
this.layers.clear();
|
||||
this.sources.clear();
|
||||
this.markers.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class MapLibreMap implements IMap {
|
||||
private map: MLMap;
|
||||
private scenes = new Map<string, MapLibreScene>();
|
||||
public readonly ready: Promise<void>;
|
||||
|
||||
constructor(init: MapInit) {
|
||||
this.map = new maplibregl.Map({
|
||||
container: init.container,
|
||||
style: BASE_STYLES[init.baseLayer ?? 'osm'],
|
||||
center: init.center,
|
||||
zoom: init.zoom,
|
||||
});
|
||||
|
||||
if (init.showNavigationControl !== false) {
|
||||
this.map.addControl(new maplibregl.NavigationControl(), 'bottom-left');
|
||||
}
|
||||
if (init.showScaleControl !== false) {
|
||||
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 100, unit: 'metric' }), 'bottom-right');
|
||||
}
|
||||
|
||||
this.ready = new Promise((resolve) => this.map.once('load', () => resolve()));
|
||||
}
|
||||
|
||||
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
|
||||
const wrapped = (e: unknown) => {
|
||||
switch (event) {
|
||||
case 'click':
|
||||
case 'mousemove': {
|
||||
const ev = e as maplibregl.MapMouseEvent;
|
||||
(handler as MapEventHandler<'click'>)({
|
||||
lngLat: { lat: ev.lngLat.lat, lng: ev.lngLat.lng },
|
||||
originalEvent: ev.originalEvent,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'move':
|
||||
(handler as MapEventHandler<'move'>)({
|
||||
center: [this.map.getCenter().lng, this.map.getCenter().lat],
|
||||
zoom: this.map.getZoom(),
|
||||
});
|
||||
break;
|
||||
case 'zoom':
|
||||
(handler as MapEventHandler<'zoom'>)({ zoom: this.map.getZoom() });
|
||||
break;
|
||||
case 'load':
|
||||
(handler as MapEventHandler<'load'>)(undefined as MapEventPayload['load']);
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.map.on(event as 'click', wrapped);
|
||||
return () => this.map.off(event as 'click', wrapped);
|
||||
}
|
||||
|
||||
setCenter(pos: LngLatTuple, zoom?: number): void {
|
||||
this.map.setCenter(pos);
|
||||
if (zoom !== undefined) this.map.setZoom(zoom);
|
||||
}
|
||||
|
||||
panTo(pos: LngLatTuple, durationMs?: number): void {
|
||||
this.map.panTo(pos, durationMs ? { duration: durationMs } : undefined);
|
||||
}
|
||||
|
||||
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
|
||||
if (coords.length === 0) return;
|
||||
const first: [number, number] = [coords[0][1], coords[0][0]];
|
||||
const bounds = coords.reduce(
|
||||
(b, c) => b.extend([c[1], c[0]] as [number, number]),
|
||||
new maplibregl.LngLatBounds(first, first),
|
||||
);
|
||||
this.map.fitBounds(bounds, { padding: paddingPx });
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return this.map.getZoom();
|
||||
}
|
||||
|
||||
setZoom(zoom: number): void {
|
||||
this.map.setZoom(zoom);
|
||||
}
|
||||
|
||||
setCursor(cursor: string | null): void {
|
||||
this.map.getCanvas().style.cursor = cursor ?? '';
|
||||
}
|
||||
|
||||
scene(name: string): Scene {
|
||||
let scene = this.scenes.get(name);
|
||||
if (!scene) {
|
||||
scene = new MapLibreScene(name, this.map);
|
||||
this.scenes.set(name, scene);
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
|
||||
disposeScene(name: string): void {
|
||||
const scene = this.scenes.get(name);
|
||||
if (!scene) return;
|
||||
scene.dispose();
|
||||
this.scenes.delete(name);
|
||||
}
|
||||
|
||||
getRawInstance(): MLMap {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const s of this.scenes.values()) s.dispose();
|
||||
this.scenes.clear();
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMapLibreMap(init: MapInit): IMap {
|
||||
return new MapLibreMap(init);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import type { IMap } from '$map';
|
||||
import { startCoordinateSelection } from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { MapChrome } from '$features/mapchrome';
|
||||
import { PanelContainer, TabBar } from '$ui';
|
||||
import { addToast, removeToast } from '$ui';
|
||||
import { ControlPanel, ScenarioPanel } from '$features/prediction';
|
||||
|
|
@ -12,7 +13,6 @@
|
|||
WorkspaceRenderer,
|
||||
workspacesStore,
|
||||
} from '$features/workspaces';
|
||||
import { WindRenderer } from '$features/wind';
|
||||
import { SettingsPanel } from '$features/settings';
|
||||
import { TimeLine } from '$features/timeline';
|
||||
import { t } from '$i18n';
|
||||
|
|
@ -72,8 +72,8 @@
|
|||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||
<MapChrome />
|
||||
<WorkspaceRenderer />
|
||||
<WindRenderer />
|
||||
|
||||
<PanelContainer position="left">
|
||||
<TabBar
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
type IMap,
|
||||
} from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { MapChrome } from '$features/mapchrome';
|
||||
import { PanelContainer, CollapsibleCard } from '$ui';
|
||||
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
||||
import { workspacesStore } from '$features/workspaces';
|
||||
|
|
@ -116,6 +117,7 @@
|
|||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView onReady={onMapReady}>
|
||||
<MapChrome />
|
||||
<PanelContainer position="left">
|
||||
<TelemetryPanel />
|
||||
</PanelContainer>
|
||||
|
|
|
|||
123
tests/e2e/bbox.spec.ts
Normal file
123
tests/e2e/bbox.spec.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* Browser-side checks for the restricted-area box. The geometry itself is
|
||||
* covered in tests/unit/boundingBox.spec.ts, which runs in Node against the pure
|
||||
* module; what only a browser can answer is whether the thing rasterises and
|
||||
* what the operator ends up reading on screen.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** Seed one workspace at the given launch point, run it, and draw its box. */
|
||||
async function runAt(page: import('@playwright/test').Page, lat: number, lng: number) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ lat, lng }) => {
|
||||
const ws = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `bbox-${lat}`,
|
||||
color: '#dc3545',
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
flightParameters: {
|
||||
ascent_rate: 5,
|
||||
burst_altitude: 30000,
|
||||
dataset: '',
|
||||
descent_rate: 5,
|
||||
format: 'json',
|
||||
launch_altitude: 0,
|
||||
launch_latitude: lat,
|
||||
launch_longitude: lng,
|
||||
profile: 'standard_profile',
|
||||
version: 2,
|
||||
},
|
||||
launchDate: new Date().toISOString().split('T')[0],
|
||||
launchTime: '12:00:00',
|
||||
result: null,
|
||||
bboxMargin: 5,
|
||||
bboxVisible: false,
|
||||
};
|
||||
localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id }));
|
||||
},
|
||||
{ lat, lng },
|
||||
);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const panel = page.locator('.panel-container-right');
|
||||
await panel
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000] })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await panel.getByRole('button', { name: /Построить рамку|Generate bounding box/ }).click();
|
||||
await expect.poll(() => sceneObjectCount(page, 'bbox'), { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the box must actually rasterise.
|
||||
*
|
||||
* An earlier attempt made a pole-touching box circumpolar with west=-180 and
|
||||
* east=180. Both meridional edges then lay on the antimeridian, and Cesium's
|
||||
* splitLongitude pass — which cuts geometry at the IDL — produced mismatched
|
||||
* attribute lists and killed the render loop:
|
||||
*
|
||||
* DeveloperError: All attribute lists must have the same number of attributes.
|
||||
* at k.splitLongitude / S.combineGeometry
|
||||
*
|
||||
* Assertions on polyline.positions could not see this, because that is the entity
|
||||
* definition and geometry is combined later in a worker. This listens for the
|
||||
* render failure itself.
|
||||
*/
|
||||
test('drawing a polar box does not break the renderer', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runAt(page, 89.99, 30);
|
||||
|
||||
await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
(window as any).__renderErrors = [];
|
||||
v.scene.renderError.addEventListener((_s: unknown, e: any) =>
|
||||
(window as any).__renderErrors.push(String(e?.message ?? e)),
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const errors = await page.evaluate(
|
||||
() => (window as never as { __renderErrors: string[] }).__renderErrors,
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
// Cesium swaps in an error panel when the render loop dies.
|
||||
await expect(page.locator('.cesium-widget-errorPanel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the panel reports a corridor-sized area at the pole, not a polar cap', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
const panel = await runAt(page, 89.99, 30);
|
||||
|
||||
// Near a pole the four corners are unreadable on their own — a box whose north
|
||||
// edge passes over the pole comes back down the far side — so the size line is
|
||||
// what the operator actually checks. The lat/lon-rectangle form reported the
|
||||
// whole cap north of 88.93, 44 200 km^2, for a corridor of about 1 700.
|
||||
const size = panel.locator('.font-monospace', { hasText: 'km @' }).first();
|
||||
await expect(size).toBeVisible();
|
||||
const text = (await size.textContent()) ?? '';
|
||||
const [w, h] = text.split('km @')[0].split('×').map((s) => parseFloat(s));
|
||||
|
||||
expect(w).toBeGreaterThan(0);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
expect(w * h).toBeLessThan(10_000);
|
||||
});
|
||||
77
tests/e2e/dataset-param.spec.ts
Normal file
77
tests/e2e/dataset-param.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { test, expect, login } from './fixtures';
|
||||
|
||||
/**
|
||||
* The client must not invent a dataset.
|
||||
*
|
||||
* `predictionsApi.run` used to fall back to a client-side guess at which GFS run
|
||||
* the server held, and the guess had rotted into a hardcoded "2025-04-06T00:00:00Z"
|
||||
* — over a year stale. That was harmless only for as long as Django dropped the
|
||||
* parameter on the floor. Now that the predictor honours it and refuses a run it
|
||||
* does not hold, sending an invented epoch fails every prediction with
|
||||
* "dataset 2025-04-06T00:00:00Z is not stored".
|
||||
*
|
||||
* Which runs exist is server knowledge. An unchosen dataset must stay absent so
|
||||
* the server picks.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
test('a prediction request carries no dataset the operator did not choose', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const bodies: string[] = [];
|
||||
await page.route('**/predictions/', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
bodies.push(route.request().postData() ?? '');
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
const ws = {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'dataset-param',
|
||||
color: '#dc3545',
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
flightParameters: {
|
||||
ascent_rate: 5,
|
||||
burst_altitude: 30000,
|
||||
dataset: '', // the UI's "choose automatically"
|
||||
descent_rate: 5,
|
||||
format: 'json',
|
||||
launch_altitude: 0,
|
||||
launch_latitude: 52.2,
|
||||
launch_longitude: 0.1,
|
||||
profile: 'standard_profile',
|
||||
version: 2,
|
||||
},
|
||||
launchDate: new Date().toISOString().split('T')[0],
|
||||
launchTime: '12:00:00',
|
||||
result: null,
|
||||
};
|
||||
localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id }));
|
||||
});
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
await page
|
||||
.locator('.panel-container-right .workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => bodies.length, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
|
||||
const payload = JSON.parse(bodies[0]);
|
||||
expect(payload).not.toHaveProperty('dataset');
|
||||
// The rest of the request must still be intact.
|
||||
expect(payload.launch_latitude).toBe(52.2);
|
||||
expect(payload.launch_datetime).toBeTruthy();
|
||||
});
|
||||
121
tests/e2e/export.spec.ts
Normal file
121
tests/e2e/export.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* Prediction export. The UI shell (format select + Export button) existed with
|
||||
* no handler wired at all, so nothing was ever produced.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** Run a prediction so there is a result to export. */
|
||||
async function runPrediction(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
await page
|
||||
.locator('.panel-container-right')
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 90_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/** Pick a format in the export select and click Export; return the file text. */
|
||||
async function exportAs(
|
||||
page: import('@playwright/test').Page,
|
||||
format: string,
|
||||
): Promise<{ name: string; text: string }> {
|
||||
const group = page.locator('.panel-container-left .input-group', {
|
||||
has: page.getByRole('button', { name: /Экспорт|Export/ }),
|
||||
});
|
||||
await group.locator('select').selectOption(format);
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
group.getByRole('button', { name: /Экспорт|Export/ }).click(),
|
||||
]);
|
||||
const path = await download.path();
|
||||
if (!path) throw new Error('download produced no file');
|
||||
return { name: download.suggestedFilename(), text: readFileSync(path, 'utf8') };
|
||||
}
|
||||
|
||||
test('exports the trajectory as CSV', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'CSV');
|
||||
expect(name).toMatch(/\.csv$/);
|
||||
|
||||
const lines = text.trim().split('\n');
|
||||
expect(lines[0]).toBe('datetime,latitude,longitude,altitude');
|
||||
// One row per trajectory point.
|
||||
expect(lines.length).toBeGreaterThan(50);
|
||||
// Every data row: ISO timestamp + three numbers.
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line.split(',');
|
||||
expect(cols).toHaveLength(4);
|
||||
expect(cols[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
for (const n of cols.slice(1)) expect(Number.isFinite(Number(n))).toBe(true);
|
||||
}
|
||||
// Altitude must actually rise — proves real data, not zeros.
|
||||
const alts = lines.slice(1).map((l) => Number(l.split(',')[3]));
|
||||
expect(Math.max(...alts)).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
test('exports the trajectory as JSON', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'JSON');
|
||||
expect(name).toMatch(/\.json$/);
|
||||
|
||||
const data = JSON.parse(text) as {
|
||||
launch: { latitude: number; longitude: number; datetime: string };
|
||||
burst: { altitude: number };
|
||||
landing: { latitude: number; longitude: number };
|
||||
flight_time: number;
|
||||
trajectory: { datetime: string; latitude: number; longitude: number; altitude: number }[];
|
||||
};
|
||||
expect(data.trajectory.length).toBeGreaterThan(50);
|
||||
expect(Number.isFinite(data.launch.latitude)).toBe(true);
|
||||
expect(data.burst.altitude).toBeGreaterThan(10_000);
|
||||
expect(Number.isFinite(data.landing.longitude)).toBe(true);
|
||||
expect(data.flight_time).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('exports the trajectory as KML with absolute altitude', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'KML');
|
||||
expect(name).toMatch(/\.kml$/);
|
||||
|
||||
expect(text).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(text).toContain('<kml xmlns="http://www.opengis.net/kml/2.2">');
|
||||
// A balloon track is not a ground feature: it must carry its own altitude,
|
||||
// otherwise Google Earth drapes the 30 km arc onto the terrain.
|
||||
expect(text).toContain('<altitudeMode>absolute</altitudeMode>');
|
||||
expect(text).toContain('<LineString>');
|
||||
// Placemarks for the three key events.
|
||||
for (const n of ['Launch', 'Burst', 'Landing']) expect(text).toContain(`<name>${n}</name>`);
|
||||
|
||||
// Coordinates are lon,lat,alt triples — note the order differs from CSV.
|
||||
const coords = /<coordinates>([\s\S]*?)<\/coordinates>/.exec(text);
|
||||
expect(coords).not.toBeNull();
|
||||
const triples = (coords as RegExpExecArray)[1].trim().split(/\s+/);
|
||||
expect(triples.length).toBeGreaterThan(50);
|
||||
const alts = triples.map((tr) => Number(tr.split(',')[2]));
|
||||
expect(Math.max(...alts)).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
|
@ -65,6 +65,25 @@ export const test = base.extend({
|
|||
|
||||
export { expect };
|
||||
|
||||
/**
|
||||
* Count map objects belonging to scenes whose name starts with `prefix`.
|
||||
*
|
||||
* Renderer-specific: CesiumScene scopes every entity id as `<scene>__<id>`
|
||||
* (see src/lib/map/cesium-scene.ts), so scene membership is read off the id.
|
||||
* Kept in fixtures so a renderer swap touches one place, not every spec.
|
||||
*/
|
||||
export function sceneObjectCount(page: Page, prefix: string): Promise<number> {
|
||||
return page.evaluate((p) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const viewer: any = (window as any)._lsvMap;
|
||||
if (!viewer?.entities) return 0;
|
||||
return viewer.entities.values.filter((e: { id?: string }) =>
|
||||
typeof e.id === 'string' ? e.id.startsWith(p) : false,
|
||||
).length;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}, prefix);
|
||||
}
|
||||
|
||||
export async function openPredict(page: Page) {
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
|
|
|
|||
144
tests/e2e/globe.spec.ts
Normal file
144
tests/e2e/globe.spec.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* Regression guard for the Mercator -> globe migration.
|
||||
*
|
||||
* The MapLibre implementation tiled every GeoJSON source through
|
||||
* @maplibre/geojson-vt, whose projectY() clamps its result to [0,1] — so any
|
||||
* vertex above 85.051129° collapsed onto that parallel and a polar trajectory
|
||||
* rendered as a straight line along it. Cesium converts degrees straight to
|
||||
* Cartesian3, so latitude must survive intact. If a Mercator-tiled render path
|
||||
* is ever reintroduced, this test fails.
|
||||
*/
|
||||
|
||||
const MERCATOR_LIMIT = 85.051129;
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
test('globe preserves latitude above the Mercator limit', async ({ page }) => {
|
||||
// Cesium ships ~7 MB of workers/assets; first paint is slower than MapLibre.
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
// Map.svelte exposes the raw Cesium Viewer on window._lsvMap in dev builds.
|
||||
await page.waitForFunction(
|
||||
() => (window as unknown as { _lsvMap?: { scene?: unknown } })._lsvMap?.scene !== undefined,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
const latitudes: number[] = await page.evaluate((limit) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const viewer = (window as any)._lsvMap;
|
||||
// Cesium is an ES module, not a global. Reach its constructors through
|
||||
// live objects the Viewer already holds: camera.position is a Cartesian3
|
||||
// and positionCartographic is a Cartographic, both carrying the statics.
|
||||
const Cartesian3: any = viewer.camera.position.constructor;
|
||||
const Cartographic: any = viewer.camera.positionCartographic.constructor;
|
||||
|
||||
const wanted = [89.0, limit + 0.001, 89.99, 90.0];
|
||||
const positions = Cartesian3.fromDegreesArray(wanted.flatMap((lat: number) => [10, lat]));
|
||||
viewer.entities.add({ polyline: { positions, width: 3 } });
|
||||
|
||||
return positions.map(
|
||||
(p: unknown) => (Cartographic.fromCartesian(p).latitude * 180) / Math.PI,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}, MERCATOR_LIMIT);
|
||||
|
||||
expect(latitudes).toHaveLength(4);
|
||||
// Every vertex round-trips to the latitude it was given.
|
||||
expect(latitudes[0]).toBeCloseTo(89.0, 6);
|
||||
expect(latitudes[1]).toBeCloseTo(MERCATOR_LIMIT + 0.001, 6);
|
||||
expect(latitudes[2]).toBeCloseTo(89.99, 6);
|
||||
expect(latitudes[3]).toBeCloseTo(90.0, 6);
|
||||
// And none got pinned to the old Mercator ceiling.
|
||||
for (const lat of latitudes.slice(1)) {
|
||||
expect(lat).toBeGreaterThan(MERCATOR_LIMIT);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The flight_path tuples carry altitude as an optional third element
|
||||
* (domain/geo.ts: LatLngTuple), filled in by parsePrediction. MapLibre could
|
||||
* not use it, so it sat unread; on a globe it must become real geometry —
|
||||
* otherwise a 30 km balloon flight renders as a flat line on the ground.
|
||||
*/
|
||||
test.describe('altitude', () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** Heights (metres) of every vertex of the rendered workspace path. */
|
||||
function pathHeights(page: import('@playwright/test').Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const C: any = v.camera.positionCartographic.constructor;
|
||||
const e = v.entities.values.find((x: any) => String(x.id).endsWith('__path'));
|
||||
if (!e) return [];
|
||||
return e.polyline.positions
|
||||
.getValue(v.clock.currentTime)
|
||||
.map((p: unknown) => C.fromCartesian(p).height);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
}
|
||||
|
||||
async function runPrediction(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
await page
|
||||
.locator('.panel-container-right')
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 90_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test('trajectory vertices carry real altitude, not ground level', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const heights = await pathHeights(page);
|
||||
expect(heights.length).toBeGreaterThan(10);
|
||||
// Burst is at 30 km; the apex must be somewhere near it, definitely not 0.
|
||||
expect(Math.max(...heights)).toBeGreaterThan(10_000);
|
||||
// Launch/landing sit on the ground, so the minimum stays low.
|
||||
expect(Math.min(...heights)).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
test('burst marker sits at its altitude, not on the ground', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const burstHeight = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const C: any = v.camera.positionCartographic.constructor;
|
||||
const e = v.entities.values.find((x: any) => String(x.id).endsWith('__burst'));
|
||||
if (!e) return null;
|
||||
return C.fromCartesian(e.position.getValue(v.clock.currentTime)).height;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
expect(burstHeight).not.toBeNull();
|
||||
expect(burstHeight as number).toBeGreaterThan(10_000);
|
||||
});
|
||||
});
|
||||
291
tests/e2e/map-controls.spec.ts
Normal file
291
tests/e2e/map-controls.spec.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { test, expect, login } from './fixtures';
|
||||
|
||||
/**
|
||||
* Coordinate bounds, graticule and basemap switching.
|
||||
*
|
||||
* Bounds are now geographic reality rather than a workaround: the predictor
|
||||
* integrates along great circles and handles latitude 90 exactly, so the old
|
||||
* 89.999 mitigation is gone. Latitude clamps because a pole is a real barrier;
|
||||
* longitude wraps because a meridian is not.
|
||||
*/
|
||||
|
||||
const MAX_LAUNCH_LAT = 90;
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** The active workspace's persisted launch latitude. */
|
||||
function storedLaunchLat(page: import('@playwright/test').Page): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
const raw = localStorage.getItem('workspaces');
|
||||
if (!raw) return null;
|
||||
const slice = JSON.parse(raw) as {
|
||||
items: { flightParameters?: { launch_latitude?: number } }[];
|
||||
};
|
||||
return slice.items[0]?.flightParameters?.launch_latitude ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The active workspace's persisted launch longitude. */
|
||||
function storedLaunchLng(page: import('@playwright/test').Page): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
const raw = localStorage.getItem('workspaces');
|
||||
if (!raw) return null;
|
||||
const slice = JSON.parse(raw) as {
|
||||
items: { flightParameters?: { launch_longitude?: number } }[];
|
||||
};
|
||||
return slice.items[0]?.flightParameters?.launch_longitude ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async function openConditions(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
await page.getByRole('button', { name: /Условия|Conditions/ }).click();
|
||||
}
|
||||
|
||||
test('latitude is clamped to the real poles, not to a workaround value', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openConditions(page);
|
||||
|
||||
const latInput = page.locator('.panel-container-left input[type="number"]').first();
|
||||
|
||||
await latInput.fill('91');
|
||||
await latInput.dispatchEvent('input');
|
||||
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(MAX_LAUNCH_LAT);
|
||||
|
||||
await latInput.fill('-91');
|
||||
await latInput.dispatchEvent('input');
|
||||
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(-MAX_LAUNCH_LAT);
|
||||
});
|
||||
|
||||
test('latitudes the old mitigation rejected are now accepted verbatim', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openConditions(page);
|
||||
|
||||
const latInput = page.locator('.panel-container-left input[type="number"]').first();
|
||||
for (const v of [89.9999, 90, -90]) {
|
||||
await latInput.fill(String(v));
|
||||
await latInput.dispatchEvent('input');
|
||||
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('longitude wraps rather than clamping', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openConditions(page);
|
||||
|
||||
// A meridian is not a barrier: 200 E is the same place as -160, so wrapping
|
||||
// keeps the launch site the user meant. Clamping to 180 would move it.
|
||||
const lngInput = page.locator('.panel-container-left input[type="number"]').nth(1);
|
||||
for (const [typed, want] of [
|
||||
[181, -179],
|
||||
[-181, 179],
|
||||
[400, 40],
|
||||
[-200, 160],
|
||||
[129.1234, 129.1234],
|
||||
] as const) {
|
||||
await lngInput.fill(String(typed));
|
||||
await lngInput.dispatchEvent('input');
|
||||
await expect.poll(() => storedLaunchLng(page), { timeout: 10_000 }).toBeCloseTo(want, 6);
|
||||
}
|
||||
});
|
||||
|
||||
test('graticule is drawn by default', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const grid = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const lines = v.entities.values.filter((e: any) => String(e.id).startsWith('graticule'));
|
||||
return {
|
||||
count: lines.length,
|
||||
allPolylines: lines.every((e: any) => !!e.polyline),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
// Sparse on purpose: few enough lines to stay readable at any zoom.
|
||||
expect(grid.count).toBeGreaterThan(4);
|
||||
expect(grid.count).toBeLessThan(40);
|
||||
expect(grid.allPolylines).toBe(true);
|
||||
});
|
||||
|
||||
test('switching the base layer swaps the imagery provider', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const providerUrl = () =>
|
||||
page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
for (let i = v.imageryLayers.length - 1; i >= 0; i--) {
|
||||
const u = v.imageryLayers.get(i).imageryProvider?.url;
|
||||
if (typeof u === 'string') return u;
|
||||
}
|
||||
return null;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
expect(await providerUrl()).toContain('openstreetmap');
|
||||
|
||||
// Flip the persisted setting the way the settings panel does.
|
||||
await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('settings');
|
||||
const s = raw ? JSON.parse(raw) : {};
|
||||
s.map = { ...(s.map ?? {}), baseLayer: 'satellite' };
|
||||
localStorage.setItem('settings', JSON.stringify(s));
|
||||
});
|
||||
await page.reload();
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
await expect.poll(providerUrl, { timeout: 15_000 }).toContain('arcgisonline');
|
||||
});
|
||||
|
||||
test('meridians converge at both poles', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const meridians = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const C: any = v.camera.positionCartographic.constructor;
|
||||
const out: { id: string; minLat: number; maxLat: number }[] = [];
|
||||
for (const e of v.entities.values) {
|
||||
// graticule meridians are scoped `graticule__m<lng>`
|
||||
if (!/graticule__m-?\d+$/.test(String(e.id))) continue;
|
||||
const lats = e.polyline.positions
|
||||
.getValue(v.clock.currentTime)
|
||||
.map((p: unknown) => (C.fromCartesian(p).latitude * 180) / Math.PI);
|
||||
out.push({ id: String(e.id), minLat: Math.min(...lats), maxLat: Math.max(...lats) });
|
||||
}
|
||||
return out;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
expect(meridians.length).toBeGreaterThan(4);
|
||||
// Every meridian must run all the way from pole to pole, so they all meet
|
||||
// at a single point at each end — that is what a globe graticule looks like.
|
||||
for (const m of meridians) {
|
||||
expect(m.maxLat).toBeCloseTo(90, 3);
|
||||
expect(m.minLat).toBeCloseTo(-90, 3);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The Esri "satellite" layer is Web Mercator, so it has no tiles above
|
||||
* 85.0511° and the pole renders as blank blue. A polar-capable layer must use
|
||||
* a geographic (EPSG:4326) tiling scheme, which covers ±90° by construction.
|
||||
*/
|
||||
test('polar base layer uses a geographic tiling scheme', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/predict');
|
||||
await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('settings');
|
||||
const s = raw ? JSON.parse(raw) : { locale: 'ru' };
|
||||
s.map = { ...(s.map ?? {}), baseLayer: 'polar' };
|
||||
localStorage.setItem('settings', JSON.stringify(s));
|
||||
});
|
||||
await page.reload();
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const readLayer = () =>
|
||||
page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
for (let i = v.imageryLayers.length - 1; i >= 0; i--) {
|
||||
const p = v.imageryLayers.get(i).imageryProvider;
|
||||
if (typeof p?.url === 'string') {
|
||||
return {
|
||||
url: p.url as string,
|
||||
scheme: (p.tilingScheme?.constructor?.name ?? null) as string | null,
|
||||
rectNorthDeg: (p.rectangle.north * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
await expect.poll(async () => (await readLayer()) !== null, { timeout: 15_000 }).toBe(true);
|
||||
const layer = await readLayer();
|
||||
|
||||
expect(layer).not.toBeNull();
|
||||
const l = layer as { url: string; scheme: string | null; rectNorthDeg: number };
|
||||
// Served from our own origin: the polar basemap must not depend on a third
|
||||
// party that can throttle it into blank navy tiles.
|
||||
expect(l.url).toContain('/cesium/Assets/Textures/NaturalEarthII');
|
||||
expect(l.url).not.toContain('gibs.earthdata.nasa.gov');
|
||||
expect(l.scheme).toBe('GeographicTilingScheme');
|
||||
// Coverage must reach the pole, not stop at the Mercator limit.
|
||||
expect(l.rectNorthDeg).toBeCloseTo(90, 3);
|
||||
});
|
||||
|
||||
/**
|
||||
* Each base layer must be built by Cesium's protocol-specific provider rather
|
||||
* than a hand-written URL template. A template forces us to guess the row
|
||||
* convention, max level, extent and tiling scheme — and a wrong {reverseY}
|
||||
* silently mirrors every tile into the wrong latitude band, which is exactly
|
||||
* how the Esri layer broke. The dedicated providers read those facts from the
|
||||
* service, so the guess has nowhere to live.
|
||||
*/
|
||||
const EXPECTED_PROVIDER: Record<string, string> = {
|
||||
osm: 'OpenStreetMapImageryProvider',
|
||||
satellite: 'ArcGisMapServerImageryProvider',
|
||||
polar: 'TileMapServiceImageryProvider',
|
||||
};
|
||||
|
||||
for (const [layer, expected] of Object.entries(EXPECTED_PROVIDER)) {
|
||||
test(`${layer} layer uses ${expected}`, async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto('/predict');
|
||||
await page.evaluate((l) => {
|
||||
const raw = localStorage.getItem('settings');
|
||||
const s = raw ? JSON.parse(raw) : { locale: 'ru' };
|
||||
s.map = { ...(s.map ?? {}), baseLayer: l };
|
||||
localStorage.setItem('settings', JSON.stringify(s));
|
||||
}, layer);
|
||||
await page.reload();
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const providerName = () =>
|
||||
page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const n = v.imageryLayers.length;
|
||||
if (n === 0) return null;
|
||||
return v.imageryLayers.get(n - 1).imageryProvider?.constructor?.name ?? null;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
await expect.poll(providerName, { timeout: 20_000 }).toBe(expected);
|
||||
});
|
||||
}
|
||||
191
tests/e2e/polar.spec.ts
Normal file
191
tests/e2e/polar.spec.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { test, expect, login } from './fixtures';
|
||||
|
||||
/**
|
||||
* Task 6 verification: end-to-end polar behaviour against the REAL stack.
|
||||
*
|
||||
* Unlike the rest of the suite, this spec expects Django on :8000 and the Go
|
||||
* predictor on :8080 with a loaded GFS dataset (./run-stack.py). It drives a
|
||||
* launch at high latitude through the UI, then reads back the coordinates the
|
||||
* globe actually rendered — the numbers the predictor returned, not synthetic
|
||||
* ones.
|
||||
*
|
||||
* Run with:
|
||||
* npx playwright test tests/e2e/polar.spec.ts --reporter=list
|
||||
*/
|
||||
|
||||
const MERCATOR_LIMIT = 85.051129;
|
||||
|
||||
/**
|
||||
* Must be reached as localhost, not 127.0.0.1. Django's CSRF_TRUSTED_ORIGINS
|
||||
* defaults to `http://localhost:5173`, so a 127.0.0.1 origin gets 403 on any
|
||||
* POST (including /api/predictions/). run-stack.py binds Vite to 127.0.0.1 and
|
||||
* does not set CSRF_TRUSTED_ORIGINS, so the trusted spelling is the one to use.
|
||||
*/
|
||||
test.use({ baseURL: 'http://localhost:5173' });
|
||||
|
||||
/** Seed one workspace whose launch point sits at the given latitude. */
|
||||
async function seedLaunch(page: import('@playwright/test').Page, lat: number, lng: number) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ lat, lng }) => {
|
||||
const ws = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `polar-${lat}`,
|
||||
color: '#dc3545',
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
flightParameters: {
|
||||
ascent_rate: 5.0,
|
||||
burst_altitude: 30000.0,
|
||||
dataset: '',
|
||||
descent_rate: 5.0,
|
||||
format: 'json',
|
||||
launch_altitude: 0.0,
|
||||
launch_latitude: lat,
|
||||
launch_longitude: lng,
|
||||
profile: 'standard_profile',
|
||||
version: 2,
|
||||
},
|
||||
launchDate: new Date().toISOString().split('T')[0],
|
||||
launchTime: '12:00:00',
|
||||
result: null,
|
||||
bboxMargin: 10,
|
||||
bboxVisible: false,
|
||||
};
|
||||
localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id }));
|
||||
},
|
||||
{ lat, lng },
|
||||
);
|
||||
}
|
||||
|
||||
/** Every latitude Cesium is holding in a rendered workspace polyline. */
|
||||
function renderedLatitudes(page: import('@playwright/test').Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const viewer: any = (window as any)._lsvMap;
|
||||
if (!viewer?.entities) return [];
|
||||
const Cartographic: any = viewer.camera.positionCartographic.constructor;
|
||||
const out: number[] = [];
|
||||
for (const e of viewer.entities.values) {
|
||||
if (typeof e.id !== 'string' || !e.id.startsWith('ws/')) continue;
|
||||
const positions = e.polyline?.positions?.getValue?.(viewer.clock.currentTime);
|
||||
if (!positions) continue;
|
||||
for (const p of positions) {
|
||||
out.push((Cartographic.fromCartesian(p).latitude * 180) / Math.PI);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This spec needs the real predictor + Django, unlike the rest of the suite
|
||||
* which runs against the mock plugin. Probe for it and skip rather than fail
|
||||
* confusingly when only the mock dev server is up.
|
||||
*/
|
||||
let stackUp = false;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
const res = await fetch('http://127.0.0.1:8080/ready');
|
||||
stackUp = res.ok;
|
||||
} catch {
|
||||
stackUp = false;
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
test.skip(!stackUp, 'requires the real stack — start it with ./run-stack.py');
|
||||
await login(context);
|
||||
});
|
||||
|
||||
for (const lat of [89.5, 89.99]) {
|
||||
test(`polar launch at ${lat}N renders unclamped on the globe`, async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await seedLaunch(page, lat, 0.1);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
await page
|
||||
.locator('.panel-container-right')
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
|
||||
// Wait for the real predictor round-trip to paint something.
|
||||
await expect
|
||||
.poll(async () => (await renderedLatitudes(page)).length, {
|
||||
timeout: 150_000,
|
||||
intervals: [2000, 3000, 5000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const lats = await renderedLatitudes(page);
|
||||
const maxLat = Math.max(...lats);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` [${lat}N] vertices=${lats.length} max=${maxLat.toFixed(5)} min=${Math.min(...lats).toFixed(5)}`,
|
||||
);
|
||||
|
||||
// Nothing may be NaN, and the track must reach above the Mercator ceiling
|
||||
// that the previous renderer could not represent.
|
||||
expect(lats.every((v) => Number.isFinite(v))).toBe(true);
|
||||
expect(maxLat).toBeGreaterThan(MERCATOR_LIMIT);
|
||||
// And no vertex may sit exactly on the old clamp value, which would mean
|
||||
// something re-introduced Mercator tiling.
|
||||
expect(lats.some((v) => Math.abs(v - MERCATOR_LIMIT) < 1e-6)).toBe(false);
|
||||
|
||||
// Nothing on /predict auto-frames the result (fitBounds is only wired on
|
||||
// the tracking page), so frame the track's own bounds to capture it —
|
||||
// the same Rectangle maths CesiumMap.fitBounds uses.
|
||||
await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const Cartographic: any = v.camera.positionCartographic.constructor;
|
||||
const Rectangle: any = v.camera.computeViewRectangle().constructor;
|
||||
const lats: number[] = [];
|
||||
const lngs: number[] = [];
|
||||
for (const e of v.entities.values) {
|
||||
if (typeof e.id !== 'string' || !e.id.startsWith('ws/')) continue;
|
||||
const ps = e.polyline?.positions?.getValue?.(v.clock.currentTime);
|
||||
if (!ps) continue;
|
||||
for (const p of ps) {
|
||||
const c = Cartographic.fromCartesian(p);
|
||||
lats.push((c.latitude * 180) / Math.PI);
|
||||
lngs.push((c.longitude * 180) / Math.PI);
|
||||
}
|
||||
}
|
||||
const rect = Rectangle.fromDegrees(
|
||||
Math.min(...lngs),
|
||||
Math.min(...lats),
|
||||
Math.max(...lngs),
|
||||
Math.max(...lats),
|
||||
);
|
||||
v.camera.flyTo({ destination: rect, duration: 0 });
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
// Let the camera move and tile streaming settle before capturing.
|
||||
await page.waitForTimeout(6000);
|
||||
const cam = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const c = v.camera.positionCartographic;
|
||||
return {
|
||||
lng: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
tilesLoaded: v.scene.globe.tilesLoaded,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` [${lat}N] camera ${JSON.stringify(cam)}`);
|
||||
await page.screenshot({ path: `test-results/polar-${lat}N.png`, fullPage: false });
|
||||
});
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ test.beforeEach(async ({ context }) => {
|
|||
await login(context);
|
||||
});
|
||||
|
||||
test('predict page loads and mounts the MapLibre canvas', async ({ page }) => {
|
||||
test('predict page loads and mounts the map canvas', async ({ page }) => {
|
||||
await page.goto('/predict');
|
||||
await expect(page.locator('.map-container canvas').first()).toBeAttached({
|
||||
timeout: 15_000,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, openPredict, login } from './fixtures';
|
||||
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await login(context);
|
||||
|
|
@ -6,14 +6,9 @@ test.beforeEach(async ({ context, page }) => {
|
|||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||
});
|
||||
|
||||
/** Count map layers whose scoped id belongs to a bounding-box scene. */
|
||||
/** Count map objects whose scoped id belongs to a bounding-box scene. */
|
||||
function bboxLayerCount(page: import('@playwright/test').Page) {
|
||||
return page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map.getStyle().layers.filter((l: { id: string }) => l.id.startsWith('bbox')).length;
|
||||
});
|
||||
return sceneObjectCount(page, 'bbox');
|
||||
}
|
||||
|
||||
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
||||
|
|
@ -32,18 +27,10 @@ test('selected forecast bounding box is drawn on the tracking page', async ({ pa
|
|||
|
||||
// Wait for the run to complete (workspace scene appears).
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map
|
||||
.getStyle()
|
||||
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||
}),
|
||||
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||
)
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 75_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Enable the bounding box for this forecast.
|
||||
|
|
|
|||
183
tests/e2e/western-launch.spec.ts
Normal file
183
tests/e2e/western-launch.spec.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* A launch west of Greenwich must work.
|
||||
*
|
||||
* The app holds longitudes in [-180, 180] — wrapLongitude produces that, the map
|
||||
* reports clicks that way, and the predictor accepts [-180, 360). Django's
|
||||
* PredictionRequestSerializer declared launch_longitude with min_value=0, so every
|
||||
* western coordinate was refused before it ever reached the predictor. That is
|
||||
* Canada, Greenland and Alaska: most of the Arctic launch sites this product is
|
||||
* being built for.
|
||||
*
|
||||
* Nuuk (64.1, -51.7) is the fixture. 308.3 is the same meridian written in the
|
||||
* [0, 360) convention, which is the control: if one is refused and the other is
|
||||
* accepted, the constraint is about notation, not about the place.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
const NUUK = { lat: 64.1, lngSigned: -51.7, lngUnsigned: 308.3 };
|
||||
|
||||
/** POST straight to Django from the page, so the browser's session and CSRF apply. */
|
||||
async function postPrediction(page: import('@playwright/test').Page, lat: number, lng: number) {
|
||||
return page.evaluate(
|
||||
async ({ lat, lng }) => {
|
||||
const cookie = (name: string) =>
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith(name + '='))
|
||||
?.split('=')[1] ?? '';
|
||||
await fetch('/api/csrf/', { credentials: 'include' });
|
||||
const res = await fetch('/api/predictions/', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': cookie('csrftoken') },
|
||||
body: JSON.stringify({
|
||||
launch_latitude: lat,
|
||||
launch_longitude: lng,
|
||||
launch_datetime: '2026-08-05T12:00:00Z',
|
||||
launch_altitude: 0,
|
||||
ascent_rate: 5,
|
||||
burst_altitude: 30000,
|
||||
descent_rate: 5,
|
||||
profile: 'standard_profile',
|
||||
}),
|
||||
});
|
||||
return { status: res.status, body: (await res.text()).slice(0, 300) };
|
||||
},
|
||||
{ lat, lng },
|
||||
);
|
||||
}
|
||||
|
||||
test('the operator can run a prediction west of Greenwich', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const posted: string[] = [];
|
||||
const answered: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('/predictions/') && r.method() === 'POST') posted.push(r.postData() ?? '');
|
||||
});
|
||||
page.on('response', async (r) => {
|
||||
if (r.url().includes('/predictions/'))
|
||||
answered.push(`${r.status()} ${(await r.text().catch(() => '')).slice(0, 200)}`);
|
||||
});
|
||||
|
||||
// A default workspace is created on first visit; nothing is seeded, so the
|
||||
// coordinates below come from the input fields the way an operator enters them.
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
// The coordinate fields live on the Conditions tab; the Scenario tab is open by
|
||||
// default, so nothing numeric is mounted until this click.
|
||||
await page
|
||||
.locator('.panel-container-left')
|
||||
.getByRole('button', { name: /Условия|Conditions/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The latitude input is the only number field bounded to +-90; longitude is the
|
||||
// number field immediately after it.
|
||||
const latInput = page.locator('.panel-container-left input[type="number"][min="-90"]').first();
|
||||
await expect(latInput).toBeVisible();
|
||||
const lngInput = latInput.locator('xpath=following::input[@type="number"][1]');
|
||||
|
||||
await latInput.fill(String(NUUK.lat));
|
||||
await lngInput.fill(String(NUUK.lngSigned));
|
||||
|
||||
// What the UI holds after its own normalisation. wrapLongitude keeps west
|
||||
// negative rather than folding it to 308.3, which is why the value Django sees
|
||||
// is negative.
|
||||
expect(Number(await lngInput.inputValue())).toBeCloseTo(NUUK.lngSigned, 6);
|
||||
|
||||
await page
|
||||
.locator('.panel-container-right .workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => answered.length, { timeout: 90_000 }).toBeGreaterThan(0);
|
||||
console.log(' POST body :', posted[0]);
|
||||
console.log(' response :', answered[0]);
|
||||
|
||||
// Compared numerically: wrapLongitude's modular arithmetic returns
|
||||
// -51.69999999999999, about a nanometre off, which is not worth chasing.
|
||||
expect(JSON.parse(posted[0]).launch_longitude).toBeCloseTo(NUUK.lngSigned, 6);
|
||||
expect(answered[0]).toMatch(/^2\d\d /);
|
||||
// And the trajectory actually renders.
|
||||
await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 30_000 }).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the same point is accepted in either longitude convention', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await page.goto('/predict');
|
||||
|
||||
const west = await postPrediction(page, NUUK.lat, NUUK.lngSigned);
|
||||
const east = await postPrediction(page, NUUK.lat, NUUK.lngUnsigned);
|
||||
console.log(` lng=${NUUK.lngSigned} -> ${west.status} ${west.body.slice(0, 120)}`);
|
||||
console.log(` lng=${NUUK.lngUnsigned} -> ${east.status} ${east.body.slice(0, 120)}`);
|
||||
|
||||
// The control: the unsigned form of the same meridian was always accepted.
|
||||
expect(east.status).toBeLessThan(300);
|
||||
expect(west.status).toBe(east.status);
|
||||
});
|
||||
|
||||
test('the parsed trajectory is folded back to the signed convention', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
// v1 publishes [0, 360) — the convention upstream Tawhiri publishes, which is
|
||||
// why v1 exists. The frontend holds [-180, 180], and domain/prediction.ts is
|
||||
// the single place that folds: parsePrediction runs normalizeLng over the
|
||||
// flight path and over launch/burst/landing. This checks that fold actually
|
||||
// happened, since a missed one would put Greenland at 308 E — on the far side
|
||||
// of the globe — with no error anywhere.
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
const ws = {
|
||||
id: crypto.randomUUID(), name: 'nuuk', color: '#dc3545', opacity: 1, visible: true,
|
||||
flightParameters: {
|
||||
ascent_rate: 5, burst_altitude: 30000, dataset: '', descent_rate: 5, format: 'json',
|
||||
launch_altitude: 0, launch_latitude: 64.1, launch_longitude: -51.7,
|
||||
profile: 'standard_profile', version: 2,
|
||||
},
|
||||
launchDate: new Date().toISOString().split('T')[0], launchTime: '12:00:00', result: null,
|
||||
};
|
||||
localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id }));
|
||||
});
|
||||
await page.goto('/predict');
|
||||
await page.locator('.map-container canvas').first().waitFor({ state: 'attached', timeout: 60_000 });
|
||||
await page
|
||||
.locator('.panel-container-right .workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000] })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Read the drawn polyline, not the store: what matters is where the trajectory
|
||||
// ends up on the globe, and Cesium holds it as earth-centred vectors that have
|
||||
// to be read back through Cartographic.
|
||||
const geom = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const C: any = v.camera.positionCartographic.constructor;
|
||||
const e = v.entities.values.find((x: any) => String(x.id).includes('ws/') && x.polyline);
|
||||
if (!e) return null;
|
||||
const ps = e.polyline.positions.getValue(v.clock.currentTime);
|
||||
const lngs = ps.map((p: any) => (C.fromCartesian(p).longitude * 180) / Math.PI);
|
||||
return { n: ps.length, min: Math.min(...lngs), max: Math.max(...lngs), first: lngs[0] };
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
expect(geom).not.toBeNull();
|
||||
console.log(' drawn longitudes:', geom!.min.toFixed(4), '..', geom!.max.toFixed(4), `(${geom!.n} pts)`);
|
||||
// Nuuk and its whole flight stay near -51, not near +308 on the far side.
|
||||
expect(geom!.max).toBeLessThan(0);
|
||||
expect(geom!.min).toBeGreaterThan(-90);
|
||||
expect(geom!.first).toBeCloseTo(-51.7, 3);
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, openPredict, login } from './fixtures';
|
||||
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await login(context);
|
||||
|
|
@ -52,17 +52,75 @@ test('workspace render pipeline adds a map scene after a run', async ({ page })
|
|||
|
||||
// Wait for the prediction request to complete and layers to be added.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map
|
||||
.getStyle()
|
||||
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||
}),
|
||||
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||
)
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 75_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Regression: a flight is only tens of km across, which is a few dozen pixels
|
||||
// at the default camera height — the track ends up hidden under its own launch/
|
||||
// burst/landing markers and looks like a single dot. A fresh result must be
|
||||
// framed by the camera.
|
||||
test('camera frames the trajectory after a run', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openPredict(page);
|
||||
|
||||
const camera = () =>
|
||||
page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const c = v.camera.positionCartographic;
|
||||
return {
|
||||
lng: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
const before = await camera();
|
||||
|
||||
await workspacesPanel(page)
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 90_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Give the framing flight time to finish.
|
||||
await expect
|
||||
.poll(async () => (await camera()).height, { timeout: 20_000, intervals: [500, 1000] })
|
||||
.toBeLessThan(before.height / 2);
|
||||
|
||||
const after = await camera();
|
||||
// The camera must sit inside the track's own bounds, not at the default centre.
|
||||
const bounds = await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
const C: any = v.camera.positionCartographic.constructor;
|
||||
const la: number[] = [];
|
||||
const lo: number[] = [];
|
||||
for (const e of v.entities.values) {
|
||||
if (!String(e.id).endsWith('__path')) continue;
|
||||
for (const p of e.polyline.positions.getValue(v.clock.currentTime)) {
|
||||
const c = C.fromCartesian(p);
|
||||
la.push((c.latitude * 180) / Math.PI);
|
||||
lo.push((c.longitude * 180) / Math.PI);
|
||||
}
|
||||
}
|
||||
return { latMin: Math.min(...la), latMax: Math.max(...la), lngMin: Math.min(...lo), lngMax: Math.max(...lo) };
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
expect(after.lat).toBeGreaterThanOrEqual(bounds.latMin - 0.5);
|
||||
expect(after.lat).toBeLessThanOrEqual(bounds.latMax + 0.5);
|
||||
expect(after.lng).toBeGreaterThanOrEqual(bounds.lngMin - 0.5);
|
||||
expect(after.lng).toBeLessThanOrEqual(bounds.lngMax + 0.5);
|
||||
});
|
||||
|
|
|
|||
200
tests/unit/boundingBox.spec.ts
Normal file
200
tests/unit/boundingBox.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
computeBoundingBox,
|
||||
boundingBoxRing,
|
||||
type BoundingBox,
|
||||
} from '../../src/lib/domain/boundingBox';
|
||||
import type { LatLngTuple } from '../../src/lib/domain/geo';
|
||||
|
||||
/**
|
||||
* The restricted area filed with the regulator.
|
||||
*
|
||||
* It is a rectangle in kilometres, axis-aligned to east/north at its own centre,
|
||||
* with four lat/lon corners joined by great circles. Not a rectangle in degrees:
|
||||
* that form cannot work near a pole, because every meridian passes through the
|
||||
* pole, so any lat/lon rectangle containing one spans all 360 deg of longitude.
|
||||
* The measured cost of that was a 44 200 km^2 cap standing in for a 1 695 km^2
|
||||
* corridor.
|
||||
*
|
||||
* These tests run in Node, not a browser — the module under test is pure
|
||||
* geometry and imports only a type.
|
||||
*/
|
||||
|
||||
const R_KM = 6371;
|
||||
const rad = (d: number) => (d * Math.PI) / 180;
|
||||
const deg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Standard spherical destination-point formula, written out here rather than
|
||||
* imported so the tests do not check the implementation against itself.
|
||||
* Undefined starting exactly at a pole, which is why the polar cases use 89.99.
|
||||
*/
|
||||
function destination(lat: number, lng: number, bearingDeg: number, distKm: number): LatLngTuple {
|
||||
const d = distKm / R_KM;
|
||||
const br = rad(bearingDeg);
|
||||
const p1 = rad(lat);
|
||||
const l1 = rad(lng);
|
||||
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(br));
|
||||
const l2 =
|
||||
l1 +
|
||||
Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
|
||||
return [deg(p2), deg(l2)];
|
||||
}
|
||||
|
||||
/** A straight meridional track: identical in kilometres at any latitude. */
|
||||
function meridionalTrack(lat: number, lng: number, lengthKm: number, n = 40): LatLngTuple[] {
|
||||
return Array.from({ length: n }, (_, i) => destination(lat, lng, 180, (lengthKm * i) / (n - 1)));
|
||||
}
|
||||
|
||||
function toVec([lat, lng]: LatLngTuple): [number, number, number] {
|
||||
const p = rad(lat);
|
||||
const l = rad(lng);
|
||||
return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
|
||||
}
|
||||
|
||||
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: number[], b: number[]): [number, number, number] => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
|
||||
/**
|
||||
* Signed clearance in km from a point to the great circle through two corners,
|
||||
* positive on the side the box interior is on.
|
||||
*
|
||||
* This is measured against the edge the regulator would draw — the great circle
|
||||
* between the filed corners — not against the projected rectangle. The two are
|
||||
* not the same: a great-circle edge bows toward the centre of the box relative
|
||||
* to its chord in the projection, by roughly (half-edge)^2 / 2R. On a 500 km
|
||||
* edge that is 4.9 km, so it silently eats a 5 km margin whole.
|
||||
*/
|
||||
function clearanceKm(point: LatLngTuple, from: LatLngTuple, to: LatLngTuple, inside: LatLngTuple) {
|
||||
const n = cross(toVec(from), toVec(to));
|
||||
const len = Math.hypot(...n) || 1;
|
||||
const unit = n.map((c) => c / len);
|
||||
const sign = Math.sign(dot(unit, toVec(inside))) || 1;
|
||||
return sign * Math.asin(Math.max(-1, Math.min(1, dot(unit, toVec(point))))) * R_KM;
|
||||
}
|
||||
|
||||
/** Smallest clearance from any track point to any of the four filed edges. */
|
||||
function worstClearanceKm(box: BoundingBox, path: LatLngTuple[]): number {
|
||||
const c = box.corners;
|
||||
const edges: [LatLngTuple, LatLngTuple][] = [
|
||||
[c[0], c[1]],
|
||||
[c[1], c[2]],
|
||||
[c[2], c[3]],
|
||||
[c[3], c[0]],
|
||||
];
|
||||
let worst = Infinity;
|
||||
for (const p of path) {
|
||||
for (const [a, b] of edges) {
|
||||
const d = clearanceKm(p, a, b, box.centre);
|
||||
if (d < worst) worst = d;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/** Spherical excess area of the filed quad, km^2. */
|
||||
function areaKm2(box: BoundingBox): number {
|
||||
const v = box.corners.map(toVec);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// Interior angle at vertex i, between the planes of its two edges.
|
||||
const prev = v[(i + 3) % 4];
|
||||
const next = v[(i + 1) % 4];
|
||||
const n1 = cross(v[i], prev);
|
||||
const n2 = cross(v[i], next);
|
||||
const l1 = Math.hypot(...n1) || 1;
|
||||
const l2 = Math.hypot(...n2) || 1;
|
||||
sum += Math.acos(Math.max(-1, Math.min(1, -dot(n1, n2) / (l1 * l2))));
|
||||
}
|
||||
return (sum - 2 * Math.PI) * R_KM * R_KM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real trajectory from a launch at 89.99 N, 0 E, decimated. Under the old
|
||||
* lat/lon-rectangle form this produced south 88.93, north 90, west -180,
|
||||
* east 180: the entire cap, 44 200 km^2. In 1 degree of latitude it sweeps
|
||||
* 79 degrees of longitude, because near a pole a short displacement crosses
|
||||
* many meridians.
|
||||
*/
|
||||
const POLAR_TRACK: LatLngTuple[] = [
|
||||
[89.99, 0],
|
||||
[89.9564, 60.5627],
|
||||
[89.8954, 70.1192],
|
||||
[89.8095, 73.9615],
|
||||
[89.6924, 76.1248],
|
||||
[89.563, 77.1293],
|
||||
[89.494, 77.2284],
|
||||
[89.4553, 77.0498],
|
||||
[89.4281, 76.8977],
|
||||
[89.4109, 76.7301],
|
||||
[89.3981, 76.6866],
|
||||
[89.388, 76.9395],
|
||||
[89.3823, 77.663],
|
||||
[89.3771, 78.6642],
|
||||
[89.3748, 79.0682],
|
||||
[89.3736, 79.2029],
|
||||
[89.3589, 79.2173],
|
||||
[89.3177, 78.9415],
|
||||
[89.2028, 78.9972],
|
||||
[89.0974, 79.125],
|
||||
[89.0269, 79.0071],
|
||||
[88.9812, 78.8571],
|
||||
[88.9782, 78.8563],
|
||||
];
|
||||
|
||||
const MARGIN = 5;
|
||||
|
||||
test('every trajectory point clears the filed edges by the full margin', () => {
|
||||
// 500 km is where the great-circle bow matters: the east and west edges span
|
||||
// +-255 km, and a chord-to-arc sag of 255^2/2R = 5.1 km would consume the
|
||||
// entire 5 km margin and put the edge inside the trajectory.
|
||||
const path = meridionalTrack(52.2, 0.1, 500);
|
||||
const box = computeBoundingBox(path, MARGIN);
|
||||
|
||||
expect(box).not.toBeNull();
|
||||
expect(worstClearanceKm(box!, path)).toBeGreaterThanOrEqual(MARGIN - 0.01);
|
||||
});
|
||||
|
||||
test('every trajectory point clears the filed edges by the full margin near the pole', () => {
|
||||
const box = computeBoundingBox(POLAR_TRACK, MARGIN);
|
||||
|
||||
expect(box).not.toBeNull();
|
||||
expect(worstClearanceKm(box!, POLAR_TRACK)).toBeGreaterThanOrEqual(MARGIN - 0.01);
|
||||
});
|
||||
|
||||
test('a box at the pole covers the corridor, not the whole polar cap', () => {
|
||||
const box = computeBoundingBox(POLAR_TRACK, MARGIN);
|
||||
|
||||
// The lat/lon rectangle gave 44 200 km^2 for this track. The corridor it
|
||||
// actually flies is 13.7 x 123.4 km.
|
||||
expect(areaKm2(box!)).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('the same track in kilometres gives the same box at 52 N and at 89.99 N', () => {
|
||||
// A meridional track is the one shape whose kilometre extent is independent
|
||||
// of latitude, so any difference here is the code treating a pole specially.
|
||||
const mid = computeBoundingBox(meridionalTrack(52.2, 0.1, 120), MARGIN)!;
|
||||
const polar = computeBoundingBox(meridionalTrack(89.99, 0, 120), MARGIN)!;
|
||||
|
||||
expect(polar.heightKm).toBeCloseTo(mid.heightKm, 1);
|
||||
expect(polar.widthKm).toBeCloseTo(mid.widthKm, 1);
|
||||
});
|
||||
|
||||
test('the drawn ring closes and never spans the antimeridian in one segment', () => {
|
||||
// Cesium cuts geometry at the IDL; a single segment straddling it with
|
||||
// degenerate endpoints is what stopped the render loop before. Dense samples
|
||||
// along each edge keep every segment short and the drawn shape equal to the
|
||||
// filed one.
|
||||
const ring = boundingBoxRing(computeBoundingBox(POLAR_TRACK, MARGIN)!);
|
||||
|
||||
expect(ring.length).toBeGreaterThan(16);
|
||||
expect(ring[0]).toEqual(ring[ring.length - 1]);
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const step = Math.abs(ring[i][1] - ring[i - 1][1]);
|
||||
expect(Math.min(step, 360 - step)).toBeLessThan(90);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue