leaflet_svelte/docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
2026-08-03 22:12:32 +09:00

700 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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