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

24 KiB
Raw Blame History

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

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

npm uninstall @sakitam-gis/maplibre-wind svelte5-chartjs
  • Step 4: Verify nothing dangles
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
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
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:

// 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:

"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%:

<script>window.CESIUM_BASE_URL = '/cesium/';</script>
  • Step 4: Run it and verify
node scripts/copy-cesium.js
ls static/cesium/
du -sh static/cesium/

Expected: Assets ThirdParty Widgets Workers present, ~20 MB total.

  • Step 5: Commit
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
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)
npm run check 2>&1 | head -20

Expected: errors only about the missing ./cesium-scene module. Task 4 supplies it.

  • Step 3: Commit
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
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
npm run check 2>&1 | head -20

Expected: 0 errors.

  • Step 3: Commit
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:

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

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
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
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-88WindTransport 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-73GeoAdd 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:87dLng = 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.