feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

157
docs/POLAR_FINDINGS.md Normal file
View file

@ -0,0 +1,157 @@
# Predictor behaviour at the poles
Measured 2026-08-03 against the full local stack (`./run-stack.py`): Go predictor
on :8080 with GFS dataset `2026-08-03T00:00:00Z` (`gfs-0p50-3h`, 130/130 units,
2.05 GB), Django on :8000, the Cesium build of this SPA on :5173.
Reproduce the browser half with:
```bash
npx playwright test tests/e2e/polar.spec.ts --reporter=list
```
That spec requires the real stack (it overrides `baseURL` to `http://localhost:5173`
— see "CSRF" below).
## 1. What the predictor returns near the poles
Direct `GET /api/v1/prediction`, launch longitude 0.1, 2026-08-03T12:00:00Z,
ascent 5 m/s, burst 30 km, descent 5 m/s:
| launch lat | points | latitude range | longitude range | NaN/Inf | above 85.051129° | frozen |
|---|---|---|---|---|---|---|
| 62.1234 | 148 | 62.1219 … 62.3342 | 0.100 … 1.737 | 0 | no | no |
| 85.0 | 148 | 85.0000 … 85.1535 | 0.100 … 5.560 | 0 | **yes** | no |
| 89.5 | 148 | 88.8092 … 89.5000 | 0.100 … 55.922 | 0 | **yes** | no |
| 89.99 | 148 | 88.9926 … 89.9900 | 0.100 … 79.279 | 0 | **yes** | no |
**No NaN, no Inf, no frozen tracks.** Today's polar wind blows southward, so
every balloon drifted away from the pole and never reached latitude 90°, where
the defects below would trigger. The failure modes are latent, not constant.
Note the longitude amplification: at 89.99°N the track sweeps 79° of longitude
while moving 1° of latitude. That is **physically correct**, not a bug — the
parallel at 89.99°N is only ~7 km long, so a few km of drift is a large change
in longitude.
## 2. What the globe now renders
`tests/e2e/polar.spec.ts` reads back the coordinates Cesium actually holds in
the rendered workspace polyline:
```
[89.5N] vertices=148 max=89.50000 min=88.80919
[89.99N] vertices=148 max=89.99000 min=88.99262
```
These match the predictor's API output exactly. Under the previous
MapLibre renderer every one of these vertices would have been clamped to
85.051129° by `@maplibre/geojson-vt`'s `projectY()` and the track would have
rendered as a straight line along that parallel.
Screenshots: `test-results/polar-89.5N.png`, `test-results/polar-89.99N.png`.
## 3. Known limitation: no basemap above 85.051129°
The polar cap renders as a featureless surface. Both configured basemaps —
OpenStreetMap raster and Esri World Imagery — are Web Mercator tile pyramids,
which do not extend past ±85.051129°. The trajectory, markers and bounding
boxes draw correctly there (they are geographic vector data, not tiles), but
there is no map detail underneath. The same limitation means **Antarctica can
never be drawn in full** from these sources; its true extent reaches 90°.
Fixing this needs a polar-projection basemap (NASA GIBS serves EPSG:3413 for
the Arctic and EPSG:3031 for the Antarctic), which is a separate piece of work.
## 4. Predictor defects found by reading the source
These live in the `predictor` repo and are **not fixed**. Verified by source
reading plus a unit probe run against the real `Axis` geometry (GFS 0p50:
lat 90…90 step 0.5, N=361).
### 4.1 The wind error is swallowed into a zero derivative — worst of the three
`internal/engine/models.go:85-88`
```go
sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude)
if err != nil {
return State{} // zero derivative; error discarded
}
```
`internal/numerics/grid.go:40` (`Axis.Locate`) is correct — it returns an
explicit error rather than reading out of bounds:
```
lat=89 OK lat=90 ERROR: lat=90 out of range
lat=89.5 OK lat=90.05 ERROR: lat=90.05 out of range
lat=89.99 OK lat=91 ERROR: lat=91 out of range
```
But the caller drops it, so the propagator returns a zero derivative and the
balloon **silently freezes horizontally** with no error and no event in
`events[]`:
```
lat=89.99 dLat=8.95e-05 dLng=0.5128576395811134
lat=90 dLat=0 dLng=0 <-- silently zeroed
lat=90.05 dLat=0 dLng=0 <-- silently zeroed
lat=91 dLat=0 dLng=0 <-- silently zeroed
```
A plausible-looking wrong answer is more dangerous than a crash.
### 4.2 Latitude is never bounded, so there is no pole crossing
`internal/numerics/vec.go:71-73``GeoAdd` wraps longitude through `PyMod` but
adds latitude unguarded:
```
step 1: lat=89.9500 step 3: lat=90.0500 step 5: lat=90.1500
step 2: lat=90.0000 step 4: lat=90.1000 step 6: lat=90.2000
```
The state leaves the sphere. Correct pole crossing is `lat → 180 lat`,
`lng → lng + 180`; that logic does not exist.
### 4.3 1/cos(lat) divergence against a fixed integration step
`internal/numerics/vec.go:87`
```go
dLng = degPerRad * u / (r * math.Cos(lat*piOver180))
```
For a 10 m/s eastward wind at 30 km:
| lat | cos(lat) | dLng °/s | ° per 60 s step |
|---|---|---|---|
| 0 | 1 | 8.951054359255286e-05 | 0.0054 |
| 60 | 0.5000000000000001 | 0.00017902108718510567 | 0.0107 |
| 85.051129 | 0.08626673450528127 | 0.0010376020850432562 | 0.062 |
| 89.99 | 1.745329251907294e-05 | 5.128576370030803 | 307.7 |
| 90 | 6.123233995736757e-17 | 1.461818112044611e+12 | 8.77e+13 |
| 90.0000001 | 1.7453291532541063e-09 | 51285.766599190494 | 3.08e+06 |
At exactly 90° the result is **large but finite, not NaN**`math.Cos(π/2)` in
float64 is `6.123233995736757e-17`, not zero. Past 90° the cosine goes negative
and the drift direction inverts.
`internal/numerics/ode.go:15` integrates with classical fixed-step RK4 — no
adaptive step, no error control — so truncation error grows without bound as the
derivative stiffens. At 89.999° a single 60 s step advances longitude by more
than a full revolution. Scaling the step by `cos(lat)` would bound it.
## 5. Environment notes
**CSRF.** `run-stack.py` binds Vite to `127.0.0.1` but never sets
`CSRF_TRUSTED_ORIGINS`, whose default (`stratoflights/settings.py:195`) is
`http://localhost:5173, http://localhost:8000`. Reaching the app as
`127.0.0.1:5173` therefore gets **403 on every POST**, including
`/api/predictions/`. Use `localhost:5173`, or add the origin to the env block in
`run-stack.py`. (The default value also has a leading space in its second entry
after `split(',')`.)
`ALLOWED_HOSTS` has the same shape: it defaults to `localhost` only, so
`curl 127.0.0.1:8000` returns 400 while `curl localhost:8000` returns 200.

