Compare commits

..

No commits in common. "celium_globe" and "master" have entirely different histories.

54 changed files with 2009 additions and 3792 deletions

7
.gitignore vendored
View file

@ -25,10 +25,3 @@ vite.config.ts.timestamp-*
# AI tools
.claude
tmpclaude*
# generated from node_modules/cesium by scripts/copy-cesium.js
static/cesium/
# Playwright output
/test-results/
/playwright-report/

View file

@ -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.

View file

@ -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.

View file

@ -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
View file

@ -8,14 +8,16 @@
"name": "app4",
"version": "0.0.1",
"dependencies": {
"@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.13.1",
"cesium": "1.129.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
"luxon": "^3.6.1"
"luxon": "^3.6.1",
"maplibre-gl": "^4.0.0",
"svelte5-chartjs": "^1.0.0"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
@ -43,57 +45,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@cesium/engine": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-17.0.0.tgz",
"integrity": "sha512-bvLWmWVY4QX9rfcx/zfBzA8R1xR8KzmaCDOVL6pFkNZeYmRtt8JN//IICYR3P45lznlcb0Dklw1iCb37t4tvLA==",
"dependencies": {
"@tweenjs/tween.js": "^25.0.0",
"@zip.js/zip.js": "^2.7.34",
"autolinker": "^4.0.0",
"bitmap-sdf": "^1.0.3",
"dompurify": "^3.0.2",
"draco3d": "^1.5.1",
"earcut": "^3.0.0",
"grapheme-splitter": "^1.0.4",
"jsep": "^1.3.8",
"kdbush": "^4.0.1",
"ktx-parse": "^1.0.0",
"lerc": "^2.0.0",
"mersenne-twister": "^1.1.0",
"meshoptimizer": "^0.23.0",
"pako": "^2.0.4",
"protobufjs": "^7.1.0",
"rbush": "3.0.1",
"topojson-client": "^3.1.0",
"urijs": "^1.19.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@cesium/engine/node_modules/@zip.js/zip.js": {
"version": "2.7.73",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.73.tgz",
"integrity": "sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==",
"engines": {
"bun": ">=0.7.0",
"deno": ">=1.0.0",
"node": ">=16.5.0"
}
},
"node_modules/@cesium/widgets": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-12.0.0.tgz",
"integrity": "sha512-5Re06LU8lRPEZInHrpOCGBiLoj9VNJ0JJtPrzdnw1qhIDpf3v1W75OJf59k8CCFvUYkZzuTTi8qTVbpzmcbOuw==",
"dependencies": {
"@cesium/engine": "^17.0.0",
"nosleep.js": "^0.12.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
@ -542,6 +493,81 @@
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
},
"bin": {
"geojson-rewind": "geojson-rewind"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug=="
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
},
"node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
"dependencies": {
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "20.4.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
"integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^2.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@ -572,54 +598,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
@ -880,6 +858,47 @@
"win32"
]
},
"node_modules/@sakitam-gis/maplibre-wind": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@sakitam-gis/maplibre-wind/-/maplibre-wind-2.0.3.tgz",
"integrity": "sha512-KeBlh2EJ13+MsFck2l8sKXKz/ogezvnontarSCTmpfzNzB3b9nA+ydzXLFfqqUMrnZwEhsuEG+pKTFMyPv1shg==",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@sakitam-gis/rbush": "3.1.2",
"@sakitam-gis/vis-engine": "^1.5.3",
"gl-matrix": "^3.4.3",
"wind-gl-core": "2.0.2"
},
"peerDependencies": {
"maplibre-gl": ">=3.0.0"
}
},
"node_modules/@sakitam-gis/rbush": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@sakitam-gis/rbush/-/rbush-3.1.2.tgz",
"integrity": "sha512-pnNaLnxFBBMnHgGjFX+h2jkpZQg2vXquvDv1BUKfU72uJzJqPcS8smaLydJqcbXp8p7GruoPrQzUpqYG0MYyIg==",
"dependencies": {
"quickselect": "^2.0.0"
}
},
"node_modules/@sakitam-gis/rbush/node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
},
"node_modules/@sakitam-gis/vis-engine": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@sakitam-gis/vis-engine/-/vis-engine-1.5.3.tgz",
"integrity": "sha512-IpuZwi0XRflJiP1mNTwOSjlAJZRCczOuVh6s/feVOpXctiAoSWrAuhK0HVITLpCWAQF1bN6CRKA3LW0z1nCr0g==",
"dependencies": {
"colord": "^2.9.3",
"gl-matrix": "^3.4.3"
},
"engines": {
"node": ">= 14.18.1",
"npm": ">= 6.14.15"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
@ -978,11 +997,6 @@
"svelte": "^4.0.0 || ^5.0.0 || ^5.0.0-next.0"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -994,25 +1008,61 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
},
"node_modules/@types/mapbox__vector-tile": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
"dependencies": {
"@types/geojson": "*",
"@types/mapbox__point-geometry": "*",
"@types/pbf": "*"
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@vincjo/datatables": {
"version": "2.5.0",
@ -1042,17 +1092,6 @@
"node": ">= 0.4"
}
},
"node_modules/autolinker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz",
"integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==",
"dependencies": {
"tslib": "^2.8.1"
},
"engines": {
"pnpm": ">=10.10.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -1061,11 +1100,6 @@
"node": ">= 0.4"
}
},
"node_modules/bitmap-sdf": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz",
"integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg=="
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
@ -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": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
@ -1148,10 +1170,10 @@
"node": ">=6"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
"node_modules/cookie": {
"version": "0.6.0",
@ -1222,19 +1244,6 @@
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"dev": true
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
@ -1293,6 +1302,11 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
},
"node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
@ -1321,10 +1335,66 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="
},
"node_modules/global-prefix": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
"dependencies": {
"ini": "^4.1.3",
"kind-of": "^6.0.3",
"which": "^4.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
@ -1334,6 +1404,14 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/isexe": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"engines": {
"node": ">=16"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -1342,19 +1420,24 @@
"node": ">=14"
}
},
"node_modules/jsep": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"engines": {
"node": ">= 10.16.0"
}
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -1364,26 +1447,11 @@
"node": ">=6"
}
},
"node_modules/ktx-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
},
"node_modules/lerc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz",
"integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg=="
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
@ -1400,15 +1468,53 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/mersenne-twister": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
"integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
"node_modules/maplibre-gl": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
"@types/geojson": "^7946.0.14",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^3.0.0",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.3",
"global-prefix": "^4.0.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^3.3.0",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0",
"vt-pbf": "^3.1.3"
},
"node_modules/meshoptimizer": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz",
"integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"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": {
"version": "1.2.0",
@ -1434,6 +1540,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1452,25 +1563,17 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nosleep.js": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
"integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
"node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
"dependencies": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
"bin": {
"pbf": "bin/pbf"
}
]
},
"node_modules/picocolors": {
"version": "1.1.1",
@ -1562,40 +1665,20 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/protobufjs": {
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"hasInstallScript": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
},
"engines": {
"node": ">=12.0.0"
}
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
},
"node_modules/rbush": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
"dependencies": {
"quickselect": "^2.0.0"
}
},
"node_modules/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/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
},
"node_modules/readdirp": {
"version": "4.1.2",
@ -1610,6 +1693,14 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rollup": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
@ -1649,6 +1740,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -1690,6 +1786,14 @@
"node": ">=0.10.0"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/svelte": {
"version": "5.34.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.8.tgz",
@ -1737,6 +1841,15 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte5-chartjs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte5-chartjs/-/svelte5-chartjs-1.0.0.tgz",
"integrity": "sha512-SMk+D5ECbsoeFurKE/Nr9sqD4H3WqZkQ4eLxwchDSh8gu7YSGN3ASXYCz9kzFhrH2QGQYpebHwLIMHg7FOI/7A==",
"peerDependencies": {
"chart.js": "^3.5.0 || ^4.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@ -1753,18 +1866,10 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
},
"node_modules/totalist": {
"version": "3.0.1",
@ -1775,11 +1880,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": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@ -1796,12 +1896,8 @@
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
},
"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=="
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true
},
"node_modules/vite": {
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",

View file

@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build",
"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:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test:e2e": "playwright test",
@ -28,16 +28,15 @@
"vite": "^6.2.5"
},
"dependencies": {
"@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.13.1",
"cesium": "1.129.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
"luxon": "^3.6.1"
},
"overrides": {
"@zip.js/zip.js": "2.7.73"
"luxon": "^3.6.1",
"maplibre-gl": "^4.0.0",
"svelte5-chartjs": "^1.0.0"
}
}

