Compare commits
No commits in common. "celium_globe" and "master" have entirely different histories.
celium_glo
...
master
54 changed files with 2009 additions and 3792 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -25,10 +25,3 @@ 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/
|
|
||||||
|
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,700 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# 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.
|
|
||||||
667
package-lock.json
generated
667
package-lock.json
generated
|
|
@ -8,14 +8,16 @@
|
||||||
"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",
|
||||||
|
|
@ -43,57 +45,6 @@
|
||||||
"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",
|
||||||
|
|
@ -542,6 +493,81 @@
|
||||||
"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",
|
||||||
|
|
@ -572,54 +598,6 @@
|
||||||
"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",
|
||||||
|
|
@ -880,6 +858,47 @@
|
||||||
"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",
|
||||||
|
|
@ -978,11 +997,6 @@
|
||||||
"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",
|
||||||
|
|
@ -994,25 +1008,61 @@
|
||||||
"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/trusted-types": {
|
"node_modules/@types/pbf": {
|
||||||
"version": "2.0.7",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
|
||||||
"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",
|
||||||
|
|
@ -1042,17 +1092,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1061,11 +1100,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1081,18 +1115,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|
@ -1148,10 +1170,10 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/colord": {
|
||||||
"version": "2.20.3",
|
"version": "2.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
|
||||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
|
|
@ -1222,19 +1244,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1293,6 +1302,11 @@
|
||||||
"@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",
|
||||||
|
|
@ -1321,10 +1335,66 @@
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/grapheme-splitter": {
|
"node_modules/geojson-vt": {
|
||||||
"version": "1.0.4",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||||
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
|
||||||
|
},
|
||||||
|
"node_modules/get-stream": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="
|
||||||
|
},
|
||||||
|
"node_modules/global-prefix": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ini": "^4.1.3",
|
||||||
|
"kind-of": "^6.0.3",
|
||||||
|
"which": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
|
|
@ -1334,6 +1404,14 @@
|
||||||
"@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",
|
||||||
|
|
@ -1342,19 +1420,24 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsep": {
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
"version": "1.4.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
|
||||||
"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",
|
||||||
|
|
@ -1364,26 +1447,11 @@
|
||||||
"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",
|
||||||
|
|
@ -1400,15 +1468,53 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mersenne-twister": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "1.1.0",
|
"version": "4.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
||||||
"integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
|
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/geojson-rewind": "^0.5.2",
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^0.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.0.6",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"@mapbox/vector-tile": "^1.3.1",
|
||||||
|
"@mapbox/whoots-js": "^3.1.0",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
|
||||||
|
"@types/geojson": "^7946.0.14",
|
||||||
|
"@types/geojson-vt": "3.2.5",
|
||||||
|
"@types/mapbox__point-geometry": "^0.1.4",
|
||||||
|
"@types/mapbox__vector-tile": "^1.3.4",
|
||||||
|
"@types/pbf": "^3.0.5",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"earcut": "^3.0.0",
|
||||||
|
"geojson-vt": "^4.0.2",
|
||||||
|
"gl-matrix": "^3.4.3",
|
||||||
|
"global-prefix": "^4.0.0",
|
||||||
|
"kdbush": "^4.0.2",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^3.3.0",
|
||||||
|
"potpack": "^2.0.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"supercluster": "^8.0.1",
|
||||||
|
"tinyqueue": "^3.0.0",
|
||||||
|
"vt-pbf": "^3.1.3"
|
||||||
},
|
},
|
||||||
"node_modules/meshoptimizer": {
|
"engines": {
|
||||||
"version": "0.23.0",
|
"node": ">=16.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz",
|
"npm": ">=8.1.0"
|
||||||
"integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
|
@ -1434,6 +1540,11 @@
|
||||||
"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",
|
||||||
|
|
@ -1452,25 +1563,17 @@
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nosleep.js": {
|
"node_modules/pbf": {
|
||||||
"version": "0.12.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
|
||||||
"integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
|
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"ieee754": "^1.1.12",
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
"bin": {
|
||||||
"version": "2.2.0",
|
"pbf": "bin/pbf"
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/puzrin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/nodeca"
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
|
|
@ -1562,40 +1665,20 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/potpack": {
|
||||||
"version": "7.6.5",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
|
||||||
"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_modules/protocol-buffers-schema": {
|
||||||
"node": ">=12.0.0"
|
"version": "3.6.0",
|
||||||
}
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
|
||||||
},
|
},
|
||||||
"node_modules/rbush": {
|
"node_modules/quickselect": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
|
||||||
"dependencies": {
|
|
||||||
"quickselect": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rbush/node_modules/quickselect": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
|
|
@ -1610,6 +1693,14 @@
|
||||||
"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",
|
||||||
|
|
@ -1649,6 +1740,11 @@
|
||||||
"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",
|
||||||
|
|
@ -1690,6 +1786,14 @@
|
||||||
"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",
|
||||||
|
|
@ -1737,6 +1841,15 @@
|
||||||
"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",
|
||||||
|
|
@ -1753,18 +1866,10 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/topojson-client": {
|
"node_modules/tinyqueue": {
|
||||||
"version": "3.1.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
|
||||||
"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",
|
||||||
|
|
@ -1775,11 +1880,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1796,12 +1896,8 @@
|
||||||
"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",
|
||||||
|
|
@ -1891,6 +1987,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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 '' ; node scripts/copy-cesium.js",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"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,16 +28,15 @@
|
||||||
"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",
|
||||||
"overrides": {
|
"svelte5-chartjs": "^1.0.0"
|
||||||
"@zip.js/zip.js": "2.7.73"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ 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({
|
||||||
// Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node).
|
testDir: './tests/e2e',
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// 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 (Cesium,
|
* panel-container geometry, and overrides for third-party libs (MapLibre,
|
||||||
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
|
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -93,6 +93,27 @@ 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,11 +4,6 @@
|
||||||
<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,4 +3,5 @@ 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, buildLaunchDateTime } from './predictions';
|
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||||
|
export { windApi, type WindFieldParams } from './wind';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
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();
|
||||||
|
|
@ -12,17 +24,11 @@ export interface PredictionResponse {
|
||||||
|
|
||||||
export const predictionsApi = {
|
export const predictionsApi = {
|
||||||
run: (params: FlightParameters, launchDateTime: string) => {
|
run: (params: FlightParameters, launchDateTime: string) => {
|
||||||
// `dataset` carries only what the operator actually chose. It used to fall
|
const payload: FlightParameters & { launch_datetime: string } = {
|
||||||
// back to a client-side guess at which GFS run the server holds — and the
|
...params,
|
||||||
// guess had degenerated into a hardcoded 2025-04-06, over a year stale. The
|
dataset: params.dataset || getLatestDataset(),
|
||||||
// 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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
58
src/lib/api/wind.ts
Normal file
58
src/lib/api/wind.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 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,134 +1,26 @@
|
||||||
import type { LatLngTuple } from './geo';
|
import type { LatLngTuple } from './geo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
|
* Axis-aligned geographic bounding box around a flight path, with an optional
|
||||||
* to east/north at its own centre, with four lat/lon corners joined by great
|
* margin so recovery teams get a box that clears the trajectory by a set
|
||||||
* circles.
|
* distance rather than hugging its extreme points.
|
||||||
*
|
|
||||||
* 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;
|
||||||
* The filed corners, joined by great circles, in the order the operator reads
|
west: number;
|
||||||
* them: NW, NE, SE, SW of the centre's own east/north frame.
|
north: number;
|
||||||
*/
|
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;
|
||||||
|
|
||||||
const R_KM = 6371;
|
// Mean length of one degree of latitude. Longitude degrees shrink toward the
|
||||||
const D2R = Math.PI / 180;
|
// poles, handled below via cos(latitude).
|
||||||
|
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A local frame: the point itself plus earth-centred east and north unit vectors.
|
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
|
||||||
*
|
|
||||||
* 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(
|
||||||
|
|
@ -137,71 +29,52 @@ export function computeBoundingBox(
|
||||||
): BoundingBox | null {
|
): BoundingBox | null {
|
||||||
if (path.length === 0) return null;
|
if (path.length === 0) return null;
|
||||||
|
|
||||||
const points = path.map(toVec);
|
let south = Infinity;
|
||||||
|
let north = -Infinity;
|
||||||
|
let west = Infinity;
|
||||||
|
let east = -Infinity;
|
||||||
|
for (const [lat, lng] of path) {
|
||||||
|
if (lat < south) south = lat;
|
||||||
|
if (lat > north) north = lat;
|
||||||
|
if (lng < west) west = lng;
|
||||||
|
if (lng > east) east = lng;
|
||||||
|
}
|
||||||
|
|
||||||
const margin = Math.max(0, marginKm);
|
const margin = Math.max(0, marginKm);
|
||||||
|
const dLat = margin / KM_PER_DEG_LAT;
|
||||||
// Two passes. The first, framed on the track's mean direction, only locates
|
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
|
||||||
// the box centre; the second frames on that centre, which makes the four
|
// Size the longitude margin at the latitude nearest a pole so the box never
|
||||||
// corners symmetric about it and keeps the projection error smallest where
|
// comes in tighter than requested along the whole band.
|
||||||
// the corners actually are.
|
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
|
||||||
const first = frame(centroid(points));
|
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
|
||||||
const rough = extent(first, points);
|
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
|
||||||
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 {
|
||||||
corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
|
south: south - dLat,
|
||||||
centre: toLatLng(unproject(f, cx, cy)),
|
north: north + dLat,
|
||||||
widthKm: 2 * ex,
|
west: west - dLng,
|
||||||
heightKm: 2 * ey,
|
east: east + dLng,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Samples per edge when drawing. Keeps segments short enough for any renderer. */
|
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
|
||||||
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[] {
|
||||||
const v = box.corners.map(toVec);
|
return [
|
||||||
const ring: LatLngTuple[] = [];
|
[box.south, box.west],
|
||||||
for (let e = 0; e < 4; e++) {
|
[box.north, box.west],
|
||||||
const from = v[e];
|
[box.north, box.east],
|
||||||
const to = v[(e + 1) % 4];
|
[box.south, box.east],
|
||||||
for (let i = 0; i < RING_STEPS_PER_EDGE; i++) {
|
[box.south, box.west],
|
||||||
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 {
|
||||||
return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n');
|
const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
return [
|
||||||
|
fmt(box.north, box.west),
|
||||||
|
fmt(box.north, box.east),
|
||||||
|
fmt(box.south, box.east),
|
||||||
|
fmt(box.south, box.west),
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
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,9 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Geographic primitives used by map layers and predictions.
|
* Geographic primitives used by map layers and predictions.
|
||||||
*
|
*
|
||||||
* LngLat convention is longitude-first for on-map work, matching Cesium's
|
* LngLat convention matches MapLibre (longitude first) for on-map work;
|
||||||
* Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
|
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
|
||||||
* legacy Leaflet-era code paths.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LatLng {
|
export interface LatLng {
|
||||||
|
|
@ -12,45 +11,6 @@ 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';
|
|
||||||
|
|
|
||||||
214
src/lib/domain/wind.ts
Normal file
214
src/lib/domain/wind.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
/**
|
||||||
|
* 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];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<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 +0,0 @@
|
||||||
export { default as MapChrome } from './MapChrome.svelte';
|
|
||||||
|
|
@ -27,10 +27,7 @@
|
||||||
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,
|
||||||
|
|
@ -78,17 +75,7 @@
|
||||||
|
|
||||||
function patchActive(patch: Partial<FlightParameters>) {
|
function patchActive(patch: Partial<FlightParameters>) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
// Single choke point for launch latitude: typing it, clicking the map
|
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
|
||||||
// (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) {
|
||||||
|
|
@ -238,8 +225,6 @@
|
||||||
<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,15 +10,7 @@
|
||||||
} 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 {
|
import { PREDICTION_MODES, type SavedScenario } from '$domain';
|
||||||
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';
|
||||||
|
|
@ -26,32 +18,8 @@
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -204,12 +172,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" bind:value={exportFormat}>
|
<Input type="select" class="form-control-sm">
|
||||||
{#each EXPORT_FORMATS as f (f)}
|
<option>JSON</option>
|
||||||
<option value={f}>{f}</option>
|
<option>CSV</option>
|
||||||
{/each}
|
<option>KML</option>
|
||||||
</Input>
|
</Input>
|
||||||
<Button color="primary" disabled={!active?.result} onclick={handleExport}>
|
<Button color="primary">
|
||||||
<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 } from './store';
|
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } 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,12 +61,10 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
|
||||||
path: 'map.baseLayer',
|
path: 'map.baseLayer',
|
||||||
labelKey: 'settings.baseLayer',
|
labelKey: 'settings.baseLayer',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'osm', labelKey: 'settings.baseLayerOsm' },
|
{ value: 'osm', labelKey: 'settings.baseLayer' },
|
||||||
{ value: 'satellite', labelKey: 'settings.baseLayerSatellite' },
|
{ value: 'satellite', labelKey: 'settings.baseLayer' },
|
||||||
{ 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' },
|
||||||
],
|
],
|
||||||
|
|
@ -85,4 +83,90 @@ 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,17 +1,13 @@
|
||||||
import { persisted } from '$state';
|
import { persisted } from '$state';
|
||||||
import type { Locale } from '$i18n';
|
import type { Locale } from '$i18n';
|
||||||
import type { BaseLayerId } from '$map';
|
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
||||||
|
|
||||||
|
export type { WindSettings };
|
||||||
|
|
||||||
export interface MapSettings {
|
export interface MapSettings {
|
||||||
baseLayer: BaseLayerId;
|
baseLayer: 'osm' | 'satellite';
|
||||||
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 {
|
||||||
|
|
@ -22,12 +18,14 @@ 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, graticule: true },
|
map: { baseLayer: 'osm', showScale: true, showNavigation: 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);
|
||||||
|
|
|
||||||
335
src/lib/features/wind/ParticleField.ts
Normal file
335
src/lib/features/wind/ParticleField.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
332
src/lib/features/wind/WindRenderer.svelte
Normal file
332
src/lib/features/wind/WindRenderer.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<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>
|
||||||
3
src/lib/features/wind/index.ts
Normal file
3
src/lib/features/wind/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as WindRenderer } from './WindRenderer.svelte';
|
||||||
|
export { windCache } from './store';
|
||||||
|
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';
|
||||||
61
src/lib/features/wind/store.ts
Normal file
61
src/lib/features/wind/store.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* 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,15 +73,6 @@
|
||||||
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,15 +199,6 @@
|
||||||
</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,8 +56,7 @@
|
||||||
"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",
|
||||||
|
|
@ -139,10 +138,18 @@
|
||||||
"metric": "Metric",
|
"metric": "Metric",
|
||||||
"imperial": "Imperial",
|
"imperial": "Imperial",
|
||||||
"saved": "Settings saved",
|
"saved": "Settings saved",
|
||||||
"baseLayerOsm": "Map (OpenStreetMap)",
|
"wind": "Wind visualization",
|
||||||
"baseLayerSatellite": "Satellite (Esri)",
|
"windEnabled": "Show wind layer",
|
||||||
"graticule": "Lat/lon grid",
|
"windStep": "Grid resolution (°)",
|
||||||
"baseLayerPolar": "Polar map (offline, covers the poles)"
|
"windTrajectoryStep": "Trajectory grid res. (°)",
|
||||||
|
"windPrefetchInterval": "Pre-fetch interval (min)",
|
||||||
|
"windMaxDuration": "Max sync duration (h)",
|
||||||
|
"windMaxRegion": "Max region size (°)",
|
||||||
|
"windMargin": "Trajectory margin (°)",
|
||||||
|
"windParticleDensity": "Particle density",
|
||||||
|
"windParticleSpeed": "Particle speed",
|
||||||
|
"windTrailPersistence": "Trail length",
|
||||||
|
"windMaxVelocity": "Max wind speed (m/s)"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,7 @@
|
||||||
"datasetAuto": "Выбрать автоматически",
|
"datasetAuto": "Выбрать автоматически",
|
||||||
"modified": "изменено",
|
"modified": "изменено",
|
||||||
"export": "Экспортировать результат",
|
"export": "Экспортировать результат",
|
||||||
"exportBtn": "Экспорт",
|
"exportBtn": "Экспорт"
|
||||||
"exportNoResult": "Сначала выполните расчёт — экспортировать нечего."
|
|
||||||
},
|
},
|
||||||
"predictionMode": {
|
"predictionMode": {
|
||||||
"single": "Разовый",
|
"single": "Разовый",
|
||||||
|
|
@ -139,10 +138,18 @@
|
||||||
"metric": "Метрические",
|
"metric": "Метрические",
|
||||||
"imperial": "Имперские",
|
"imperial": "Имперские",
|
||||||
"saved": "Настройки сохранены",
|
"saved": "Настройки сохранены",
|
||||||
"baseLayerOsm": "Карта (OpenStreetMap)",
|
"wind": "Визуализация ветра",
|
||||||
"baseLayerSatellite": "Спутник (Esri)",
|
"windEnabled": "Показывать слой ветра",
|
||||||
"graticule": "Сетка координат",
|
"windStep": "Шаг сетки (°)",
|
||||||
"baseLayerPolar": "Полярная карта (офлайн, до полюсов)"
|
"windTrajectoryStep": "Шаг сетки по траектории (°)",
|
||||||
|
"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 { BaseLayerId, IMap } from './core';
|
import type { IMap } from './core';
|
||||||
import type { LngLatTuple } from '$domain';
|
import type { LngLatTuple } from '$domain';
|
||||||
import { createCesiumMap } from './cesium';
|
import { createMapLibreMap } from './maplibre';
|
||||||
import { setMapContext } from './context';
|
import { setMapContext } from './context';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
center?: LngLatTuple;
|
center?: LngLatTuple;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
baseLayer?: BaseLayerId;
|
baseLayer?: 'osm' | 'satellite';
|
||||||
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 `map.ready` resolves. Cesium's Viewer is
|
* Children must not render until the map's first `load` event. MapLibre
|
||||||
* usable synchronously, but features still expect this gate, so it stays as
|
* throws if addSource/addLayer is called on an unloaded style, and this
|
||||||
* the single place that decides when the map may be drawn on.
|
* component is the natural gate for that invariant.
|
||||||
*/
|
*/
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
map = createCesiumMap({
|
map = createMapLibreMap({
|
||||||
container,
|
container,
|
||||||
center,
|
center,
|
||||||
zoom,
|
zoom,
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
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 Cesium-specific types inside src/lib/map/cesium.ts.
|
* - Isolate all MapLibre-specific types inside src/lib/map/maplibre.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 Cesium, implementing IMap is the only
|
* If another library ever replaces MapLibre, implementing IMap is the only
|
||||||
* file that changes.
|
* file that changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -34,11 +34,6 @@ 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;
|
||||||
|
|
@ -47,7 +42,6 @@ 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;
|
||||||
|
|
@ -101,12 +95,6 @@ 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
|
||||||
|
|
@ -121,20 +109,11 @@ 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?: BaseLayerId;
|
baseLayer?: 'osm' | 'satellite';
|
||||||
showNavigationControl?: boolean;
|
showNavigationControl?: boolean;
|
||||||
showScaleControl?: boolean;
|
showScaleControl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export * from './core';
|
export * from './core';
|
||||||
export { createCesiumMap } from './cesium';
|
export { createMapLibreMap } from './maplibre';
|
||||||
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 the concrete
|
* Plot helpers for high-level domain objects. These live outside MapLibreMap
|
||||||
* map class so they can be reused against any IMap implementation.
|
* 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,9 +62,6 @@ 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)}`,
|
||||||
|
|
@ -146,8 +143,6 @@ 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,
|
||||||
|
|
|
||||||
324
src/lib/map/maplibre.ts
Normal file
324
src/lib/map/maplibre.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
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,7 +4,6 @@
|
||||||
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';
|
||||||
|
|
@ -13,6 +12,7 @@
|
||||||
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,7 +8,6 @@
|
||||||
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';
|
||||||
|
|
@ -117,7 +116,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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,25 +65,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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 map canvas', async ({ page }) => {
|
test('predict page loads and mounts the MapLibre 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, sceneObjectCount } from './fixtures';
|
import { test, expect, openPredict, login } from './fixtures';
|
||||||
|
|
||||||
test.beforeEach(async ({ context, page }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
await login(context);
|
await login(context);
|
||||||
|
|
@ -6,9 +6,14 @@ test.beforeEach(async ({ context, page }) => {
|
||||||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Count map objects whose scoped id belongs to a bounding-box scene. */
|
/** Count map layers whose scoped id belongs to a bounding-box scene. */
|
||||||
function bboxLayerCount(page: import('@playwright/test').Page) {
|
function bboxLayerCount(page: import('@playwright/test').Page) {
|
||||||
return sceneObjectCount(page, 'bbox');
|
return page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const map: any = (window as any)._lsvMap;
|
||||||
|
if (!map) return 0;
|
||||||
|
return map.getStyle().layers.filter((l: { id: string }) => l.id.startsWith('bbox')).length;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
||||||
|
|
@ -27,10 +32,18 @@ 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(() => sceneObjectCount(page, 'ws/'), {
|
.poll(
|
||||||
timeout: 75_000,
|
() =>
|
||||||
intervals: [1000, 2000, 3000],
|
page.evaluate(() => {
|
||||||
})
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const map: any = (window as any)._lsvMap;
|
||||||
|
if (!map) return 0;
|
||||||
|
return map
|
||||||
|
.getStyle()
|
||||||
|
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||||
|
}),
|
||||||
|
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||||
|
)
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// Enable the bounding box for this forecast.
|
// Enable the bounding box for this forecast.
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
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, sceneObjectCount } from './fixtures';
|
import { test, expect, openPredict, login } from './fixtures';
|
||||||
|
|
||||||
test.beforeEach(async ({ context, page }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
await login(context);
|
await login(context);
|
||||||
|
|
@ -52,75 +52,17 @@ 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(() => sceneObjectCount(page, 'ws/'), {
|
.poll(
|
||||||
timeout: 75_000,
|
async () =>
|
||||||
intervals: [1000, 2000, 3000],
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regression: a flight is only tens of km across, which is a few dozen pixels
|
|
||||||
// at the default camera height — the track ends up hidden under its own launch/
|
|
||||||
// burst/landing markers and looks like a single dot. A fresh result must be
|
|
||||||
// framed by the camera.
|
|
||||||
test('camera frames the trajectory after a run', async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
await openPredict(page);
|
|
||||||
|
|
||||||
const camera = () =>
|
|
||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const v: any = (window as any)._lsvMap;
|
const map: any = (window as any)._lsvMap;
|
||||||
const c = v.camera.positionCartographic;
|
if (!map) return 0;
|
||||||
return {
|
return map
|
||||||
lng: (c.longitude * 180) / Math.PI,
|
.getStyle()
|
||||||
lat: (c.latitude * 180) / Math.PI,
|
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||||
height: c.height,
|
}),
|
||||||
};
|
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||||
/* 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);
|
.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);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
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