View file

@ -0,0 +1,700 @@
# CesiumJS Globe Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the MapLibre/Mercator renderer with a CesiumJS globe so trajectories above 85.05° latitude render correctly, enabling verification of predictor behaviour at the poles.
**Architecture:** The app already isolates the renderer behind the `IMap` interface (`src/lib/map/core.ts`). After the wind layer is deleted, `maplibre-gl` is imported in exactly one file (`src/lib/map/maplibre.ts`) and nothing calls `getRawInstance()`. So the migration is: add `src/lib/map/cesium.ts` implementing the same `IMap`/`Scene` contract, then change one import in `Map.svelte`. MapLibre stays on disk until the Cesium path is proven, then is removed.
**Tech Stack:** SvelteKit 2 + adapter-static, Svelte 5 runes, TypeScript, Vite 6, CesiumJS 1.129.0.
## Global Constraints
- **Node is 18.19.1 and `.npmrc` sets `engine-strict=true`.** Cesium MUST be pinned to `1.129.0` — the last release declaring `node >=18.18.0`. Cesium 1.130.0 requires `node >=20.19.0`; 1.141.0+ requires `node >=22.0.0`. `npm install` will hard-fail (not warn) on a newer version.
- Vite stays at 6.x. Vite 7 requires Node 20+.
- **No Cesium Ion access token.** Do not use `Cesium.Ion`, `createWorldTerrainAsync`, or the default Ion imagery. Use `UrlTemplateImageryProvider` with the existing OSM/Esri tile URLs and `EllipsoidTerrainProvider`. Any Ion code path fails without a token.
- Existing basemap URLs must be preserved verbatim:
- osm: `https://a.tile.openstreetmap.org/{z}/{x}/{y}.png`
- satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
- The `IMap`, `Scene`, `Marker`, `MapLayer` interfaces in `src/lib/map/core.ts` are the contract. Do not change their signatures — features depend on them.
- Coordinate order convention in this codebase: `LngLatTuple = [lng, lat]`, `LatLngTuple = [lat, lng]`. `LineOptions.coords` is `LatLngTuple[]`. Getting this backwards is the most likely bug in this migration.
- Commit after each task.
---
### Task 1: Delete the wind layer and unused dependencies
Removes the only `getRawInstance()` consumer, leaving a clean seam for the renderer swap. The user has approved dropping wind entirely.
**Files:**
- Delete: `src/lib/features/wind/ParticleField.ts`, `src/lib/features/wind/WindRenderer.svelte`, `src/lib/features/wind/index.ts`, `src/lib/features/wind/store.ts`
- Delete: `src/lib/domain/wind.ts`, `src/lib/api/wind.ts`
- Modify: `src/routes/predict/+page.svelte` (remove `WindRenderer` import + usage)
- Modify: `src/lib/domain/index.ts`, `src/lib/api/index.ts` (drop wind re-exports)
- Modify: `src/lib/features/settings/schema.ts` (remove the wind settings section, ~lines 87-130)
- Modify: `src/lib/features/settings/store.ts` (remove `wind.*` defaults if present)
- Modify: `src/lib/i18n/locales/en.json`, `src/lib/i18n/locales/ru.json` (remove `settings.wind*` keys)
- Modify: `package.json` (remove `@sakitam-gis/maplibre-wind`, `svelte5-chartjs`)
- [ ] **Step 1: Confirm the blast radius before deleting**
```bash
cd /home/anton/stratoflights/leaflet_svelte
grep -rn "features/wind\|\$features/wind\|windCache\|api/wind\|WindRenderer\|WindInterpolator\|windSettings\|domain/wind" src/
```
Expected: hits only in the files listed above. If anything else appears, add it to the list before proceeding.
- [ ] **Step 2: Delete the wind files**
```bash
rm -r src/lib/features/wind src/lib/domain/wind.ts src/lib/api/wind.ts
```
- [ ] **Step 3: Remove the references**
Edit each Modify-listed file to drop wind imports, `<WindRenderer />` usage, wind re-exports, the wind settings section, and the `settings.wind*` i18n keys. `@sakitam-gis/maplibre-wind` and `svelte5-chartjs` are already imported nowhere (verified) — remove them from `package.json` dependencies.
```bash
npm uninstall @sakitam-gis/maplibre-wind svelte5-chartjs
```
- [ ] **Step 4: Verify nothing dangles**
```bash
grep -rn "wind\|Wind" src/ | grep -viE "window|rewind"
npm run check
```
Expected: no wind references remain; `npm run check` reports 0 errors.
- [ ] **Step 5: Confirm the seam is clean**
```bash
grep -rn "from 'maplibre-gl'" src/
grep -rn "getRawInstance" src/
```
Expected: `maplibre-gl` imported ONLY in `src/lib/map/maplibre.ts`. `getRawInstance` appears only in `core.ts` (declaration) and `maplibre.ts` (implementation) — no callers.
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "refactor: remove wind particle layer and unused deps"
```
---
### Task 2: Serve Cesium's static assets
Cesium loads Workers, Assets, Widgets and ThirdParty files at runtime by URL. They must be served from a known base path. `static/` is copied verbatim by adapter-static, so copying there works in both dev and build with zero plugins.
**Files:**
- Create: `scripts/copy-cesium.js`
- Modify: `package.json` (`prepare` script)
- Modify: `.gitignore` (ignore the generated `static/cesium/`)
- Modify: `src/app.html` (set `CESIUM_BASE_URL`)
**Interfaces:**
- Produces: Cesium assets served at `/cesium/`, and `window.CESIUM_BASE_URL === '/cesium/'` set before any Cesium module loads.
- [ ] **Step 1: Write the copy script**
Create `scripts/copy-cesium.js`:
```js
// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
// Cesium resolves Workers/Assets/Widgets at runtime via CESIUM_BASE_URL; they
// cannot be bundled. Regenerated on `npm install` via the prepare script.
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'node_modules', 'cesium', 'Build', 'Cesium');
const dest = join(root, 'static', 'cesium');
if (!existsSync(src)) {
console.error(`[copy-cesium] missing ${src} — is cesium installed?`);
process.exit(1);
}
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['Assets', 'ThirdParty', 'Widgets', 'Workers']) {
cpSync(join(src, dir), join(dest, dir), { recursive: true });
}
console.log('[copy-cesium] assets copied to static/cesium/');
```
- [ ] **Step 2: Wire it into `prepare` and ignore the output**
In `package.json`, change the `prepare` script to:
```json
"prepare": "svelte-kit sync || echo '' && node scripts/copy-cesium.js"
```
Append to `.gitignore`:
```
# generated from node_modules/cesium by scripts/copy-cesium.js
static/cesium/
```
- [ ] **Step 3: Set the base URL before Cesium loads**
In `src/app.html`, add inside `<head>` before `%sveltekit.head%`:
```html
<script>window.CESIUM_BASE_URL = '/cesium/';</script>
```
- [ ] **Step 4: Run it and verify**
```bash
node scripts/copy-cesium.js
ls static/cesium/
du -sh static/cesium/
```
Expected: `Assets ThirdParty Widgets Workers` present, ~20 MB total.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "build: serve Cesium runtime assets from static/cesium"
```
---
### Task 3: Implement `IMap` on Cesium — camera, events, basemaps
**Files:**
- Create: `src/lib/map/cesium.ts`
- Test: `tests/e2e/globe.spec.ts` (added in Task 5)
**Interfaces:**
- Consumes: `IMap`, `MapInit`, `MapEvent`, `MapEventPayload`, `MapClickEvent`, `Scene` from `./core`; `LatLngTuple`, `LngLatTuple` from `$domain`.
- Produces: `export function createCesiumMap(init: MapInit): IMap` — same shape as `createMapLibreMap`, so `Map.svelte` swaps one import.
- Produces: `zoomToHeight(zoom: number): number` and `heightToZoom(height: number): number` — the camera-height ↔ zoom-level bridge, exported for tests.
**Zoom note:** Cesium has no discrete zoom levels; it has camera height. `getZoom`/`setZoom` have **zero consumers outside the map implementation** (verified), so a simple monotonic mapping is sufficient. Use the standard Web-Mercator-equivalent relation at the equator: `height = 40075017 / 2^zoom`, inverted as `zoom = log2(40075017 / height)`.
- [ ] **Step 1: Write the implementation**
```ts
import {
Cartesian2,
Cartesian3,
Color,
EllipsoidTerrainProvider,
Math as CesiumMath,
Rectangle,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
UrlTemplateImageryProvider,
Viewer,
} from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
import type {
IMap,
MapEvent,
MapEventHandler,
MapInit,
MapClickEvent,
Scene as MapScene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
import { CesiumScene } from './cesium-scene';
/** Equatorial circumference in metres — the zoom<->height reference. */
const EQUATOR_M = 40075017;
export function zoomToHeight(zoom: number): number {
return EQUATOR_M / Math.pow(2, zoom);
}
export function heightToZoom(height: number): number {
return Math.log2(EQUATOR_M / Math.max(height, 1));
}
const IMAGERY: Record<NonNullable<MapInit['baseLayer']>, string> = {
osm: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
satellite:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{reverseY}/{x}',
};
class CesiumMap implements IMap {
readonly ready: Promise<void>;
private viewer: Viewer;
private handler: ScreenSpaceEventHandler;
private scenes = new Map<string, CesiumScene>();
constructor(init: MapInit) {
this.viewer = new Viewer(init.container, {
// No Ion: explicit imagery + ellipsoid terrain, no token required.
baseLayer: false,
terrainProvider: new EllipsoidTerrainProvider(),
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
navigationInstructionsInitiallyVisible: false,
});
this.viewer.imageryLayers.addImageryProvider(
new UrlTemplateImageryProvider({ url: IMAGERY[init.baseLayer ?? 'osm'], maximumLevel: 19 }),
);
// Scale/navigation controls: Cesium has no direct equivalents, and the
// existing flags only ever hid MapLibre chrome we no longer render.
this.setCenter(init.center, init.zoom);
this.ready = Promise.resolve();
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
}
/** Screen point -> lng/lat on the globe, or null if the click missed the globe. */
private pick(position: Cartesian2): { lat: number; lng: number } | null {
const ray = this.viewer.camera.getPickRay(position);
if (!ray) return null;
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
if (!hit) return null;
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
return {
lat: CesiumMath.toDegrees(c.latitude),
lng: CesiumMath.toDegrees(c.longitude),
};
}
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
if (event === 'load') {
(handler as MapEventHandler<'load'>)(undefined);
return () => {};
}
if (event === 'click' || event === 'mousemove') {
const type =
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
const pos = e.position ?? e.endPosition;
if (!pos) return;
const lngLat = this.pick(pos);
if (!lngLat) return; // clicked space, not the globe
(handler as MapEventHandler<'click'>)({
lngLat,
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
} as MapClickEvent);
};
this.handler.setInputAction(cb, type);
return () => this.handler.removeInputAction(type);
}
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
const listener = () => {
const c = this.viewer.camera.positionCartographic;
const zoom = heightToZoom(c.height);
if (event === 'move') {
(handler as MapEventHandler<'move'>)({
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
zoom,
});
} else {
(handler as MapEventHandler<'zoom'>)({ zoom });
}
};
this.viewer.camera.changed.addEventListener(listener);
return () => this.viewer.camera.changed.removeEventListener(listener);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
pos[0],
pos[1],
zoomToHeight(zoom ?? this.getZoom()),
),
});
}
panTo(pos: LngLatTuple, durationMs = 500): void {
this.viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(pos[0], pos[1], this.viewer.camera.positionCartographic.height),
duration: durationMs / 1000,
});
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
const lats = coords.map((c) => c[0]);
const lngs = coords.map((c) => c[1]);
const rect = Rectangle.fromDegrees(
Math.min(...lngs),
Math.min(...lats),
Math.max(...lngs),
Math.max(...lats),
);
// A degenerate rectangle (single point) makes Cesium fly to the centre of
// the earth; pad it so there is always area to frame.
if (rect.width === 0 || rect.height === 0) {
const pad = CesiumMath.toRadians(0.05);
rect.west -= pad;
rect.east += pad;
rect.south -= pad;
rect.north += pad;
}
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
void paddingPx; // Cesium frames the rectangle itself; no pixel padding knob.
}
getZoom(): number {
return heightToZoom(this.viewer.camera.positionCartographic.height);
}
setZoom(zoom: number): void {
const c = this.viewer.camera.positionCartographic;
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
CesiumMath.toDegrees(c.longitude),
CesiumMath.toDegrees(c.latitude),
zoomToHeight(zoom),
),
});
}
setCursor(cursor: string | null): void {
this.viewer.canvas.style.cursor = cursor ?? '';
}
scene(name: string): MapScene {
let s = this.scenes.get(name);
if (!s) {
s = new CesiumScene(name, this.viewer);
this.scenes.set(name, s);
}
return s;
}
disposeScene(name: string): void {
const s = this.scenes.get(name);
if (!s) return;
s.dispose();
this.scenes.delete(name);
}
getRawInstance(): unknown {
return this.viewer;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.handler.destroy();
this.viewer.destroy();
}
}
export function createCesiumMap(init: MapInit): IMap {
return new CesiumMap(init);
}
```
Note the Esri URL uses `{reverseY}` — Cesium's `UrlTemplateImageryProvider` template for the ArcGIS `{z}/{y}/{x}` tile order.
- [ ] **Step 2: Verify it type-checks (Scene not yet written, so expect one error)**
```bash
npm run check 2>&1 | head -20
```
Expected: errors only about the missing `./cesium-scene` module. Task 4 supplies it.
- [ ] **Step 3: Commit**
```bash
git add src/lib/map/cesium.ts && git commit -m "feat: Cesium IMap implementation — camera, events, basemaps"
```
---
### Task 4: Implement `Scene` on Cesium — lines, circles, markers
**Files:**
- Create: `src/lib/map/cesium-scene.ts`
**Interfaces:**
- Consumes: `Scene`, `MapLayer`, `Marker`, `LineOptions`, `CircleOptions`, `MarkerOptions` from `./core`.
- Produces: `export class CesiumScene implements Scene` with constructor `(name: string, viewer: Viewer)` — consumed by `cesium.ts` Task 3.
**Why this is the whole point:** these entities take `Cartesian3.fromDegrees(lng, lat)` directly. Nothing routes through a Mercator tiler, so latitude 89.99° and 90° render correctly — which is what MapLibre could not do.
- [ ] **Step 1: Write the implementation**
```ts
import {
Cartesian2,
Cartesian3,
Color,
Entity,
HorizontalOrigin,
PolylineDashMaterialProperty,
VerticalOrigin,
type Viewer,
} from 'cesium';
import type {
CircleOptions,
LineOptions,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on a bad string. */
function toColor(css: string | undefined, opacity = 1): Color {
try {
return Color.fromCssColorString(css ?? '#000').withAlpha(opacity);
} catch {
return Color.BLACK.withAlpha(opacity);
}
}
export class CesiumScene implements Scene {
private entities = new Map<string, Entity>();
constructor(
public readonly name: string,
private viewer: Viewer,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
private add(id: string, entity: Entity): Entity {
this.remove(id);
const added = this.viewer.entities.add(entity);
this.entities.set(id, added);
return added;
}
addLine(id: string, options: LineOptions): MapLayer {
// LineOptions.coords is [lat, lng][] — Cesium wants a flat lng,lat list.
const degrees = options.coords.flatMap((c) => [c[1], c[0]]);
const color = toColor(options.color, options.opacity ?? 1);
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
polyline: {
positions: Cartesian3.fromDegreesArray(degrees),
width: options.width ?? 3,
material: options.dashArray
? new PolylineDashMaterialProperty({ color })
: color,
// Draw on the globe surface without clamping to terrain, and
// keep the line visible when it passes behind the horizon.
clampToGround: false,
},
}),
);
void entity;
return { id, remove: () => this.remove(id) };
}
addCircle(id: string, options: CircleOptions): MapLayer {
// radiusPx is screen-space, matching the MapLibre circle-layer semantics;
// Cesium's PointGraphics.pixelSize is the direct equivalent.
this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
point: {
pixelSize: (options.radiusPx ?? 5) * 2,
color: toColor(options.color, options.opacity ?? 1),
outlineColor: toColor(options.strokeColor, 1),
outlineWidth: options.strokeWidth ?? 0,
},
}),
);
return { id, remove: () => this.remove(id) };
}
addMarker(id: string, options: MarkerOptions): Marker {
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1]),
...(options.iconUrl
? {
billboard: {
image: options.iconUrl,
width: options.iconSize?.[0],
height: options.iconSize?.[1],
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.BOTTOM,
},
}
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
...(options.popupHtml ? { description: options.popupHtml } : {}),
}),
);
return {
setLngLat: (pos) => {
entity.position = Cartesian3.fromDegrees(pos[0], pos[1]) as never;
},
remove: () => this.remove(id),
};
}
remove(id: string): void {
const e = this.entities.get(id);
if (!e) return;
this.viewer.entities.remove(e);
this.entities.delete(id);
}
clear(): void {
for (const e of this.entities.values()) this.viewer.entities.remove(e);
this.entities.clear();
}
dispose(): void {
this.clear();
}
}
```
- [ ] **Step 2: Type-check**
```bash
npm run check 2>&1 | head -20
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/lib/map/cesium-scene.ts && git commit -m "feat: Cesium Scene — geodesic lines, points, billboards"
```
---
### Task 5: Swap the renderer and get the app green
**Files:**
- Modify: `src/lib/map/Map.svelte:5,44` (import + factory call)
- Modify: `src/lib/map/index.ts` (export the Cesium factory)
- Create: `tests/e2e/globe.spec.ts`
- [ ] **Step 1: Swap the factory**
In `src/lib/map/Map.svelte`, change line 5 from `import { createMapLibreMap } from './maplibre';` to `import { createCesiumMap } from './cesium';`, and line 44 from `map = createMapLibreMap({` to `map = createCesiumMap({`.
- [ ] **Step 2: Write the pole e2e test**
Create `tests/e2e/globe.spec.ts`:
```ts
import { expect, test } from '@playwright/test';
test('globe renders a polar trajectory above the Mercator limit', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/predict');
// The dev build exposes the raw Cesium Viewer on window._lsvMap.
await page.waitForFunction(() => (window as any)._lsvMap?.scene !== undefined, { timeout: 30_000 });
// Place a polyline crossing 89.99N and read back what Cesium stored.
const readback = await page.evaluate(() => {
const viewer = (window as any)._lsvMap;
const Cesium = (window as any).Cesium;
const e = viewer.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([0, 89.0, 30, 89.99, 60, 89.5]),
width: 3,
},
});
const carto = Cesium.Cartographic.fromCartesian(e.polyline.positions.getValue()[1]);
return Cesium.Math.toDegrees(carto.latitude);
});
// MapLibre's tiler clamped anything above 85.051129; Cesium must not.
expect(readback).toBeGreaterThan(85.051129);
expect(readback).toBeCloseTo(89.99, 2);
expect(errors).toEqual([]);
});
```
- [ ] **Step 3: Run it**
```bash
npm run check
npx playwright test tests/e2e/globe.spec.ts
```
Expected: `npm run check` clean; the globe test passes, proving latitude survives past 85.051129°.
- [ ] **Step 4: Run the existing suite and fix fallout**
```bash
npx playwright test
```
Expected: `auth`, `settings`, `saved-points` pass unchanged. `smoke`, `track`, `workspaces` may assert on MapLibre-specific DOM (`.maplibregl-*` classes) — update those selectors to the Cesium canvas. Fix each failure; do not delete assertions.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: switch map renderer to CesiumJS globe"
```
---
### Task 6: Verify predictor behaviour at the poles
The actual objective. The globe is the instrument; this task is the measurement.
**Files:**
- Create: `docs/POLAR_FINDINGS.md`
- [ ] **Step 1: Bring up the full stack**
```bash
cd /home/anton/stratoflights
# predictor already holds a complete 2026-08-03T00:00:00Z dataset in .stack-data/gfs
./run-stack.py
```
Expected: `stack up — predictor :8080 django :8000 svelte http://127.0.0.1:5173 …`. If port 8080 is already held by a standalone predictor, stop that first.
- [ ] **Step 2: Run polar predictions and record the output**
```bash
for lat in 85 89.5 89.99; do
curl -s "http://127.0.0.1:8080/api/v1/prediction?launch_latitude=$lat&launch_longitude=0.1&launch_datetime=2026-08-03T12:00:00Z&ascent_rate=5&burst_altitude=30000&descent_rate=5&launch_altitude=0"
done
```
- [ ] **Step 3: View each on the globe**
Open `http://127.0.0.1:5173/predict`, run the same three launches through the UI, and confirm the trajectory renders as a track over the polar region rather than a line pinned along the 85.05° parallel.
- [ ] **Step 4: Write up the findings**
Create `docs/POLAR_FINDINGS.md` recording, for each latitude: whether the track rendered, whether longitude behaved plausibly, and the three predictor defects listed under "Known predictor defects" below with their file:line references.
- [ ] **Step 5: Commit**
```bash
git add docs/POLAR_FINDINGS.md && git commit -m "docs: polar behaviour findings"
```
---
## Known predictor defects (out of scope — for a follow-up decision)
Verified by source reading plus a unit-level probe against the real `Axis` geometry. These live in the `predictor` repo, not this one, and are **not** fixed by this plan:
1. **`internal/engine/models.go:85-88`** — `WindTransport` swallows the wind-field error and returns `State{}`, a zero derivative. At latitude ≥ 90° the balloon silently freezes horizontally with no error and no event emitted. This is the most dangerous defect: a wrong answer that looks valid.
2. **`internal/numerics/vec.go:71-73`** — `GeoAdd` wraps longitude via `PyMod` but never bounds latitude. Latitude walks past 90° (90.05, 90.10, …) off the sphere. Correct pole-crossing behaviour is `lat → 180 lat`, `lng → lng + 180`.
3. **`internal/numerics/vec.go:87`** — `dLng = degPerRad * u / (r * cos(lat))` diverges as `cos(lat) → 0` (0.00089 °/s at the equator → 0.513 °/s at 89.99° → 1.46e12 °/s at exactly 90°; finite, not NaN, because `math.Cos(π/2)` is 6.12e-17). Combined with the **fixed-step RK4** in `internal/numerics/ode.go:15` (no adaptive step, no error control), truncation error grows without bound near the pole. A step size scaled by `cos(lat)` would bound it.
`internal/numerics/grid.go:40` (`Axis.Locate`) is correct — it returns an explicit `lat=90 out of range` error rather than reading out of bounds. The bug is purely that the caller discards it.