View file

@ -15,8 +15,7 @@ import { defineConfig, devices } from '@playwright/test';
* npm run test:e2e:ui # Playwright UI mode
*/
export default defineConfig({
// Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node).
testDir: './tests',
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false, // the mock plugin writes to shared JSON files on disk

View file

@ -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/');

View file

@ -9,7 +9,7 @@
* Global application styles.
*
* Keep this file focused on cross-feature concerns: the navbar chrome, the
* panel-container geometry, and overrides for third-party libs (Cesium,
* panel-container geometry, and overrides for third-party libs (MapLibre,
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
*/
@ -93,6 +93,27 @@ body {
right: var(--panel-left);
}
.maplibregl-ctrl-group {
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
}
.maplibregl-popup-tip {
border-top-color: var(--bs-border-color) !important;
}
.maplibregl-popup-content {
background-color: var(--bs-body-bg) !important;
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
color: var(--bs-body-color);
box-shadow: none !important;
}
.maplibregl-popup-close-button {
color: var(--bs-body-color);
}
.modal-backdrop {
opacity: var(--bs-backdrop-opacity) !important;
}

View file

@ -4,11 +4,6 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Cesium resolves Workers/Assets/Widgets at runtime from this base.
Populated by scripts/copy-cesium.js; must be set before Cesium loads. -->
<script>
window.CESIUM_BASE_URL = '/cesium/';
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -3,4 +3,5 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
export { pointsApi } from './points';
export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios';
export { predictionsApi, buildLaunchDateTime } from './predictions';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
export { windApi, type WindFieldParams } from './wind';

View file

@ -1,6 +1,18 @@
import { api } from './client';
import type { FlightParameters, RawPrediction } from '$domain';
/**
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
* Round down to the most recent available slot.
*/
export function getLatestDataset(now: Date = new Date()): string {
// const rounded = new Date(now);
// rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
// rounded.setUTCHours(rounded.getUTCHours() - 6);
// return rounded.toISOString();
return "2025-04-06T00:00:00Z";
}
export function buildLaunchDateTime(date: string, time: string): string {
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
return new Date(`${date}T${fullTime}Z`).toISOString();
@ -12,17 +24,11 @@ export interface PredictionResponse {
export const predictionsApi = {
run: (params: FlightParameters, launchDateTime: string) => {
// `dataset` carries only what the operator actually chose. It used to fall
// back to a client-side guess at which GFS run the server holds — and the
// guess had degenerated into a hardcoded 2025-04-06, over a year stale. The
// client cannot know which runs are stored; the predictor refuses one it
// does not have, so an unset value must stay unset and let the server pick.
const { dataset, ...rest } = params;
const payload = {
...rest,
...(dataset ? { dataset } : {}),
const payload: FlightParameters & { launch_datetime: string } = {
...params,
dataset: params.dataset || getLatestDataset(),
launch_datetime: launchDateTime,
} as FlightParameters & { launch_datetime: string };
};
if (payload.start_point === -1) delete payload.start_point;
return api.post<PredictionResponse>('/predictions/', payload);
},

58
src/lib/api/wind.ts Normal file
View 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');
},
};

View file

@ -1,134 +1,26 @@
import type { LatLngTuple } from './geo';
/**
* The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
* to east/north at its own centre, with four lat/lon corners joined by great
* circles.
*
* Deliberately not a rectangle in degrees. That form cannot be made to work near
* a pole: every meridian passes through the pole, so any lat/lon rectangle that
* contains one spans all 360 degrees of longitude. On a real launch from 89.99 N
* it produced the entire cap north of 88.93 44 200 km^2 in place of the
* 13.7 x 123.4 km corridor the balloon actually flies. There is no latitude
* threshold below which the degree form is safe: it either contains the pole and
* balloons, or it fails to contain the trajectory.
*
* Working in kilometres removes the whole class of problem rather than the
* instance. Nothing here divides by cos(latitude), clamps to a pole, or branches
* on how far north it is.
* Axis-aligned geographic bounding box around a flight path, with an optional
* margin so recovery teams get a box that clears the trajectory by a set
* distance rather than hugging its extreme points.
*/
export interface BoundingBox {
/**
* The filed corners, joined by great circles, in the order the operator reads
* them: NW, NE, SE, SW of the centre's own east/north frame.
*/
corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple];
centre: LatLngTuple;
/** East-west size in km, including the margin on both sides. */
widthKm: number;
/** North-south size in km, including the margin on both sides. */
heightKm: number;
south: number;
west: number;
north: number;
east: number;
}
/** Default clearance (km) between the box edge and the nearest trajectory point. */
export const DEFAULT_BBOX_MARGIN_KM = 5;
const R_KM = 6371;
const D2R = Math.PI / 180;
type Vec = [number, number, number];
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const clamp1 = (v: number) => (v > 1 ? 1 : v < -1 ? -1 : v);
function toVec([lat, lng]: LatLngTuple): Vec {
const p = lat * D2R;
const l = lng * D2R;
return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
}
function toLatLng(v: Vec): LatLngTuple {
return [Math.asin(clamp1(v[2])) / D2R, Math.atan2(v[1], v[0]) / D2R];
}
// Mean length of one degree of latitude. Longitude degrees shrink toward the
// poles, handled below via cos(latitude).
const KM_PER_DEG_LAT = 111.32;
/**
* A local frame: the point itself plus earth-centred east and north unit vectors.
*
* All three are unit length and mutually orthogonal at every latitude, the poles
* included at 90 N, east is (-sin l, cos l, 0) and north is (-cos l, -sin l, 0),
* both still unit. This is the same construction the predictor's integrator uses
* (internal/numerics/spherical.go) and for the same reason: it is where the
* cos(latitude) singularity would otherwise live.
*/
function frame(origin: Vec) {
const [lat, lng] = toLatLng(origin);
const p = lat * D2R;
const l = lng * D2R;
return {
origin,
east: [-Math.sin(l), Math.cos(l), 0] as Vec,
north: [-Math.sin(p) * Math.cos(l), -Math.sin(p) * Math.sin(l), Math.cos(p)] as Vec,
};
}
type Frame = ReturnType<typeof frame>;
/**
* Azimuthal equidistant offsets of `p` from the frame origin, in km east and
* north. Distance from the origin is exact at any range; only the shape of
* something far from the origin is distorted, and a flight is never far.
*/
function project(f: Frame, p: Vec): [number, number] {
const e = dot(p, f.east);
const n = dot(p, f.north);
const t = Math.hypot(e, n);
if (t === 0) return [0, 0]; // p is the origin itself, or its antipode
const r = R_KM * Math.acos(clamp1(dot(p, f.origin)));
return [(r * e) / t, (r * n) / t];
}
/** Inverse of project: walk `x` km east and `y` km north of the origin. */
function unproject(f: Frame, x: number, y: number): Vec {
const r = Math.hypot(x, y);
if (r === 0) return f.origin;
const a = r / R_KM;
const c = Math.cos(a);
const s = Math.sin(a);
return [0, 1, 2].map(
(i) => f.origin[i] * c + ((x / r) * f.east[i] + (y / r) * f.north[i]) * s,
) as Vec;
}
/** Centre and half-sizes, in km, of the smallest axis-aligned box in this frame. */
function extent(f: Frame, points: Vec[]) {
let xMin = Infinity;
let xMax = -Infinity;
let yMin = Infinity;
let yMax = -Infinity;
for (const p of points) {
const [x, y] = project(f, p);
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
return {
cx: (xMin + xMax) / 2,
cy: (yMin + yMax) / 2,
hx: (xMax - xMin) / 2,
hy: (yMax - yMin) / 2,
};
}
/** Mean direction of the points. Falls back to the first point if they cancel. */
function centroid(points: Vec[]): Vec {
const sum = points.reduce<Vec>((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]);
const len = Math.hypot(...sum);
return len < 1e-9 ? points[0] : (sum.map((c) => c / len) as Vec);
}
/**
* Compute the restricted area around a flight path, clearing it by `marginKm`.
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
* Returns null for an empty path (callers treat that as "nothing to draw").
*/
export function computeBoundingBox(
@ -137,71 +29,52 @@ export function computeBoundingBox(
): BoundingBox | 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);
// Two passes. The first, framed on the track's mean direction, only locates
// the box centre; the second frames on that centre, which makes the four
// corners symmetric about it and keeps the projection error smallest where
// the corners actually are.
const first = frame(centroid(points));
const rough = extent(first, points);
const f = frame(unproject(first, rough.cx, rough.cy));
const { cx, cy, hx, hy } = extent(f, points);
const ex = hx + margin;
const ey = hy + margin;
// A great-circle edge bows away from the frame origin relative to its chord
// in this projection, so the filed quad contains the box measured here. The
// margin is a floor, never eaten.
const corner = (sx: number, sy: number) => toLatLng(unproject(f, cx + sx * ex, cy + sy * ey));
const dLat = margin / KM_PER_DEG_LAT;
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
// Size the longitude margin at the latitude nearest a pole so the box never
// comes in tighter than requested along the whole band.
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
return {
corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
centre: toLatLng(unproject(f, cx, cy)),
widthKm: 2 * ex,
heightKm: 2 * ey,
south: south - dLat,
north: north + dLat,
west: west - dLng,
east: east + dLng,
};
}
/** Samples per edge when drawing. Keeps segments short enough for any renderer. */
const RING_STEPS_PER_EDGE = 16;
/** Point a fraction `t` along the great circle from `a` to `b`. */
function slerp(a: Vec, b: Vec, t: number): Vec {
const w = Math.acos(clamp1(dot(a, b)));
const s = Math.sin(w);
if (s < 1e-12) return a;
const wa = Math.sin((1 - t) * w) / s;
const wb = Math.sin(t * w) / s;
return [0, 1, 2].map((i) => a[i] * wa + b[i] * wb) as Vec;
}
/**
* Closed ring for drawing the box, sampled along each great-circle edge.
*
* Corners alone are not enough. The drawn shape would then depend on the
* renderer's interpolation mode rather than on the filed geometry, and a long
* single segment landing on the antimeridian is what previously stopped Cesium's
* render loop: its splitLongitude pass emitted mismatched attribute lists and
* threw "All attribute lists must have the same number of attributes". Short
* explicit samples have neither problem.
*/
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
const v = box.corners.map(toVec);
const ring: LatLngTuple[] = [];
for (let e = 0; e < 4; e++) {
const from = v[e];
const to = v[(e + 1) % 4];
for (let i = 0; i < RING_STEPS_PER_EDGE; i++) {
ring.push(toLatLng(slerp(from, to, i / RING_STEPS_PER_EDGE)));
}
}
ring.push(ring[0]);
return ring;
return [
[box.south, box.west],
[box.north, box.west],
[box.north, box.east],
[box.south, box.east],
[box.south, box.west],
];
}
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
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');
}

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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]}`;
}

View file

@ -1,9 +1,8 @@
/**
* Geographic primitives used by map layers and predictions.
*
* LngLat convention is longitude-first for on-map work, matching Cesium's
* Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
* legacy Leaflet-era code paths.
* LngLat convention matches MapLibre (longitude first) for on-map work;
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
*/
export interface LatLng {
@ -12,45 +11,6 @@ export interface LatLng {
alt?: number;
}
/**
* The pole. This is geographic reality, not a workaround.
*
* An earlier revision capped launches at 89.999° because the predictor's
* longitude rate went as 1/cos(lat) against a fixed step and became
* unreproducible near the pole. That formulation is gone it now integrates
* along great circles and handles 90° exactly so the cap is the real limit.
*/
export const MAX_LAUNCH_LATITUDE = 90;
/**
* Constrain a latitude to ±90. NaN passes through so a half-typed input does
* not jump under the user's cursor.
*
* Latitude clamps rather than wraps: a pole is a barrier, and 91°N is not a
* place. Longitude is the opposite case see wrapLongitude.
*/
export function clampLaunchLatitude(lat: number): number {
if (!Number.isFinite(lat)) return lat;
return Math.min(MAX_LAUNCH_LATITUDE, Math.max(-MAX_LAUNCH_LATITUDE, lat));
}
/**
* Wrap a longitude into [-180, 180).
*
* Wraps rather than clamps because a meridian is not a barrier: 200°E is the
* same place as -160°, so wrapping keeps the point the user meant, while
* clamping to 180 would silently move it 20° away.
*
* Distinct from normalizeLng, which only folds the 0..360 convention the API
* sometimes returns and is not a general wrap.
*/
export function wrapLongitude(lng: number): number {
if (!Number.isFinite(lng)) return lng;
const wrapped = ((lng + 180) % 360 + 360) % 360 - 180;
// -180 and 180 are the same meridian; pick the lower bound consistently.
return wrapped === -0 ? 0 : wrapped;
}
export type LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
export type LatLngExpression = LatLng | LatLngTuple;

View file

@ -3,5 +3,5 @@ export * from './math';
export * from './scenario';
export * from './prediction';
export * from './telemetry';
export * from './wind';
export * from './boundingBox';
export * from './export';

214
src/lib/domain/wind.ts Normal file
View 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 northsouth (la1 = 90,
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
*
* Stepping from the first point toward the last (la1la2, lo1lo2) 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];
};
}

View file

@ -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>

View file

@ -1 +0,0 @@
export { default as MapChrome } from './MapChrome.svelte';

View file

@ -27,10 +27,7 @@
import { pointsApi } from '$api';
import {
DEFAULT_FLIGHT_PARAMETERS,
MAX_LAUNCH_LATITUDE,
PROFILE_IDENTIFIERS,
clampLaunchLatitude,
wrapLongitude,
toFixedNumber,
type FlightParameters,
type ProfileIdentifier,
@ -78,17 +75,7 @@
function patchActive(patch: Partial<FlightParameters>) {
if (!active) return;
// Single choke point for launch latitude: typing it, clicking the map
// (updateLaunchPosition) and picking a saved point all land here, so one
// clamp covers every path.
const safe = { ...patch };
if (safe.launch_latitude !== undefined) {
safe.launch_latitude = clampLaunchLatitude(safe.launch_latitude);
}
if (safe.launch_longitude !== undefined) {
safe.launch_longitude = wrapLongitude(safe.launch_longitude);
}
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...safe });
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
}
function handlePointSelection(newPointId: number | null) {
@ -238,8 +225,6 @@
<Input
type="number"
step="0.000001"
min={-MAX_LAUNCH_LATITUDE}
max={MAX_LAUNCH_LATITUDE}
value={params.launch_latitude}
oninput={(e) =>
patchActive({

View file

@ -10,15 +10,7 @@
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
import { scenariosApi } from '$api';
import {
EXPORT_FORMATS,
PREDICTION_MODES,
exportFilename,
exportMimeType,
serializePrediction,
type ExportFormat,
type SavedScenario,
} from '$domain';
import { PREDICTION_MODES, type SavedScenario } from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { scenariosStore } from './pointsStore';
@ -26,32 +18,8 @@
let selectedScenarioId = $state<number>(-1);
let editorRef: ScenarioEditor | null = $state(null);
let exportFormat = $state<ExportFormat>('JSON');
let active = $derived(getActiveWorkspace($workspacesStore));
function handleExport() {
const result = active?.result;
if (!result) {
addToast({
header: $t('scenario.export'),
body: $t('scenario.exportNoResult'),
color: 'warning',
});
return;
}
const text = serializePrediction(result, exportFormat);
const url = URL.createObjectURL(
new Blob([text], { type: exportMimeType(exportFormat) }),
);
const a = document.createElement('a');
a.href = url;
a.download = exportFilename(result, exportFormat);
a.click();
// Revoking immediately can abort the download in some browsers; let the
// navigation start first.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
let scenarioUnsaved = $derived.by(() => {
if (!active) return false;
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
@ -204,12 +172,12 @@
<FormGroup spacing="mb-0">
<Label class="form-label">{$t('scenario.export')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm" bind:value={exportFormat}>
{#each EXPORT_FORMATS as f (f)}
<option value={f}>{f}</option>
{/each}
<Input type="select" class="form-control-sm">
<option>JSON</option>
<option>CSV</option>
<option>KML</option>
</Input>
<Button color="primary" disabled={!active?.result} onclick={handleExport}>
<Button color="primary">
<span>{$t('scenario.exportBtn')}</span>
<Icon name="file-earmark-arrow-down" />
</Button>

View file

@ -1,5 +1,5 @@
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 { SETTINGS_SCHEMA } from './schema';
export type { SettingsField, SettingsSection } from './schema';

View file

@ -61,12 +61,10 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
path: 'map.baseLayer',
labelKey: 'settings.baseLayer',
options: [
{ value: 'osm', labelKey: 'settings.baseLayerOsm' },
{ value: 'satellite', labelKey: 'settings.baseLayerSatellite' },
{ value: 'polar', labelKey: 'settings.baseLayerPolar' },
{ value: 'osm', labelKey: 'settings.baseLayer' },
{ value: 'satellite', labelKey: 'settings.baseLayer' },
],
},
{ kind: 'boolean', path: 'map.graticule', labelKey: 'settings.graticule' },
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
{ 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,
},
],
},
];

View file

@ -1,17 +1,13 @@
import { persisted } from '$state';
import type { Locale } from '$i18n';
import type { BaseLayerId } from '$map';
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
export type { WindSettings };
export interface MapSettings {
baseLayer: BaseLayerId;
baseLayer: 'osm' | 'satellite';
showScale: boolean;
showNavigation: boolean;
/**
* Optional because `persisted` does not merge defaults into an existing
* stored payload settings saved before the graticule existed simply lack
* the key, so read sites default it to on.
*/
graticule?: boolean;
}
export interface UnitsSettings {
@ -22,12 +18,14 @@ export interface AppSettings {
locale: Locale;
map: MapSettings;
units: UnitsSettings;
wind: WindSettings;
}
export const DEFAULT_SETTINGS: AppSettings = {
locale: 'ru',
map: { baseLayer: 'osm', showScale: true, showNavigation: true, graticule: true },
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
units: { system: 'metric' },
wind: { ...DEFAULT_WIND_SETTINGS },
};
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);

View 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();
};
}

View 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 &gt; {windSettings.maxFlightDurationHours}h
{:else}
Wind sync skipped: region &gt; {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>

View 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';

View 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();

View file

@ -73,15 +73,6 @@
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
const scene = map.scene(name);
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
// Frame a freshly-arrived result. A flight spans only tens of km,
// which is a few dozen pixels at the default camera height — the
// track ends up completely hidden under its own launch/burst/
// landing markers and reads as a single dot. Deliberately not done
// for colour/opacity edits, so tweaking one workspace while
// comparing several does not yank the camera around.
if (!cached || cached.result !== w.result) {
map.fitBounds(w.result.flight_path, 50);
}
ownedPlotScenes.add(name);
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
}

View file

@ -199,15 +199,6 @@
</Button>
{#if w.bboxVisible && box}
{@const coords = formatBoundingBox(box)}
<!-- Size and centre, because near a pole the corners alone are
unreadable: a box whose north edge passes over the pole comes
back down the far side, so its two north corners land ~180
degrees away in longitude. -->
<div class="small text-muted mt-2 font-monospace">
{box.widthKm.toFixed(1)} × {box.heightKm.toFixed(1)} km @ {box.centre[0].toFixed(
4,
)}, {box.centre[1].toFixed(4)}
</div>
<textarea
class="form-control form-control-sm mt-2 font-monospace"
style="resize: none;"

View file

@ -56,8 +56,7 @@
"datasetAuto": "Pick automatically",
"modified": "modified",
"export": "Export result",
"exportBtn": "Export",
"exportNoResult": "Run a prediction first — there is nothing to export."
"exportBtn": "Export"
},
"predictionMode": {
"single": "Single",
@ -139,10 +138,18 @@
"metric": "Metric",
"imperial": "Imperial",
"saved": "Settings saved",
"baseLayerOsm": "Map (OpenStreetMap)",
"baseLayerSatellite": "Satellite (Esri)",
"graticule": "Lat/lon grid",
"baseLayerPolar": "Polar map (offline, covers the poles)"
"wind": "Wind visualization",
"windEnabled": "Show wind layer",
"windStep": "Grid resolution (°)",
"windTrajectoryStep": "Trajectory grid res. (°)",
"windPrefetchInterval": "Pre-fetch interval (min)",
"windMaxDuration": "Max sync duration (h)",
"windMaxRegion": "Max region size (°)",
"windMargin": "Trajectory margin (°)",
"windParticleDensity": "Particle density",
"windParticleSpeed": "Particle speed",
"windTrailPersistence": "Trail length",
"windMaxVelocity": "Max wind speed (m/s)"
},
"editor": {
"add": "Add",

View file

@ -56,8 +56,7 @@
"datasetAuto": "Выбрать автоматически",
"modified": "изменено",
"export": "Экспортировать результат",
"exportBtn": "Экспорт",
"exportNoResult": "Сначала выполните расчёт — экспортировать нечего."
"exportBtn": "Экспорт"
},
"predictionMode": {
"single": "Разовый",
@ -139,10 +138,18 @@
"metric": "Метрические",
"imperial": "Имперские",
"saved": "Настройки сохранены",
"baseLayerOsm": "Карта (OpenStreetMap)",
"baseLayerSatellite": "Спутник (Esri)",
"graticule": "Сетка координат",
"baseLayerPolar": "Полярная карта (офлайн, до полюсов)"
"wind": "Визуализация ветра",
"windEnabled": "Показывать слой ветра",
"windStep": "Шаг сетки (°)",
"windTrajectoryStep": "Шаг сетки по траектории (°)",
"windPrefetchInterval": "Интервал предзагрузки (мин)",
"windMaxDuration": "Макс. длительность синхронизации (ч)",
"windMaxRegion": "Макс. размер региона (°)",
"windMargin": "Отступ вокруг траектории (°)",
"windParticleDensity": "Плотность частиц",
"windParticleSpeed": "Скорость частиц",
"windTrailPersistence": "Длина следа",
"windMaxVelocity": "Макс. скорость ветра (м/с)"
},
"editor": {
"add": "Добавить",

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { onMount, onDestroy, type Snippet } from 'svelte';
import type { BaseLayerId, IMap } from './core';
import type { IMap } from './core';
import type { LngLatTuple } from '$domain';
import { createCesiumMap } from './cesium';
import { createMapLibreMap } from './maplibre';
import { setMapContext } from './context';
interface Props {
center?: LngLatTuple;
zoom?: number;
baseLayer?: BaseLayerId;
baseLayer?: 'osm' | 'satellite';
showNavigationControl?: boolean;
showScaleControl?: boolean;
children?: Snippet;
@ -28,9 +28,9 @@
let container: HTMLDivElement;
let map: IMap | null = $state(null);
/**
* Children must not render until `map.ready` resolves. Cesium's Viewer is
* usable synchronously, but features still expect this gate, so it stays as
* the single place that decides when the map may be drawn on.
* Children must not render until the map's first `load` event. MapLibre
* throws if addSource/addLayer is called on an unloaded style, and this
* component is the natural gate for that invariant.
*/
let ready = $state(false);
@ -41,7 +41,7 @@
}
onMount(() => {
map = createCesiumMap({
map = createMapLibreMap({
container,
center,
zoom,

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -4,14 +4,14 @@ import type { LatLngTuple, LngLatTuple } from '$domain';
* Map abstraction.
*
* 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,
* icons, events) so features (workspaces, timeline, tools) can be tested
* against the interface alone.
* - Support "scenes" named collections of layers owned by a feature so
* each workspace/tool can add/remove everything it owns atomically.
*
* If another library ever replaces Cesium, implementing IMap is the only
* If another library ever replaces MapLibre, implementing IMap is the only
* file that changes.
*/
@ -34,11 +34,6 @@ export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => voi
export interface MarkerOptions {
lngLat: LngLatTuple;
/**
* Metres above the ellipsoid. Renderers that cannot show altitude ignore it.
* Used so the burst marker sits at the apex instead of on the ground under it.
*/
altitude?: number;
iconUrl?: string;
iconSize?: [number, number];
className?: string;
@ -47,7 +42,6 @@ export interface MarkerOptions {
}
export interface LineOptions {
/** Joined by great circles. Callers that need another curve sample it themselves. */
coords: LatLngTuple[];
color?: string;
width?: number;
@ -101,12 +95,6 @@ export interface IMap {
setCursor(cursor: string | null): void;
/**
* Swap the base imagery in place, keeping the current camera. Needed because
* the base layer is a user setting that can change after the map is built.
*/
setBaseLayer(layer: NonNullable<MapInit['baseLayer']>): void;
/**
* Get or create a named scene. Scenes are the unit of layer ownership:
* a feature adds all its layers through a scene and calls `.clear()` to
@ -121,20 +109,11 @@ export interface IMap {
dispose(): void;
}
/**
* Available base imagery.
*
* `satellite` (Esri) is Web Mercator: high resolution but no tiles above
* 85.0511°, so the poles are blank. `polar` trades resolution for a geographic
* (EPSG:4326) source that covers ±90°.
*/
export type BaseLayerId = 'osm' | 'satellite' | 'polar';
export interface MapInit {
container: HTMLElement;
center: LngLatTuple;
zoom: number;
baseLayer?: BaseLayerId;
baseLayer?: 'osm' | 'satellite';
showNavigationControl?: boolean;
showScaleControl?: boolean;
}

View file

@ -1,5 +1,5 @@
export * from './core';
export { createCesiumMap } from './cesium';
export { createMapLibreMap } from './maplibre';
export {
plotPrediction,
plotTelemetry,

View file

@ -3,8 +3,8 @@ import { toLngLat, boundingBoxRing } from '$domain';
import type { IMap, Scene } from './core';
/**
* Plot helpers for high-level domain objects. These live outside the concrete
* map class so they can be reused against any IMap implementation.
* Plot helpers for high-level domain objects. These live outside MapLibreMap
* so they can be reused against any IMap implementation.
*
* Icons are served from /static; pass explicit overrides if a workspace
* should use custom markers.
@ -62,9 +62,6 @@ export function plotPrediction(
scene.addMarker('burst', {
lngLat: toLngLat(prediction.burst.latlng),
// Burst happens at ~30 km; on a globe the marker belongs at the apex of
// the track, not on the ground beneath it.
altitude: prediction.burst.latlng.alt,
iconUrl: s.burstIcon,
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
popupHtml: `<b>Burst</b><br>${prediction.burst.latlng.lat.toFixed(6)}, ${prediction.burst.latlng.lng.toFixed(6)}`,
@ -146,8 +143,6 @@ export interface BoundingBoxStyle {
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
scene.clear();
scene.addLine('box', {
// Already sampled along its great-circle edges, so the drawn shape is the
// filed shape and does not depend on the renderer's interpolation.
coords: boundingBoxRing(box),
color: style.color ?? '#0d6efd',
width: style.width ?? 3,

324
src/lib/map/maplibre.ts Normal file
View 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: '&copy; <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 &copy; 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);
}

View file

@ -4,7 +4,6 @@
import type { IMap } from '$map';
import { startCoordinateSelection } from '$map';
import { Navbar } from '$features/auth';
import { MapChrome } from '$features/mapchrome';
import { PanelContainer, TabBar } from '$ui';
import { addToast, removeToast } from '$ui';
import { ControlPanel, ScenarioPanel } from '$features/prediction';
@ -13,6 +12,7 @@
WorkspaceRenderer,
workspacesStore,
} from '$features/workspaces';
import { WindRenderer } from '$features/wind';
import { SettingsPanel } from '$features/settings';
import { TimeLine } from '$features/timeline';
import { t } from '$i18n';
@ -72,8 +72,8 @@
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView bind:this={mapComponent} onReady={handleMapReady}>
<MapChrome />
<WorkspaceRenderer />
<WindRenderer />
<PanelContainer position="left">
<TabBar

View file

@ -8,7 +8,6 @@
type IMap,
} from '$map';
import { Navbar } from '$features/auth';
import { MapChrome } from '$features/mapchrome';
import { PanelContainer, CollapsibleCard } from '$ui';
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
import { workspacesStore } from '$features/workspaces';
@ -117,7 +116,6 @@
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView onReady={onMapReady}>
<MapChrome />
<PanelContainer position="left">
<TelemetryPanel />
</PanelContainer>

View file

@ -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);
});

View file

@ -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();
});

View file

@ -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);
});

View file

@ -65,25 +65,6 @@ export const test = base.extend({
export { expect };
/**
* Count map objects belonging to scenes whose name starts with `prefix`.
*
* Renderer-specific: CesiumScene scopes every entity id as `<scene>__<id>`
* (see src/lib/map/cesium-scene.ts), so scene membership is read off the id.
* Kept in fixtures so a renderer swap touches one place, not every spec.
*/
export function sceneObjectCount(page: Page, prefix: string): Promise<number> {
return page.evaluate((p) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const viewer: any = (window as any)._lsvMap;
if (!viewer?.entities) return 0;
return viewer.entities.values.filter((e: { id?: string }) =>
typeof e.id === 'string' ? e.id.startsWith(p) : false,
).length;
/* eslint-enable @typescript-eslint/no-explicit-any */
}, prefix);
}
export async function openPredict(page: Page) {
await page.goto('/predict');
await page

View file

@ -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);
});
});

View file

@ -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);
});
}

View file

@ -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 });
});
}

View file

@ -4,7 +4,7 @@ test.beforeEach(async ({ 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 expect(page.locator('.map-container canvas').first()).toBeAttached({
timeout: 15_000,

View file

@ -1,4 +1,4 @@
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
import { test, expect, openPredict, login } from './fixtures';
test.beforeEach(async ({ context, page }) => {
await login(context);
@ -6,9 +6,14 @@ test.beforeEach(async ({ context, page }) => {
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) {
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).
@ -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).
await expect
.poll(() => sceneObjectCount(page, 'ws/'), {
timeout: 75_000,
intervals: [1000, 2000, 3000],
})
.poll(
() =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const map: any = (window as any)._lsvMap;
if (!map) return 0;
return map
.getStyle()
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
}),
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
)
.toBeGreaterThan(0);
// Enable the bounding box for this forecast.

View file

@ -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);
});

View file

@ -1,4 +1,4 @@
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
import { test, expect, openPredict, login } from './fixtures';
test.beforeEach(async ({ context, page }) => {
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.
await expect
.poll(() => sceneObjectCount(page, 'ws/'), {
timeout: 75_000,
intervals: [1000, 2000, 3000],
})
.toBeGreaterThan(0);
});
// Regression: a flight is only tens of km across, which is a few dozen pixels
// at the default camera height — the track ends up hidden under its own launch/
// burst/landing markers and looks like a single dot. A fresh result must be
// framed by the camera.
test('camera frames the trajectory after a run', async ({ page }) => {
test.setTimeout(120_000);
await openPredict(page);
const camera = () =>
.poll(
async () =>
page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const c = v.camera.positionCartographic;
return {
lng: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
height: c.height,
};
/* eslint-enable @typescript-eslint/no-explicit-any */
});
const before = await camera();
await workspacesPanel(page)
.locator('.workspace-row')
.first()
.getByRole('button', { name: /Рассчитать|Run/ })
.click();
await expect
.poll(() => sceneObjectCount(page, 'ws/'), {
timeout: 90_000,
intervals: [1000, 2000, 3000],
})
// 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);
// 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);
});

View file

@ -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);
}
});