feat(globe): port to CeliumJS
This commit is contained in:
parent
ec03425067
commit
eb7698e034
51 changed files with 3521 additions and 1992 deletions
700
docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
Normal file
700
docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue