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