View file

@ -0,0 +1,133 @@
# Restricted-area box: rectangle in kilometres, not in degrees
**Goal:** the no-fly area filed with the regulator must be a usable rectangle at
every latitude, the poles included.
## Problem
The area was a rectangle in latitude/longitude, padded by a margin in kilometres.
It cannot be made to work near a pole, and the failure is geometric, not a bug:
> Every meridian passes through the pole, so any lat/lon rectangle that contains
> a pole spans all 360 degrees of longitude.
Measured on a real launch from 89.99 N, 0 E (GFS 2026-08-03 06Z, 5 km margin):
| Form | Area |
| --- | --- |
| lat/lon rectangle (south 88.93, north 90, west -180, east 180) | **44 200 km²** |
| corridor actually flown, 13.7 × 123.4 km | **1 695 km²** |
A 26× over-claim. The balloon also sweeps 79 degrees of longitude while
descending one degree of latitude, because near a pole a short displacement
crosses many meridians — so even without the pole clamp the degree form is wide.
There is no latitude threshold below which the degree form is safe. Above it the
box contains the pole and balloons; below it the box fails to contain the
trajectory. The form itself is what fails.
Rendering made this visible: at the pole the box drew as a circle following a
parallel. That was a correct picture of a wrong shape.
## Decision
Define the area as a **rectangle in kilometres**, axis-aligned to east/north at
its own centre, filed as four lat/lon corners joined by great circles.
Rejected alternatives:
- **Rectangle in degrees plus a circle near the pole.** A circle (centre +
radius) is the standard ICAO NOTAM form, but this needs two filing forms and a
latitude threshold to switch between them — the crutch this replaces.
- **Circle everywhere.** One form, always filable, but a circle around a long
corridor claims far more area than a rectangle.
- **Rotate the rectangle to the track's heading.** Tighter for a diagonal track,
up to 2×, but it puts an azimuth in the filing and the corners stop reading as
north/south/east/west. Not taken; revisit only if area pressure appears.
## Design
```ts
interface BoundingBox {
corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple]; // NW, NE, SE, SW
centre: LatLngTuple;
widthKm: number;
heightKm: number;
}
```
`computeBoundingBox(path, marginKm)` keeps its signature.
1. **Local frame.** Earth-centred radial/east/north unit vectors at a point. All
three are unit length and orthogonal at every latitude including the poles, so
nothing divides by `cos(latitude)`. Same construction as the predictor's
integrator, `internal/numerics/spherical.go`, for the same reason.
2. **Project** each path point to kilometres east/north by azimuthal
equidistant: exact in distance from the origin at any range.
3. **Two passes.** The first frame, on the track's mean direction, only locates
the box centre; the second frames on that centre, which makes the corners
symmetric about it and keeps projection error smallest where the corners are.
4. **Pad** the half-extents by the margin and unproject the four corners.
### Margin is a floor
A great-circle edge bows **away** from the frame origin relative to its chord in
this projection: `y_mid = hy · (1 + ρ² sin²β / 3)`, positive. Check by
inspection — the equator in an azimuthal-equidistant projection centred on the
pole is a circle at `R·π/2`, while the chord between two of its points 90° apart
would sag to `0.707·R·π/2`. So the filed quad **contains** the projected
rectangle and the requested clearance is never eaten. No correction is applied.
(The first draft of this design asserted the opposite sign and specified a
sagitta correction. It would have inflated the area for no reason.)
### The box may cross a pole, and must
A launch 1.1 km from the pole with a 5 km margin needs coverage 3.9 km past the
pole. The north edge therefore passes over it and comes down the far side, which
puts the two north corners ~180° apart in longitude — for the measured case,
-43.87 and -158.44. Correct, and unreadable from the corners alone, so the panel
also reports `width × height @ centre`.
### Drawing
`boundingBoxRing` samples 16 points per edge along the great circle (slerp
between corner vectors). The drawn shape is then the filed shape and does not
depend on the renderer's interpolation mode, and no single segment is long enough
to land degenerate on the antimeridian — which is what previously stopped
Cesium's render loop with "All attribute lists must have the same number of
attributes" in its `splitLongitude` pass.
## Removed
`KM_PER_DEG_LAT` and the `cos(latitude)` margin division, the latitude clamps,
the `circumpolar` branch, `LineOptions.arc`, and the `ArcType.RHUMB` branch in
`cesium-scene.ts` — no parallels remain in the box, so rhumb lines have no
remaining caller. Net less code, and no branch on latitude anywhere.
## Verification
`tests/unit/boundingBox.spec.ts` runs in Node against the pure module:
- every trajectory point clears all four filed edges by the full margin, measured
as distance to the edge's great circle — at 52.2 N over 500 km (where a
wrong-signed bow would have been 5.1 km, the whole margin) and on the real
polar track;
- the polar box is under 3 000 km² where the degree form gave 44 200;
- a meridional track of the same length in kilometres gives the same box at
52.2 N and at 89.99 N — the test that fails if any latitude branch returns;
- the drawn ring closes and no segment spans 90° of longitude.
`tests/e2e/bbox.spec.ts` covers what only a browser answers: `scene.renderError`
stays empty while a polar box is drawn, and the panel reports a corridor-sized
area rather than a cap.
Measured after the change: polar 13.3 × 123.4 km @ 89.4835, 78.3014; mid-latitude
61.4 × 68.9 km @ 52.4654, 0.4651, with the northernmost track point clearing the
north edge by 4.98 km against 5.0 asked.
## Not addressed
- **WGS84.** Everything here uses a spherical earth, R = 6371 km, matching the
predictor. The ellipsoid remains deferred there too.
- **Altitude.** The box is the lat/lon footprint; it does not encode a ceiling.