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
|
# AI tools
|
||||||
.claude
|
.claude
|
||||||
tmpclaude*
|
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",
|
"name": "app4",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"cesium": "1.129.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"chartjs-adapter-luxon": "^1.3.1",
|
"chartjs-adapter-luxon": "^1.3.1",
|
||||||
"chartjs-plugin-dragdata": "^2.3.1",
|
"chartjs-plugin-dragdata": "^2.3.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1"
|
||||||
"maplibre-gl": "^4.0.0",
|
|
||||||
"svelte5-chartjs": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|
@ -45,6 +43,57 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.2",
|
"version": "0.25.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
|
@ -598,6 +572,54 @@
|
||||||
"url": "https://opencollective.com/popperjs"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.39.0",
|
"version": "4.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
|
||||||
|
|
@ -858,47 +880,6 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
|
"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": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
|
||||||
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
|
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pbf": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "3.0.5",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
},
|
"optional": true
|
||||||
"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/@vincjo/datatables": {
|
"node_modules/@vincjo/datatables": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
|
|
@ -1092,6 +1042,17 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
|
|
@ -1100,6 +1061,11 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/bootstrap-icons": {
|
||||||
"version": "1.13.1",
|
"version": "1.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
"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": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
|
@ -1170,10 +1148,10 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/colord": {
|
"node_modules/commander": {
|
||||||
"version": "2.9.3",
|
"version": "2.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
|
|
@ -1244,6 +1222,19 @@
|
||||||
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
|
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/earcut": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
|
|
@ -1302,11 +1293,6 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
"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": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/geojson-vt": {
|
"node_modules/grapheme-splitter": {
|
||||||
"version": "4.0.2",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
||||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
|
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
||||||
},
|
|
||||||
"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/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
|
|
@ -1404,14 +1334,6 @@
|
||||||
"@types/estree": "^1.0.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": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
|
@ -1420,24 +1342,19 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-stringify-pretty-compact": {
|
"node_modules/jsep": {
|
||||||
"version": "4.0.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
||||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
|
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kdbush": {
|
"node_modules/kdbush": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
|
"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": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
|
|
@ -1447,11 +1364,26 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
|
"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": {
|
"node_modules/luxon": {
|
||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
||||||
|
|
@ -1468,53 +1400,15 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/maplibre-gl": {
|
"node_modules/mersenne-twister": {
|
||||||
"version": "4.7.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
|
||||||
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
|
"integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
|
||||||
"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/minimist": {
|
"node_modules/meshoptimizer": {
|
||||||
"version": "1.2.8",
|
"version": "0.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
|
@ -1540,11 +1434,6 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"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": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pbf": {
|
"node_modules/nosleep.js": {
|
||||||
"version": "3.3.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
||||||
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
|
"integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
|
||||||
"dependencies": {
|
},
|
||||||
"ieee754": "^1.1.12",
|
"node_modules/pako": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"version": "2.2.0",
|
||||||
},
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||||
"bin": {
|
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||||
"pbf": "bin/pbf"
|
"funding": [
|
||||||
}
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
|
|
@ -1665,20 +1562,40 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/potpack": {
|
"node_modules/protobufjs": {
|
||||||
"version": "2.1.0",
|
"version": "7.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
|
"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": {
|
"node_modules/rbush": {
|
||||||
"version": "3.6.0",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
|
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
||||||
|
"dependencies": {
|
||||||
|
"quickselect": "^2.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/quickselect": {
|
"node_modules/rbush/node_modules/quickselect": {
|
||||||
"version": "3.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
|
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
|
|
@ -1693,14 +1610,6 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.39.0",
|
"version": "4.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
|
||||||
|
|
@ -1740,11 +1649,6 @@
|
||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
|
|
@ -1786,14 +1690,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.34.8",
|
"version": "5.34.8",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.8.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.8.tgz",
|
||||||
|
|
@ -1841,15 +1737,6 @@
|
||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
|
|
@ -1866,10 +1753,18 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyqueue": {
|
"node_modules/topojson-client": {
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
|
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"topo2geo": "bin/topo2geo",
|
||||||
|
"topomerge": "bin/topomerge",
|
||||||
|
"topoquantize": "bin/topoquantize"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
|
|
@ -1880,6 +1775,11 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||||
|
|
@ -1896,8 +1796,12 @@
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
|
||||||
"dev": true
|
},
|
||||||
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"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": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -7,7 +7,7 @@
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
|
@ -28,15 +28,16 @@
|
||||||
"vite": "^6.2.5"
|
"vite": "^6.2.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"cesium": "1.129.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"chartjs-adapter-luxon": "^1.3.1",
|
"chartjs-adapter-luxon": "^1.3.1",
|
||||||
"chartjs-plugin-dragdata": "^2.3.1",
|
"chartjs-plugin-dragdata": "^2.3.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1"
|
||||||
"maplibre-gl": "^4.0.0",
|
},
|
||||||
"svelte5-chartjs": "^1.0.0"
|
"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
|
* npm run test:e2e:ui # Playwright UI mode
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
// Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node).
|
||||||
|
testDir: './tests',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 5_000 },
|
expect: { timeout: 5_000 },
|
||||||
fullyParallel: false, // the mock plugin writes to shared JSON files on disk
|
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.
|
* Global application styles.
|
||||||
*
|
*
|
||||||
* Keep this file focused on cross-feature concerns: the navbar chrome, the
|
* 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.
|
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -93,27 +93,6 @@ body {
|
||||||
right: var(--panel-left);
|
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 {
|
.modal-backdrop {
|
||||||
opacity: var(--bs-backdrop-opacity) !important;
|
opacity: var(--bs-backdrop-opacity) !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,4 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
||||||
export { pointsApi } from './points';
|
export { pointsApi } from './points';
|
||||||
export { profilesApi } from './profiles';
|
export { profilesApi } from './profiles';
|
||||||
export { scenariosApi } from './scenarios';
|
export { scenariosApi } from './scenarios';
|
||||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
export { predictionsApi, buildLaunchDateTime } from './predictions';
|
||||||
export { windApi, type WindFieldParams } from './wind';
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,6 @@
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { FlightParameters, RawPrediction } from '$domain';
|
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 {
|
export function buildLaunchDateTime(date: string, time: string): string {
|
||||||
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
||||||
return new Date(`${date}T${fullTime}Z`).toISOString();
|
return new Date(`${date}T${fullTime}Z`).toISOString();
|
||||||
|
|
@ -24,11 +12,17 @@ export interface PredictionResponse {
|
||||||
|
|
||||||
export const predictionsApi = {
|
export const predictionsApi = {
|
||||||
run: (params: FlightParameters, launchDateTime: string) => {
|
run: (params: FlightParameters, launchDateTime: string) => {
|
||||||
const payload: FlightParameters & { launch_datetime: string } = {
|
// `dataset` carries only what the operator actually chose. It used to fall
|
||||||
...params,
|
// back to a client-side guess at which GFS run the server holds — and the
|
||||||
dataset: params.dataset || getLatestDataset(),
|
// 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,
|
launch_datetime: launchDateTime,
|
||||||
};
|
} as FlightParameters & { launch_datetime: string };
|
||||||
if (payload.start_point === -1) delete payload.start_point;
|
if (payload.start_point === -1) delete payload.start_point;
|
||||||
return api.post<PredictionResponse>('/predictions/', payload);
|
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';
|
import type { LatLngTuple } from './geo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Axis-aligned geographic bounding box around a flight path, with an optional
|
* The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
|
||||||
* margin so recovery teams get a box that clears the trajectory by a set
|
* to east/north at its own centre, with four lat/lon corners joined by great
|
||||||
* distance rather than hugging its extreme points.
|
* 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 {
|
export interface BoundingBox {
|
||||||
south: number;
|
/**
|
||||||
west: number;
|
* The filed corners, joined by great circles, in the order the operator reads
|
||||||
north: number;
|
* them: NW, NE, SE, SW of the centre's own east/north frame.
|
||||||
east: number;
|
*/
|
||||||
|
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. */
|
/** Default clearance (km) between the box edge and the nearest trajectory point. */
|
||||||
export const DEFAULT_BBOX_MARGIN_KM = 5;
|
export const DEFAULT_BBOX_MARGIN_KM = 5;
|
||||||
|
|
||||||
// Mean length of one degree of latitude. Longitude degrees shrink toward the
|
const R_KM = 6371;
|
||||||
// poles, handled below via cos(latitude).
|
const D2R = Math.PI / 180;
|
||||||
const KM_PER_DEG_LAT = 111.32;
|
|
||||||
|
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").
|
* Returns null for an empty path (callers treat that as "nothing to draw").
|
||||||
*/
|
*/
|
||||||
export function computeBoundingBox(
|
export function computeBoundingBox(
|
||||||
|
|
@ -29,52 +137,71 @@ export function computeBoundingBox(
|
||||||
): BoundingBox | null {
|
): BoundingBox | null {
|
||||||
if (path.length === 0) return null;
|
if (path.length === 0) return null;
|
||||||
|
|
||||||
let south = Infinity;
|
const points = path.map(toVec);
|
||||||
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 margin = Math.max(0, marginKm);
|
const margin = Math.max(0, marginKm);
|
||||||
const dLat = margin / KM_PER_DEG_LAT;
|
|
||||||
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
|
// Two passes. The first, framed on the track's mean direction, only locates
|
||||||
// Size the longitude margin at the latitude nearest a pole so the box never
|
// the box centre; the second frames on that centre, which makes the four
|
||||||
// comes in tighter than requested along the whole band.
|
// corners symmetric about it and keeps the projection error smallest where
|
||||||
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
|
// the corners actually are.
|
||||||
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
|
const first = frame(centroid(points));
|
||||||
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
|
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 {
|
return {
|
||||||
south: south - dLat,
|
corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
|
||||||
north: north + dLat,
|
centre: toLatLng(unproject(f, cx, cy)),
|
||||||
west: west - dLng,
|
widthKm: 2 * ex,
|
||||||
east: east + dLng,
|
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[] {
|
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
|
||||||
return [
|
const v = box.corners.map(toVec);
|
||||||
[box.south, box.west],
|
const ring: LatLngTuple[] = [];
|
||||||
[box.north, box.west],
|
for (let e = 0; e < 4; e++) {
|
||||||
[box.north, box.east],
|
const from = v[e];
|
||||||
[box.south, box.east],
|
const to = v[(e + 1) % 4];
|
||||||
[box.south, box.west],
|
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). */
|
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
|
||||||
export function formatBoundingBox(box: BoundingBox): string {
|
export function formatBoundingBox(box: BoundingBox): string {
|
||||||
const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n');
|
||||||
return [
|
|
||||||
fmt(box.north, box.west),
|
|
||||||
fmt(box.north, box.east),
|
|
||||||
fmt(box.south, box.east),
|
|
||||||
fmt(box.south, box.west),
|
|
||||||
].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.
|
* Geographic primitives used by map layers and predictions.
|
||||||
*
|
*
|
||||||
* LngLat convention matches MapLibre (longitude first) for on-map work;
|
* LngLat convention is longitude-first for on-map work, matching Cesium's
|
||||||
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
|
* Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
|
||||||
|
* legacy Leaflet-era code paths.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LatLng {
|
export interface LatLng {
|
||||||
|
|
@ -11,6 +12,45 @@ export interface LatLng {
|
||||||
alt?: number;
|
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 LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
|
||||||
|
|
||||||
export type LatLngExpression = LatLng | LatLngTuple;
|
export type LatLngExpression = LatLng | LatLngTuple;
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ export * from './math';
|
||||||
export * from './scenario';
|
export * from './scenario';
|
||||||
export * from './prediction';
|
export * from './prediction';
|
||||||
export * from './telemetry';
|
export * from './telemetry';
|
||||||
export * from './wind';
|
|
||||||
export * from './boundingBox';
|
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 { pointsApi } from '$api';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FLIGHT_PARAMETERS,
|
DEFAULT_FLIGHT_PARAMETERS,
|
||||||
|
MAX_LAUNCH_LATITUDE,
|
||||||
PROFILE_IDENTIFIERS,
|
PROFILE_IDENTIFIERS,
|
||||||
|
clampLaunchLatitude,
|
||||||
|
wrapLongitude,
|
||||||
toFixedNumber,
|
toFixedNumber,
|
||||||
type FlightParameters,
|
type FlightParameters,
|
||||||
type ProfileIdentifier,
|
type ProfileIdentifier,
|
||||||
|
|
@ -75,7 +78,17 @@
|
||||||
|
|
||||||
function patchActive(patch: Partial<FlightParameters>) {
|
function patchActive(patch: Partial<FlightParameters>) {
|
||||||
if (!active) return;
|
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) {
|
function handlePointSelection(newPointId: number | null) {
|
||||||
|
|
@ -225,6 +238,8 @@
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.000001"
|
step="0.000001"
|
||||||
|
min={-MAX_LAUNCH_LATITUDE}
|
||||||
|
max={MAX_LAUNCH_LATITUDE}
|
||||||
value={params.launch_latitude}
|
value={params.launch_latitude}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
patchActive({
|
patchActive({
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,15 @@
|
||||||
} from '@sveltestrap/sveltestrap';
|
} from '@sveltestrap/sveltestrap';
|
||||||
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
|
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
|
||||||
import { scenariosApi } from '$api';
|
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 { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
import { scenariosStore } from './pointsStore';
|
import { scenariosStore } from './pointsStore';
|
||||||
|
|
@ -18,8 +26,32 @@
|
||||||
|
|
||||||
let selectedScenarioId = $state<number>(-1);
|
let selectedScenarioId = $state<number>(-1);
|
||||||
let editorRef: ScenarioEditor | null = $state(null);
|
let editorRef: ScenarioEditor | null = $state(null);
|
||||||
|
let exportFormat = $state<ExportFormat>('JSON');
|
||||||
|
|
||||||
let active = $derived(getActiveWorkspace($workspacesStore));
|
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(() => {
|
let scenarioUnsaved = $derived.by(() => {
|
||||||
if (!active) return false;
|
if (!active) return false;
|
||||||
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
||||||
|
|
@ -172,12 +204,12 @@
|
||||||
<FormGroup spacing="mb-0">
|
<FormGroup spacing="mb-0">
|
||||||
<Label class="form-label">{$t('scenario.export')}</Label>
|
<Label class="form-label">{$t('scenario.export')}</Label>
|
||||||
<InputGroup size="sm">
|
<InputGroup size="sm">
|
||||||
<Input type="select" class="form-control-sm">
|
<Input type="select" class="form-control-sm" bind:value={exportFormat}>
|
||||||
<option>JSON</option>
|
{#each EXPORT_FORMATS as f (f)}
|
||||||
<option>CSV</option>
|
<option value={f}>{f}</option>
|
||||||
<option>KML</option>
|
{/each}
|
||||||
</Input>
|
</Input>
|
||||||
<Button color="primary">
|
<Button color="primary" disabled={!active?.result} onclick={handleExport}>
|
||||||
<span>{$t('scenario.exportBtn')}</span>
|
<span>{$t('scenario.exportBtn')}</span>
|
||||||
<Icon name="file-earmark-arrow-down" />
|
<Icon name="file-earmark-arrow-down" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { settingsStore, DEFAULT_SETTINGS } from './store';
|
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 { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||||
export { SETTINGS_SCHEMA } from './schema';
|
export { SETTINGS_SCHEMA } from './schema';
|
||||||
export type { SettingsField, SettingsSection } from './schema';
|
export type { SettingsField, SettingsSection } from './schema';
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,12 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
|
||||||
path: 'map.baseLayer',
|
path: 'map.baseLayer',
|
||||||
labelKey: 'settings.baseLayer',
|
labelKey: 'settings.baseLayer',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'osm', labelKey: 'settings.baseLayer' },
|
{ value: 'osm', labelKey: 'settings.baseLayerOsm' },
|
||||||
{ value: 'satellite', labelKey: 'settings.baseLayer' },
|
{ 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.showScale', labelKey: 'settings.showScale' },
|
||||||
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
|
{ 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 { persisted } from '$state';
|
||||||
import type { Locale } from '$i18n';
|
import type { Locale } from '$i18n';
|
||||||
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
import type { BaseLayerId } from '$map';
|
||||||
|
|
||||||
export type { WindSettings };
|
|
||||||
|
|
||||||
export interface MapSettings {
|
export interface MapSettings {
|
||||||
baseLayer: 'osm' | 'satellite';
|
baseLayer: BaseLayerId;
|
||||||
showScale: boolean;
|
showScale: boolean;
|
||||||
showNavigation: 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 {
|
export interface UnitsSettings {
|
||||||
|
|
@ -18,14 +22,12 @@ export interface AppSettings {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
map: MapSettings;
|
map: MapSettings;
|
||||||
units: UnitsSettings;
|
units: UnitsSettings;
|
||||||
wind: WindSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
locale: 'ru',
|
locale: 'ru',
|
||||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
map: { baseLayer: 'osm', showScale: true, showNavigation: true, graticule: true },
|
||||||
units: { system: 'metric' },
|
units: { system: 'metric' },
|
||||||
wind: { ...DEFAULT_WIND_SETTINGS },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_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) {
|
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
|
||||||
const scene = map.scene(name);
|
const scene = map.scene(name);
|
||||||
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
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);
|
ownedPlotScenes.add(name);
|
||||||
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,15 @@
|
||||||
</Button>
|
</Button>
|
||||||
{#if w.bboxVisible && box}
|
{#if w.bboxVisible && box}
|
||||||
{@const coords = formatBoundingBox(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
|
<textarea
|
||||||
class="form-control form-control-sm mt-2 font-monospace"
|
class="form-control form-control-sm mt-2 font-monospace"
|
||||||
style="resize: none;"
|
style="resize: none;"
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@
|
||||||
"datasetAuto": "Pick automatically",
|
"datasetAuto": "Pick automatically",
|
||||||
"modified": "modified",
|
"modified": "modified",
|
||||||
"export": "Export result",
|
"export": "Export result",
|
||||||
"exportBtn": "Export"
|
"exportBtn": "Export",
|
||||||
|
"exportNoResult": "Run a prediction first — there is nothing to export."
|
||||||
},
|
},
|
||||||
"predictionMode": {
|
"predictionMode": {
|
||||||
"single": "Single",
|
"single": "Single",
|
||||||
|
|
@ -138,18 +139,10 @@
|
||||||
"metric": "Metric",
|
"metric": "Metric",
|
||||||
"imperial": "Imperial",
|
"imperial": "Imperial",
|
||||||
"saved": "Settings saved",
|
"saved": "Settings saved",
|
||||||
"wind": "Wind visualization",
|
"baseLayerOsm": "Map (OpenStreetMap)",
|
||||||
"windEnabled": "Show wind layer",
|
"baseLayerSatellite": "Satellite (Esri)",
|
||||||
"windStep": "Grid resolution (°)",
|
"graticule": "Lat/lon grid",
|
||||||
"windTrajectoryStep": "Trajectory grid res. (°)",
|
"baseLayerPolar": "Polar map (offline, covers the poles)"
|
||||||
"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)"
|
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@
|
||||||
"datasetAuto": "Выбрать автоматически",
|
"datasetAuto": "Выбрать автоматически",
|
||||||
"modified": "изменено",
|
"modified": "изменено",
|
||||||
"export": "Экспортировать результат",
|
"export": "Экспортировать результат",
|
||||||
"exportBtn": "Экспорт"
|
"exportBtn": "Экспорт",
|
||||||
|
"exportNoResult": "Сначала выполните расчёт — экспортировать нечего."
|
||||||
},
|
},
|
||||||
"predictionMode": {
|
"predictionMode": {
|
||||||
"single": "Разовый",
|
"single": "Разовый",
|
||||||
|
|
@ -138,18 +139,10 @@
|
||||||
"metric": "Метрические",
|
"metric": "Метрические",
|
||||||
"imperial": "Имперские",
|
"imperial": "Имперские",
|
||||||
"saved": "Настройки сохранены",
|
"saved": "Настройки сохранены",
|
||||||
"wind": "Визуализация ветра",
|
"baseLayerOsm": "Карта (OpenStreetMap)",
|
||||||
"windEnabled": "Показывать слой ветра",
|
"baseLayerSatellite": "Спутник (Esri)",
|
||||||
"windStep": "Шаг сетки (°)",
|
"graticule": "Сетка координат",
|
||||||
"windTrajectoryStep": "Шаг сетки по траектории (°)",
|
"baseLayerPolar": "Полярная карта (офлайн, до полюсов)"
|
||||||
"windPrefetchInterval": "Интервал предзагрузки (мин)",
|
|
||||||
"windMaxDuration": "Макс. длительность синхронизации (ч)",
|
|
||||||
"windMaxRegion": "Макс. размер региона (°)",
|
|
||||||
"windMargin": "Отступ вокруг траектории (°)",
|
|
||||||
"windParticleDensity": "Плотность частиц",
|
|
||||||
"windParticleSpeed": "Скорость частиц",
|
|
||||||
"windTrailPersistence": "Длина следа",
|
|
||||||
"windMaxVelocity": "Макс. скорость ветра (м/с)"
|
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, type Snippet } from 'svelte';
|
import { onMount, onDestroy, type Snippet } from 'svelte';
|
||||||
import type { IMap } from './core';
|
import type { BaseLayerId, IMap } from './core';
|
||||||
import type { LngLatTuple } from '$domain';
|
import type { LngLatTuple } from '$domain';
|
||||||
import { createMapLibreMap } from './maplibre';
|
import { createCesiumMap } from './cesium';
|
||||||
import { setMapContext } from './context';
|
import { setMapContext } from './context';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
center?: LngLatTuple;
|
center?: LngLatTuple;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
baseLayer?: 'osm' | 'satellite';
|
baseLayer?: BaseLayerId;
|
||||||
showNavigationControl?: boolean;
|
showNavigationControl?: boolean;
|
||||||
showScaleControl?: boolean;
|
showScaleControl?: boolean;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
|
@ -28,9 +28,9 @@
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let map: IMap | null = $state(null);
|
let map: IMap | null = $state(null);
|
||||||
/**
|
/**
|
||||||
* Children must not render until the map's first `load` event. MapLibre
|
* Children must not render until `map.ready` resolves. Cesium's Viewer is
|
||||||
* throws if addSource/addLayer is called on an unloaded style, and this
|
* usable synchronously, but features still expect this gate, so it stays as
|
||||||
* component is the natural gate for that invariant.
|
* the single place that decides when the map may be drawn on.
|
||||||
*/
|
*/
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
map = createMapLibreMap({
|
map = createCesiumMap({
|
||||||
container,
|
container,
|
||||||
center,
|
center,
|
||||||
zoom,
|
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.
|
* Map abstraction.
|
||||||
*
|
*
|
||||||
* Goals:
|
* 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,
|
* - Expose a small, map-library-agnostic vocabulary (markers, polylines,
|
||||||
* icons, events) so features (workspaces, timeline, tools) can be tested
|
* icons, events) so features (workspaces, timeline, tools) can be tested
|
||||||
* against the interface alone.
|
* against the interface alone.
|
||||||
* - Support "scenes" — named collections of layers owned by a feature so
|
* - Support "scenes" — named collections of layers owned by a feature so
|
||||||
* each workspace/tool can add/remove everything it owns atomically.
|
* 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.
|
* file that changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -34,6 +34,11 @@ export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => voi
|
||||||
|
|
||||||
export interface MarkerOptions {
|
export interface MarkerOptions {
|
||||||
lngLat: LngLatTuple;
|
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;
|
iconUrl?: string;
|
||||||
iconSize?: [number, number];
|
iconSize?: [number, number];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -42,6 +47,7 @@ export interface MarkerOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LineOptions {
|
export interface LineOptions {
|
||||||
|
/** Joined by great circles. Callers that need another curve sample it themselves. */
|
||||||
coords: LatLngTuple[];
|
coords: LatLngTuple[];
|
||||||
color?: string;
|
color?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|
@ -95,6 +101,12 @@ export interface IMap {
|
||||||
|
|
||||||
setCursor(cursor: string | null): void;
|
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:
|
* 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
|
* a feature adds all its layers through a scene and calls `.clear()` to
|
||||||
|
|
@ -109,11 +121,20 @@ export interface IMap {
|
||||||
dispose(): void;
|
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 {
|
export interface MapInit {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
center: LngLatTuple;
|
center: LngLatTuple;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
baseLayer?: 'osm' | 'satellite';
|
baseLayer?: BaseLayerId;
|
||||||
showNavigationControl?: boolean;
|
showNavigationControl?: boolean;
|
||||||
showScaleControl?: boolean;
|
showScaleControl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export * from './core';
|
export * from './core';
|
||||||
export { createMapLibreMap } from './maplibre';
|
export { createCesiumMap } from './cesium';
|
||||||
export {
|
export {
|
||||||
plotPrediction,
|
plotPrediction,
|
||||||
plotTelemetry,
|
plotTelemetry,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { toLngLat, boundingBoxRing } from '$domain';
|
||||||
import type { IMap, Scene } from './core';
|
import type { IMap, Scene } from './core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plot helpers for high-level domain objects. These live outside MapLibreMap
|
* Plot helpers for high-level domain objects. These live outside the concrete
|
||||||
* so they can be reused against any IMap implementation.
|
* map class so they can be reused against any IMap implementation.
|
||||||
*
|
*
|
||||||
* Icons are served from /static; pass explicit overrides if a workspace
|
* Icons are served from /static; pass explicit overrides if a workspace
|
||||||
* should use custom markers.
|
* should use custom markers.
|
||||||
|
|
@ -62,6 +62,9 @@ export function plotPrediction(
|
||||||
|
|
||||||
scene.addMarker('burst', {
|
scene.addMarker('burst', {
|
||||||
lngLat: toLngLat(prediction.burst.latlng),
|
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,
|
iconUrl: s.burstIcon,
|
||||||
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
|
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)}`,
|
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 {
|
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
|
||||||
scene.clear();
|
scene.clear();
|
||||||
scene.addLine('box', {
|
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),
|
coords: boundingBoxRing(box),
|
||||||
color: style.color ?? '#0d6efd',
|
color: style.color ?? '#0d6efd',
|
||||||
width: style.width ?? 3,
|
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 type { IMap } from '$map';
|
||||||
import { startCoordinateSelection } from '$map';
|
import { startCoordinateSelection } from '$map';
|
||||||
import { Navbar } from '$features/auth';
|
import { Navbar } from '$features/auth';
|
||||||
|
import { MapChrome } from '$features/mapchrome';
|
||||||
import { PanelContainer, TabBar } from '$ui';
|
import { PanelContainer, TabBar } from '$ui';
|
||||||
import { addToast, removeToast } from '$ui';
|
import { addToast, removeToast } from '$ui';
|
||||||
import { ControlPanel, ScenarioPanel } from '$features/prediction';
|
import { ControlPanel, ScenarioPanel } from '$features/prediction';
|
||||||
|
|
@ -12,7 +13,6 @@
|
||||||
WorkspaceRenderer,
|
WorkspaceRenderer,
|
||||||
workspacesStore,
|
workspacesStore,
|
||||||
} from '$features/workspaces';
|
} from '$features/workspaces';
|
||||||
import { WindRenderer } from '$features/wind';
|
|
||||||
import { SettingsPanel } from '$features/settings';
|
import { SettingsPanel } from '$features/settings';
|
||||||
import { TimeLine } from '$features/timeline';
|
import { TimeLine } from '$features/timeline';
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
|
|
@ -72,8 +72,8 @@
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div style="height: var(--navbar-height);"></div>
|
<div style="height: var(--navbar-height);"></div>
|
||||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||||
|
<MapChrome />
|
||||||
<WorkspaceRenderer />
|
<WorkspaceRenderer />
|
||||||
<WindRenderer />
|
|
||||||
|
|
||||||
<PanelContainer position="left">
|
<PanelContainer position="left">
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
type IMap,
|
type IMap,
|
||||||
} from '$map';
|
} from '$map';
|
||||||
import { Navbar } from '$features/auth';
|
import { Navbar } from '$features/auth';
|
||||||
|
import { MapChrome } from '$features/mapchrome';
|
||||||
import { PanelContainer, CollapsibleCard } from '$ui';
|
import { PanelContainer, CollapsibleCard } from '$ui';
|
||||||
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
||||||
import { workspacesStore } from '$features/workspaces';
|
import { workspacesStore } from '$features/workspaces';
|
||||||
|
|
@ -116,6 +117,7 @@
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div style="height: var(--navbar-height);"></div>
|
<div style="height: var(--navbar-height);"></div>
|
||||||
<MapView onReady={onMapReady}>
|
<MapView onReady={onMapReady}>
|
||||||
|
<MapChrome />
|
||||||
<PanelContainer position="left">
|
<PanelContainer position="left">
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
</PanelContainer>
|
</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 };
|
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) {
|
export async function openPredict(page: Page) {
|
||||||
await page.goto('/predict');
|
await page.goto('/predict');
|
||||||
await page
|
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);
|
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 page.goto('/predict');
|
||||||
await expect(page.locator('.map-container canvas').first()).toBeAttached({
|
await expect(page.locator('.map-container canvas').first()).toBeAttached({
|
||||||
timeout: 15_000,
|
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 }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
await login(context);
|
await login(context);
|
||||||
|
|
@ -6,14 +6,9 @@ test.beforeEach(async ({ context, page }) => {
|
||||||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
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) {
|
function bboxLayerCount(page: import('@playwright/test').Page) {
|
||||||
return page.evaluate(() => {
|
return sceneObjectCount(page, 'bbox');
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
// 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).
|
// Wait for the run to complete (workspace scene appears).
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||||
() =>
|
timeout: 75_000,
|
||||||
page.evaluate(() => {
|
intervals: [1000, 2000, 3000],
|
||||||
// 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] },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// Enable the bounding box for this forecast.
|
// 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 }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
await login(context);
|
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.
|
// Wait for the prediction request to complete and layers to be added.
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||||
async () =>
|
timeout: 75_000,
|
||||||
page.evaluate(() => {
|
intervals: [1000, 2000, 3000],
|
||||||
// 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] },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0);
|
.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