diff --git a/.gitignore b/.gitignore
index 15dd615..9405a0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,10 @@ vite.config.ts.timestamp-*
# AI tools
.claude
tmpclaude*
+
+# generated from node_modules/cesium by scripts/copy-cesium.js
+static/cesium/
+
+# Playwright output
+/test-results/
+/playwright-report/
diff --git a/docs/POLAR_FINDINGS.md b/docs/POLAR_FINDINGS.md
new file mode 100644
index 0000000..059469b
--- /dev/null
+++ b/docs/POLAR_FINDINGS.md
@@ -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.
diff --git a/docs/superpowers/plans/2026-08-03-cesium-globe-migration.md b/docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
new file mode 100644
index 0000000..7d57ff5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-cesium-globe-migration.md
@@ -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, `` 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 `
` before `%sveltekit.head%`:
+
+```html
+
+```
+
+- [ ] **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, 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;
+ private viewer: Viewer;
+ private handler: ScreenSpaceEventHandler;
+ private scenes = new Map();
+
+ 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(event: E, handler: MapEventHandler): () => 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();
+
+ 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.
diff --git a/docs/superpowers/specs/2026-08-03-restricted-area-box-design.md b/docs/superpowers/specs/2026-08-03-restricted-area-box-design.md
new file mode 100644
index 0000000..47d753e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-restricted-area-box-design.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index 326f105..590252c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,16 +8,14 @@
"name": "app4",
"version": "0.0.1",
"dependencies": {
- "@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.13.1",
+ "cesium": "1.129.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
- "luxon": "^3.6.1",
- "maplibre-gl": "^4.0.0",
- "svelte5-chartjs": "^1.0.0"
+ "luxon": "^3.6.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
@@ -45,6 +43,57 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@cesium/engine": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-17.0.0.tgz",
+ "integrity": "sha512-bvLWmWVY4QX9rfcx/zfBzA8R1xR8KzmaCDOVL6pFkNZeYmRtt8JN//IICYR3P45lznlcb0Dklw1iCb37t4tvLA==",
+ "dependencies": {
+ "@tweenjs/tween.js": "^25.0.0",
+ "@zip.js/zip.js": "^2.7.34",
+ "autolinker": "^4.0.0",
+ "bitmap-sdf": "^1.0.3",
+ "dompurify": "^3.0.2",
+ "draco3d": "^1.5.1",
+ "earcut": "^3.0.0",
+ "grapheme-splitter": "^1.0.4",
+ "jsep": "^1.3.8",
+ "kdbush": "^4.0.1",
+ "ktx-parse": "^1.0.0",
+ "lerc": "^2.0.0",
+ "mersenne-twister": "^1.1.0",
+ "meshoptimizer": "^0.23.0",
+ "pako": "^2.0.4",
+ "protobufjs": "^7.1.0",
+ "rbush": "3.0.1",
+ "topojson-client": "^3.1.0",
+ "urijs": "^1.19.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@cesium/engine/node_modules/@zip.js/zip.js": {
+ "version": "2.7.73",
+ "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.73.tgz",
+ "integrity": "sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==",
+ "engines": {
+ "bun": ">=0.7.0",
+ "deno": ">=1.0.0",
+ "node": ">=16.5.0"
+ }
+ },
+ "node_modules/@cesium/widgets": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-12.0.0.tgz",
+ "integrity": "sha512-5Re06LU8lRPEZInHrpOCGBiLoj9VNJ0JJtPrzdnw1qhIDpf3v1W75OJf59k8CCFvUYkZzuTTi8qTVbpzmcbOuw==",
+ "dependencies": {
+ "@cesium/engine": "^17.0.0",
+ "nosleep.js": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
@@ -493,81 +542,6 @@
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
- "node_modules/@mapbox/geojson-rewind": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
- "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
- "dependencies": {
- "get-stream": "^6.0.1",
- "minimist": "^1.2.6"
- },
- "bin": {
- "geojson-rewind": "geojson-rewind"
- }
- },
- "node_modules/@mapbox/jsonlint-lines-primitives": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
- "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/@mapbox/point-geometry": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
- "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
- },
- "node_modules/@mapbox/tiny-sdf": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
- "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug=="
- },
- "node_modules/@mapbox/unitbezier": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
- "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
- },
- "node_modules/@mapbox/vector-tile": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
- "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
- "dependencies": {
- "@mapbox/point-geometry": "~0.1.0"
- }
- },
- "node_modules/@mapbox/whoots-js": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
- "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@maplibre/maplibre-gl-style-spec": {
- "version": "20.4.0",
- "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
- "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
- "dependencies": {
- "@mapbox/jsonlint-lines-primitives": "~2.0.2",
- "@mapbox/unitbezier": "^0.0.1",
- "json-stringify-pretty-compact": "^4.0.0",
- "minimist": "^1.2.8",
- "quickselect": "^2.0.0",
- "rw": "^1.3.3",
- "tinyqueue": "^3.0.0"
- },
- "bin": {
- "gl-style-format": "dist/gl-style-format.mjs",
- "gl-style-migrate": "dist/gl-style-migrate.mjs",
- "gl-style-validate": "dist/gl-style-validate.mjs"
- }
- },
- "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
- "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
- },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@@ -598,6 +572,54 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
@@ -858,47 +880,6 @@
"win32"
]
},
- "node_modules/@sakitam-gis/maplibre-wind": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@sakitam-gis/maplibre-wind/-/maplibre-wind-2.0.3.tgz",
- "integrity": "sha512-KeBlh2EJ13+MsFck2l8sKXKz/ogezvnontarSCTmpfzNzB3b9nA+ydzXLFfqqUMrnZwEhsuEG+pKTFMyPv1shg==",
- "dependencies": {
- "@mapbox/geojson-rewind": "^0.5.2",
- "@sakitam-gis/rbush": "3.1.2",
- "@sakitam-gis/vis-engine": "^1.5.3",
- "gl-matrix": "^3.4.3",
- "wind-gl-core": "2.0.2"
- },
- "peerDependencies": {
- "maplibre-gl": ">=3.0.0"
- }
- },
- "node_modules/@sakitam-gis/rbush": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@sakitam-gis/rbush/-/rbush-3.1.2.tgz",
- "integrity": "sha512-pnNaLnxFBBMnHgGjFX+h2jkpZQg2vXquvDv1BUKfU72uJzJqPcS8smaLydJqcbXp8p7GruoPrQzUpqYG0MYyIg==",
- "dependencies": {
- "quickselect": "^2.0.0"
- }
- },
- "node_modules/@sakitam-gis/rbush/node_modules/quickselect": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
- "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
- },
- "node_modules/@sakitam-gis/vis-engine": {
- "version": "1.5.3",
- "resolved": "https://registry.npmjs.org/@sakitam-gis/vis-engine/-/vis-engine-1.5.3.tgz",
- "integrity": "sha512-IpuZwi0XRflJiP1mNTwOSjlAJZRCczOuVh6s/feVOpXctiAoSWrAuhK0HVITLpCWAQF1bN6CRKA3LW0z1nCr0g==",
- "dependencies": {
- "colord": "^2.9.3",
- "gl-matrix": "^3.4.3"
- },
- "engines": {
- "node": ">= 14.18.1",
- "npm": ">= 6.14.15"
- }
- },
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
@@ -997,6 +978,11 @@
"svelte": "^4.0.0 || ^5.0.0 || ^5.0.0-next.0"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "25.0.0",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
+ "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1008,61 +994,25 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
},
- "node_modules/@types/geojson": {
- "version": "7946.0.16",
- "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
- "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
- },
- "node_modules/@types/geojson-vt": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
- "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
- "dependencies": {
- "@types/geojson": "*"
- }
- },
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true
},
- "node_modules/@types/mapbox__point-geometry": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
- "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
- },
- "node_modules/@types/mapbox__vector-tile": {
- "version": "1.3.4",
- "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
- "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
- "dependencies": {
- "@types/geojson": "*",
- "@types/mapbox__point-geometry": "*",
- "@types/pbf": "*"
- }
- },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
- "dev": true,
"dependencies": {
"undici-types": "~7.19.0"
}
},
- "node_modules/@types/pbf": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
- "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
- },
- "node_modules/@types/supercluster": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
- "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
- "dependencies": {
- "@types/geojson": "*"
- }
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "optional": true
},
"node_modules/@vincjo/datatables": {
"version": "2.5.0",
@@ -1092,6 +1042,17 @@
"node": ">= 0.4"
}
},
+ "node_modules/autolinker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz",
+ "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "pnpm": ">=10.10.0"
+ }
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1100,6 +1061,11 @@
"node": ">= 0.4"
}
},
+ "node_modules/bitmap-sdf": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz",
+ "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg=="
+ },
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
@@ -1115,6 +1081,18 @@
}
]
},
+ "node_modules/cesium": {
+ "version": "1.129.0",
+ "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.129.0.tgz",
+ "integrity": "sha512-XDEJKLnr8s5/Q/1wcdZVEJCgx+xbgfDzkVKd9OxRZzYttazip6KffusMHexRdExVMPfDcTohuqcuHNxj9CUNFA==",
+ "dependencies": {
+ "@cesium/engine": "^17.0.0",
+ "@cesium/widgets": "^12.0.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
@@ -1170,10 +1148,10 @@
"node": ">=6"
}
},
- "node_modules/colord": {
- "version": "2.9.3",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/cookie": {
"version": "0.6.0",
@@ -1244,6 +1222,19 @@
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"dev": true
},
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="
+ },
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
@@ -1302,11 +1293,6 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
- "node_modules/exifr": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
- "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
- },
"node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
@@ -1335,66 +1321,10 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/geojson-vt": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
- "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
- },
- "node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/gl-matrix": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
- "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="
- },
- "node_modules/global-prefix": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
- "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
- "dependencies": {
- "ini": "^4.1.3",
- "kind-of": "^6.0.3",
- "which": "^4.0.0"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/ini": {
- "version": "4.1.3",
- "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
- "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
- "engines": {
- "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
- }
+ "node_modules/grapheme-splitter": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
},
"node_modules/is-reference": {
"version": "3.0.3",
@@ -1404,14 +1334,6 @@
"@types/estree": "^1.0.6"
}
},
- "node_modules/isexe": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
- "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
- "engines": {
- "node": ">=16"
- }
- },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -1420,24 +1342,19 @@
"node": ">=14"
}
},
- "node_modules/json-stringify-pretty-compact": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
- "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
+ "node_modules/jsep": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
+ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
+ "engines": {
+ "node": ">= 10.16.0"
+ }
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
},
- "node_modules/kind-of": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
- "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1447,11 +1364,26 @@
"node": ">=6"
}
},
+ "node_modules/ktx-parse": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
+ "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
+ },
+ "node_modules/lerc": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz",
+ "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg=="
+ },
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
+ },
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
@@ -1468,53 +1400,15 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
- "node_modules/maplibre-gl": {
- "version": "4.7.1",
- "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
- "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
- "dependencies": {
- "@mapbox/geojson-rewind": "^0.5.2",
- "@mapbox/jsonlint-lines-primitives": "^2.0.2",
- "@mapbox/point-geometry": "^0.1.0",
- "@mapbox/tiny-sdf": "^2.0.6",
- "@mapbox/unitbezier": "^0.0.1",
- "@mapbox/vector-tile": "^1.3.1",
- "@mapbox/whoots-js": "^3.1.0",
- "@maplibre/maplibre-gl-style-spec": "^20.3.1",
- "@types/geojson": "^7946.0.14",
- "@types/geojson-vt": "3.2.5",
- "@types/mapbox__point-geometry": "^0.1.4",
- "@types/mapbox__vector-tile": "^1.3.4",
- "@types/pbf": "^3.0.5",
- "@types/supercluster": "^7.1.3",
- "earcut": "^3.0.0",
- "geojson-vt": "^4.0.2",
- "gl-matrix": "^3.4.3",
- "global-prefix": "^4.0.0",
- "kdbush": "^4.0.2",
- "murmurhash-js": "^1.0.0",
- "pbf": "^3.3.0",
- "potpack": "^2.0.0",
- "quickselect": "^3.0.0",
- "supercluster": "^8.0.1",
- "tinyqueue": "^3.0.0",
- "vt-pbf": "^3.1.3"
- },
- "engines": {
- "node": ">=16.14.0",
- "npm": ">=8.1.0"
- },
- "funding": {
- "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
- }
+ "node_modules/mersenne-twister": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
+ "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
},
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
+ "node_modules/meshoptimizer": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz",
+ "integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="
},
"node_modules/mri": {
"version": "1.2.0",
@@ -1540,11 +1434,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
- "node_modules/murmurhash-js": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
- "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
- },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1563,17 +1452,25 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/pbf": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
- "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
- "dependencies": {
- "ieee754": "^1.1.12",
- "resolve-protobuf-schema": "^2.1.0"
- },
- "bin": {
- "pbf": "bin/pbf"
- }
+ "node_modules/nosleep.js": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
+ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
+ },
+ "node_modules/pako": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
+ "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ]
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -1665,20 +1562,40 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/potpack": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
- "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
+ "node_modules/protobufjs": {
+ "version": "7.6.5",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
+ "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
},
- "node_modules/protocol-buffers-schema": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
- "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
+ "node_modules/rbush": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
+ "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
+ "dependencies": {
+ "quickselect": "^2.0.0"
+ }
},
- "node_modules/quickselect": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
- "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
+ "node_modules/rbush/node_modules/quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
},
"node_modules/readdirp": {
"version": "4.1.2",
@@ -1693,14 +1610,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/resolve-protobuf-schema": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
- "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
- "dependencies": {
- "protocol-buffers-schema": "^3.3.1"
- }
- },
"node_modules/rollup": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
@@ -1740,11 +1649,6 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/rw": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
- "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
- },
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -1786,14 +1690,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/supercluster": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
- "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
- "dependencies": {
- "kdbush": "^4.0.2"
- }
- },
"node_modules/svelte": {
"version": "5.34.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.8.tgz",
@@ -1841,15 +1737,6 @@
"typescript": ">=5.0.0"
}
},
- "node_modules/svelte5-chartjs": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/svelte5-chartjs/-/svelte5-chartjs-1.0.0.tgz",
- "integrity": "sha512-SMk+D5ECbsoeFurKE/Nr9sqD4H3WqZkQ4eLxwchDSh8gu7YSGN3ASXYCz9kzFhrH2QGQYpebHwLIMHg7FOI/7A==",
- "peerDependencies": {
- "chart.js": "^3.5.0 || ^4.0.0",
- "svelte": "^5.0.0"
- }
- },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -1866,10 +1753,18 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tinyqueue": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
- "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
+ "node_modules/topojson-client": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
+ "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
+ "dependencies": {
+ "commander": "2"
+ },
+ "bin": {
+ "topo2geo": "bin/topo2geo",
+ "topomerge": "bin/topomerge",
+ "topoquantize": "bin/topoquantize"
+ }
},
"node_modules/totalist": {
"version": "3.0.1",
@@ -1880,6 +1775,11 @@
"node": ">=6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@@ -1896,8 +1796,12 @@
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
- "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
- "dev": true
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
+ },
+ "node_modules/urijs": {
+ "version": "1.19.11",
+ "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz",
+ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="
},
"node_modules/vite": {
"version": "6.3.5",
@@ -1987,53 +1891,6 @@
}
}
},
- "node_modules/vt-pbf": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
- "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
- "dependencies": {
- "@mapbox/point-geometry": "0.1.0",
- "@mapbox/vector-tile": "^1.3.1",
- "pbf": "^3.2.1"
- }
- },
- "node_modules/which": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
- "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
- "dependencies": {
- "isexe": "^3.1.1"
- },
- "bin": {
- "node-which": "bin/which.js"
- },
- "engines": {
- "node": "^16.13.0 || >=18.0.0"
- }
- },
- "node_modules/wind-gl-core": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/wind-gl-core/-/wind-gl-core-2.0.2.tgz",
- "integrity": "sha512-EUnUQsbucaPCFns7p6BlPE5xXiXQpb2hXMmE4t/FG4W+rKlYHjtIMWzM0wAD4M6g4Wg6JzSft7SGocPJAqjssA==",
- "dependencies": {
- "@sakitam-gis/vis-engine": "^1.5.3",
- "earcut": "^2.2.4",
- "wind-gl-worker": "2.0.2"
- }
- },
- "node_modules/wind-gl-core/node_modules/earcut": {
- "version": "2.2.4",
- "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
- "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="
- },
- "node_modules/wind-gl-worker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/wind-gl-worker/-/wind-gl-worker-2.0.2.tgz",
- "integrity": "sha512-uEMHjQtX5w+Kn+MT0RWGyYYqou6brZMe9BMOYAqoJh74tKGpuBx0+i+4J2XppAZmD8r7KYn/UvhjGHfpOq0UlQ==",
- "dependencies": {
- "exifr": "^7.1.3"
- }
- },
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
diff --git a/package.json b/package.json
index da5d11b..28404fd 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
- "prepare": "svelte-kit sync || echo ''",
+ "prepare": "svelte-kit sync || echo '' ; node scripts/copy-cesium.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test:e2e": "playwright test",
@@ -28,15 +28,16 @@
"vite": "^6.2.5"
},
"dependencies": {
- "@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.13.1",
+ "cesium": "1.129.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5",
- "luxon": "^3.6.1",
- "maplibre-gl": "^4.0.0",
- "svelte5-chartjs": "^1.0.0"
+ "luxon": "^3.6.1"
+ },
+ "overrides": {
+ "@zip.js/zip.js": "2.7.73"
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
index 8380a55..73358a1 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,7 +15,8 @@ import { defineConfig, devices } from '@playwright/test';
* npm run test:e2e:ui # Playwright UI mode
*/
export default defineConfig({
- testDir: './tests/e2e',
+ // Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node).
+ testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false, // the mock plugin writes to shared JSON files on disk
diff --git a/scripts/copy-cesium.js b/scripts/copy-cesium.js
new file mode 100644
index 0000000..378c5e5
--- /dev/null
+++ b/scripts/copy-cesium.js
@@ -0,0 +1,24 @@
+// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
+// Cesium fetches Workers/Assets/Widgets/ThirdParty at runtime by URL (see
+// CESIUM_BASE_URL in src/app.html); they cannot be bundled by Vite. static/ is
+// copied verbatim in both dev and build, so this needs no Vite plugin.
+// Regenerated on every `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/');
diff --git a/src/app.css b/src/app.css
index acdfe23..a34b57b 100644
--- a/src/app.css
+++ b/src/app.css
@@ -9,7 +9,7 @@
* Global application styles.
*
* Keep this file focused on cross-feature concerns: the navbar chrome, the
- * panel-container geometry, and overrides for third-party libs (MapLibre,
+ * panel-container geometry, and overrides for third-party libs (Cesium,
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
*/
@@ -93,27 +93,6 @@ body {
right: var(--panel-left);
}
-.maplibregl-ctrl-group {
- border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
- border-radius: var(--bs-border-radius) !important;
-}
-
-.maplibregl-popup-tip {
- border-top-color: var(--bs-border-color) !important;
-}
-
-.maplibregl-popup-content {
- background-color: var(--bs-body-bg) !important;
- border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
- border-radius: var(--bs-border-radius) !important;
- color: var(--bs-body-color);
- box-shadow: none !important;
-}
-
-.maplibregl-popup-close-button {
- color: var(--bs-body-color);
-}
-
.modal-backdrop {
opacity: var(--bs-backdrop-opacity) !important;
}
diff --git a/src/app.html b/src/app.html
index 77a5ff5..d2248ca 100644
--- a/src/app.html
+++ b/src/app.html
@@ -4,6 +4,11 @@
+
+
%sveltekit.head%
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 82b37f9..29b43d2 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,4 +4,3 @@ export { pointsApi } from './points';
export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
-export { windApi, type WindFieldParams } from './wind';
diff --git a/src/lib/api/wind.ts b/src/lib/api/wind.ts
deleted file mode 100644
index ea2d29f..0000000
--- a/src/lib/api/wind.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * Client for the predictor's wind-visualization endpoints.
- *
- * These endpoints live on the predictor service (default 127.0.0.1:8080),
- * not on the Django backend, so they bypass the shared `api` client and
- * fetch directly. No CSRF or session cookies are needed.
- *
- * Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address.
- */
-
-import type { WindField, WindMeta } from '$domain';
-
-const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080';
-
-export interface WindFieldParams {
- altitude?: number;
- step?: number;
- time?: string;
- min_lat?: number;
- max_lat?: number;
- min_lng?: number;
- max_lng?: number;
-}
-
-async function predictorFetch(path: string, params?: Record): Promise {
- const q = new URLSearchParams();
- if (params) {
- for (const [k, v] of Object.entries(params)) {
- if (v !== undefined) q.set(k, String(v));
- }
- }
- const qs = q.toString();
- const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`;
- const res = await fetch(url);
- if (!res.ok) {
- const text = await res.text().catch(() => res.statusText);
- throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`);
- }
- return res.json() as Promise;
-}
-
-export const windApi = {
- field(params: WindFieldParams = {}): Promise {
- return predictorFetch('/api/v1/wind/field', {
- altitude: params.altitude,
- step: params.step,
- time: params.time,
- min_lat: params.min_lat,
- max_lat: params.max_lat,
- min_lng: params.min_lng,
- max_lng: params.max_lng,
- });
- },
-
- meta(): Promise {
- return predictorFetch('/api/v1/wind/meta');
- },
-};
diff --git a/src/lib/domain/boundingBox.ts b/src/lib/domain/boundingBox.ts
index bac560a..ca13cfe 100644
--- a/src/lib/domain/boundingBox.ts
+++ b/src/lib/domain/boundingBox.ts
@@ -1,26 +1,134 @@
import type { LatLngTuple } from './geo';
/**
- * Axis-aligned geographic bounding box around a flight path, with an optional
- * margin so recovery teams get a box that clears the trajectory by a set
- * distance rather than hugging its extreme points.
+ * The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
+ * to east/north at its own centre, with four lat/lon corners joined by great
+ * circles.
+ *
+ * Deliberately not a rectangle in degrees. That form cannot be made to work near
+ * a pole: every meridian passes through the pole, so any lat/lon rectangle that
+ * contains one spans all 360 degrees of longitude. On a real launch from 89.99 N
+ * it produced the entire cap north of 88.93 — 44 200 km^2 — in place of the
+ * 13.7 x 123.4 km corridor the balloon actually flies. There is no latitude
+ * threshold below which the degree form is safe: it either contains the pole and
+ * balloons, or it fails to contain the trajectory.
+ *
+ * Working in kilometres removes the whole class of problem rather than the
+ * instance. Nothing here divides by cos(latitude), clamps to a pole, or branches
+ * on how far north it is.
*/
export interface BoundingBox {
- south: number;
- west: number;
- north: number;
- east: number;
+ /**
+ * The filed corners, joined by great circles, in the order the operator reads
+ * them: NW, NE, SE, SW of the centre's own east/north frame.
+ */
+ corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple];
+ centre: LatLngTuple;
+ /** East-west size in km, including the margin on both sides. */
+ widthKm: number;
+ /** North-south size in km, including the margin on both sides. */
+ heightKm: number;
}
/** Default clearance (km) between the box edge and the nearest trajectory point. */
export const DEFAULT_BBOX_MARGIN_KM = 5;
-// Mean length of one degree of latitude. Longitude degrees shrink toward the
-// poles, handled below via cos(latitude).
-const KM_PER_DEG_LAT = 111.32;
+const R_KM = 6371;
+const D2R = Math.PI / 180;
+
+type Vec = [number, number, number];
+
+const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+const clamp1 = (v: number) => (v > 1 ? 1 : v < -1 ? -1 : v);
+
+function toVec([lat, lng]: LatLngTuple): Vec {
+ const p = lat * D2R;
+ const l = lng * D2R;
+ return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
+}
+
+function toLatLng(v: Vec): LatLngTuple {
+ return [Math.asin(clamp1(v[2])) / D2R, Math.atan2(v[1], v[0]) / D2R];
+}
/**
- * Compute the bounding box of a flight path, expanded outward by `marginKm`.
+ * A local frame: the point itself plus earth-centred east and north unit vectors.
+ *
+ * All three are unit length and mutually orthogonal at every latitude, the poles
+ * included — at 90 N, east is (-sin l, cos l, 0) and north is (-cos l, -sin l, 0),
+ * both still unit. This is the same construction the predictor's integrator uses
+ * (internal/numerics/spherical.go) and for the same reason: it is where the
+ * cos(latitude) singularity would otherwise live.
+ */
+function frame(origin: Vec) {
+ const [lat, lng] = toLatLng(origin);
+ const p = lat * D2R;
+ const l = lng * D2R;
+ return {
+ origin,
+ east: [-Math.sin(l), Math.cos(l), 0] as Vec,
+ north: [-Math.sin(p) * Math.cos(l), -Math.sin(p) * Math.sin(l), Math.cos(p)] as Vec,
+ };
+}
+
+type Frame = ReturnType;
+
+/**
+ * Azimuthal equidistant offsets of `p` from the frame origin, in km east and
+ * north. Distance from the origin is exact at any range; only the shape of
+ * something far from the origin is distorted, and a flight is never far.
+ */
+function project(f: Frame, p: Vec): [number, number] {
+ const e = dot(p, f.east);
+ const n = dot(p, f.north);
+ const t = Math.hypot(e, n);
+ if (t === 0) return [0, 0]; // p is the origin itself, or its antipode
+ const r = R_KM * Math.acos(clamp1(dot(p, f.origin)));
+ return [(r * e) / t, (r * n) / t];
+}
+
+/** Inverse of project: walk `x` km east and `y` km north of the origin. */
+function unproject(f: Frame, x: number, y: number): Vec {
+ const r = Math.hypot(x, y);
+ if (r === 0) return f.origin;
+ const a = r / R_KM;
+ const c = Math.cos(a);
+ const s = Math.sin(a);
+ return [0, 1, 2].map(
+ (i) => f.origin[i] * c + ((x / r) * f.east[i] + (y / r) * f.north[i]) * s,
+ ) as Vec;
+}
+
+/** Centre and half-sizes, in km, of the smallest axis-aligned box in this frame. */
+function extent(f: Frame, points: Vec[]) {
+ let xMin = Infinity;
+ let xMax = -Infinity;
+ let yMin = Infinity;
+ let yMax = -Infinity;
+ for (const p of points) {
+ const [x, y] = project(f, p);
+ if (x < xMin) xMin = x;
+ if (x > xMax) xMax = x;
+ if (y < yMin) yMin = y;
+ if (y > yMax) yMax = y;
+ }
+ return {
+ cx: (xMin + xMax) / 2,
+ cy: (yMin + yMax) / 2,
+ hx: (xMax - xMin) / 2,
+ hy: (yMax - yMin) / 2,
+ };
+}
+
+/** Mean direction of the points. Falls back to the first point if they cancel. */
+function centroid(points: Vec[]): Vec {
+ const sum = points.reduce((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]);
+ const len = Math.hypot(...sum);
+ return len < 1e-9 ? points[0] : (sum.map((c) => c / len) as Vec);
+}
+
+/**
+ * Compute the restricted area around a flight path, clearing it by `marginKm`.
* Returns null for an empty path (callers treat that as "nothing to draw").
*/
export function computeBoundingBox(
@@ -29,52 +137,71 @@ export function computeBoundingBox(
): BoundingBox | null {
if (path.length === 0) return null;
- let south = Infinity;
- let north = -Infinity;
- let west = Infinity;
- let east = -Infinity;
- for (const [lat, lng] of path) {
- if (lat < south) south = lat;
- if (lat > north) north = lat;
- if (lng < west) west = lng;
- if (lng > east) east = lng;
- }
-
+ const points = path.map(toVec);
const margin = Math.max(0, marginKm);
- const dLat = margin / KM_PER_DEG_LAT;
- // ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
- // Size the longitude margin at the latitude nearest a pole so the box never
- // comes in tighter than requested along the whole band.
- const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
- const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
- const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
+
+ // Two passes. The first, framed on the track's mean direction, only locates
+ // the box centre; the second frames on that centre, which makes the four
+ // corners symmetric about it and keeps the projection error smallest where
+ // the corners actually are.
+ const first = frame(centroid(points));
+ const rough = extent(first, points);
+ const f = frame(unproject(first, rough.cx, rough.cy));
+ const { cx, cy, hx, hy } = extent(f, points);
+
+ const ex = hx + margin;
+ const ey = hy + margin;
+ // A great-circle edge bows away from the frame origin relative to its chord
+ // in this projection, so the filed quad contains the box measured here. The
+ // margin is a floor, never eaten.
+ const corner = (sx: number, sy: number) => toLatLng(unproject(f, cx + sx * ex, cy + sy * ey));
return {
- south: south - dLat,
- north: north + dLat,
- west: west - dLng,
- east: east + dLng,
+ corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
+ centre: toLatLng(unproject(f, cx, cy)),
+ widthKm: 2 * ex,
+ heightKm: 2 * ey,
};
}
-/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
+/** Samples per edge when drawing. Keeps segments short enough for any renderer. */
+const RING_STEPS_PER_EDGE = 16;
+
+/** Point a fraction `t` along the great circle from `a` to `b`. */
+function slerp(a: Vec, b: Vec, t: number): Vec {
+ const w = Math.acos(clamp1(dot(a, b)));
+ const s = Math.sin(w);
+ if (s < 1e-12) return a;
+ const wa = Math.sin((1 - t) * w) / s;
+ const wb = Math.sin(t * w) / s;
+ return [0, 1, 2].map((i) => a[i] * wa + b[i] * wb) as Vec;
+}
+
+/**
+ * Closed ring for drawing the box, sampled along each great-circle edge.
+ *
+ * Corners alone are not enough. The drawn shape would then depend on the
+ * renderer's interpolation mode rather than on the filed geometry, and a long
+ * single segment landing on the antimeridian is what previously stopped Cesium's
+ * render loop: its splitLongitude pass emitted mismatched attribute lists and
+ * threw "All attribute lists must have the same number of attributes". Short
+ * explicit samples have neither problem.
+ */
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
- return [
- [box.south, box.west],
- [box.north, box.west],
- [box.north, box.east],
- [box.south, box.east],
- [box.south, box.west],
- ];
+ const v = box.corners.map(toVec);
+ const ring: LatLngTuple[] = [];
+ for (let e = 0; e < 4; e++) {
+ const from = v[e];
+ const to = v[(e + 1) % 4];
+ for (let i = 0; i < RING_STEPS_PER_EDGE; i++) {
+ ring.push(toLatLng(slerp(from, to, i / RING_STEPS_PER_EDGE)));
+ }
+ }
+ ring.push(ring[0]);
+ return ring;
}
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
export function formatBoundingBox(box: BoundingBox): string {
- const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
- return [
- fmt(box.north, box.west),
- fmt(box.north, box.east),
- fmt(box.south, box.east),
- fmt(box.south, box.west),
- ].join('\n');
+ return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n');
}
diff --git a/src/lib/domain/export.ts b/src/lib/domain/export.ts
new file mode 100644
index 0000000..7ad7a11
--- /dev/null
+++ b/src/lib/domain/export.ts
@@ -0,0 +1,148 @@
+import type { Prediction } from './prediction';
+
+/**
+ * Serialize a prediction for download.
+ *
+ * Pure string builders — no DOM, no fetch — so they stay testable and the
+ * component only has to hand the result to a Blob.
+ *
+ * Note the coordinate order differs by format, which is the usual source of
+ * silently mirrored tracks: CSV/JSON are latitude-first (matching the API and
+ * the rest of this codebase), KML is longitude-first (per the OGC spec).
+ */
+
+export type ExportFormat = 'JSON' | 'CSV' | 'KML';
+
+export const EXPORT_FORMATS: ExportFormat[] = ['JSON', 'CSV', 'KML'];
+
+const MIME: Record = {
+ JSON: 'application/json',
+ CSV: 'text/csv',
+ KML: 'application/vnd.google-earth.kml+xml',
+};
+
+const EXT: Record = { JSON: 'json', CSV: 'csv', KML: 'kml' };
+
+interface Row {
+ datetime: string;
+ latitude: number;
+ longitude: number;
+ altitude: number;
+}
+
+/** Flatten flight_path + its parallel timestamps into plain rows. */
+function rows(p: Prediction): Row[] {
+ return p.flight_path.map((c, i) => ({
+ datetime: new Date(p.timestamps[i]).toISOString(),
+ latitude: c[0],
+ longitude: c[1],
+ altitude: c.length === 3 ? c[2] : 0,
+ }));
+}
+
+function pointOut(pt: Prediction['launch']) {
+ return {
+ latitude: pt.latlng.lat,
+ longitude: pt.latlng.lng,
+ altitude: pt.latlng.alt ?? 0,
+ datetime: pt.datetime.toISOString(),
+ };
+}
+
+export function predictionToJson(p: Prediction): string {
+ return JSON.stringify(
+ {
+ profile: p.profile,
+ flight_time: p.flight_time,
+ launch: pointOut(p.launch),
+ burst: pointOut(p.burst),
+ landing: pointOut(p.landing),
+ trajectory: rows(p),
+ },
+ null,
+ 2,
+ );
+}
+
+export function predictionToCsv(p: Prediction): string {
+ const head = 'datetime,latitude,longitude,altitude';
+ const body = rows(p).map(
+ (r) => `${r.datetime},${r.latitude},${r.longitude},${r.altitude}`,
+ );
+ return [head, ...body].join('\n') + '\n';
+}
+
+/** Minimal XML text escaping for the few interpolated strings. */
+function xml(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+export function predictionToKml(p: Prediction): string {
+ // lon,lat,alt — KML's order, the reverse of everywhere else here.
+ const track = rows(p)
+ .map((r) => `${r.longitude},${r.latitude},${r.altitude}`)
+ .join('\n\t\t\t\t');
+
+ const mark = (name: string, pt: Prediction['launch']) => `
+
+ ${name}
+ ${xml(pt.datetime.toISOString())} — ${pt.latlng.alt ?? 0} m
+
+ absolute
+ ${pt.latlng.lng},${pt.latlng.lat},${pt.latlng.alt ?? 0}
+
+ `;
+
+ // The track Placemark comes first so the flight line is the document's
+ // primary feature rather than one of the event markers.
+ return `
+
+
+ ${xml(`Flight ${p.launch.datetime.toISOString()}`)}
+
+
+ Flight path
+ #track
+
+
+ absolute
+
+ ${track}
+
+
+ ${mark('Launch', p.launch)}${mark('Burst', p.burst)}${mark('Landing', p.landing)}
+
+
+`;
+}
+
+export function serializePrediction(p: Prediction, format: ExportFormat): string {
+ switch (format) {
+ case 'CSV':
+ return predictionToCsv(p);
+ case 'KML':
+ return predictionToKml(p);
+ default:
+ return predictionToJson(p);
+ }
+}
+
+export function exportMimeType(format: ExportFormat): string {
+ return MIME[format];
+}
+
+/** e.g. `prediction-2026-08-03T1200Z-89.0N-68.0E.csv` */
+export function exportFilename(p: Prediction, format: ExportFormat): string {
+ const t = p.launch.datetime.toISOString().slice(0, 16).replace(/[:-]/g, '').replace('T', 'T');
+ const { lat, lng } = p.launch.latlng;
+ const ns = `${Math.abs(lat).toFixed(1)}${lat >= 0 ? 'N' : 'S'}`;
+ const ew = `${Math.abs(lng).toFixed(1)}${lng >= 0 ? 'E' : 'W'}`;
+ return `prediction-${t}Z-${ns}-${ew}.${EXT[format]}`;
+}
diff --git a/src/lib/domain/geo.ts b/src/lib/domain/geo.ts
index f859c2c..ec82787 100644
--- a/src/lib/domain/geo.ts
+++ b/src/lib/domain/geo.ts
@@ -1,8 +1,9 @@
/**
* Geographic primitives used by map layers and predictions.
*
- * LngLat convention matches MapLibre (longitude first) for on-map work;
- * LatLng is preserved for API payloads and legacy Leaflet-era code paths.
+ * LngLat convention is longitude-first for on-map work, matching Cesium's
+ * Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
+ * legacy Leaflet-era code paths.
*/
export interface LatLng {
@@ -11,6 +12,45 @@ export interface LatLng {
alt?: number;
}
+/**
+ * The pole. This is geographic reality, not a workaround.
+ *
+ * An earlier revision capped launches at 89.999° because the predictor's
+ * longitude rate went as 1/cos(lat) against a fixed step and became
+ * unreproducible near the pole. That formulation is gone — it now integrates
+ * along great circles and handles 90° exactly — so the cap is the real limit.
+ */
+export const MAX_LAUNCH_LATITUDE = 90;
+
+/**
+ * Constrain a latitude to ±90. NaN passes through so a half-typed input does
+ * not jump under the user's cursor.
+ *
+ * Latitude clamps rather than wraps: a pole is a barrier, and 91°N is not a
+ * place. Longitude is the opposite case — see wrapLongitude.
+ */
+export function clampLaunchLatitude(lat: number): number {
+ if (!Number.isFinite(lat)) return lat;
+ return Math.min(MAX_LAUNCH_LATITUDE, Math.max(-MAX_LAUNCH_LATITUDE, lat));
+}
+
+/**
+ * Wrap a longitude into [-180, 180).
+ *
+ * Wraps rather than clamps because a meridian is not a barrier: 200°E is the
+ * same place as -160°, so wrapping keeps the point the user meant, while
+ * clamping to 180 would silently move it 20° away.
+ *
+ * Distinct from normalizeLng, which only folds the 0..360 convention the API
+ * sometimes returns and is not a general wrap.
+ */
+export function wrapLongitude(lng: number): number {
+ if (!Number.isFinite(lng)) return lng;
+ const wrapped = ((lng + 180) % 360 + 360) % 360 - 180;
+ // -180 and 180 are the same meridian; pick the lower bound consistently.
+ return wrapped === -0 ? 0 : wrapped;
+}
+
export type LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
export type LatLngExpression = LatLng | LatLngTuple;
diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts
index d3705ad..4a24599 100644
--- a/src/lib/domain/index.ts
+++ b/src/lib/domain/index.ts
@@ -3,5 +3,5 @@ export * from './math';
export * from './scenario';
export * from './prediction';
export * from './telemetry';
-export * from './wind';
export * from './boundingBox';
+export * from './export';
diff --git a/src/lib/domain/wind.ts b/src/lib/domain/wind.ts
deleted file mode 100644
index 46455bc..0000000
--- a/src/lib/domain/wind.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * Wind field types matching the wind-js-server / leaflet-velocity format
- * produced by the predictor's GET /api/v1/wind/field endpoint.
- *
- * The response is a two-element array [U, V] where U is the eastward and V
- * the northward wind component, each stored as a regular lat/lng grid
- * described by a GRIB-style header.
- */
-
-export interface WindHeader {
- parameterUnit: string;
- parameterNumberName: string;
- /** Grid points in the longitude direction. */
- nx: number;
- /** Grid points in the latitude direction. */
- ny: number;
- lo1: number; // longitude of first grid point (degrees)
- la1: number; // latitude of first grid point (degrees)
- lo2: number; // longitude of last grid point
- la2: number; // latitude of last grid point
- /**
- * Grid increments in degrees. Both are reported as positive magnitudes by
- * the predictor regardless of scan direction, so the scan direction must be
- * inferred from the extent (la1/la2, lo1/lo2) — see decodeWindField.
- */
- dx: number;
- dy: number;
- refTime: string; // ISO 8601 reference time
-}
-
-export interface WindComponent {
- header: WindHeader;
- /** Flat row-major array: data[j * nx + i] = value at row j, column i. */
- data: number[];
-}
-
-/** [U-component (eastward m/s), V-component (northward m/s)] */
-export type WindField = [WindComponent, WindComponent];
-
-export interface WindMeta {
- source: string;
- epoch: string;
- altitudes: number[];
- bbox: {
- min_lat: number;
- max_lat: number;
- min_lng: number;
- max_lng: number;
- };
-}
-
-/** Decoded wind vector at a single grid cell. */
-export interface WindVector {
- lat: number;
- lng: number;
- u: number; // eastward component (m/s)
- v: number; // northward component (m/s)
- speed: number; // magnitude (m/s)
- /**
- * Direction the wind blows TO, degrees clockwise from north.
- * 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate.
- *
- * Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3).
- */
- bearing: number;
-}
-
-export interface WindSettings {
- /** Master toggle — off by default. */
- enabled: boolean;
- /** Grid resolution for static display (degrees). */
- step: number;
- /** Grid resolution when synced to a trajectory (degrees). */
- trajectoryStep: number;
- /** Time interval between pre-fetched trajectory frames (minutes). */
- prefetchIntervalMinutes: number;
- /** Trajectory sync is skipped when flight duration exceeds this (hours). */
- maxFlightDurationHours: number;
- /**
- * Trajectory sync is skipped when the bounding box exceeds this in either
- * dimension (degrees).
- */
- maxRegionDegrees: number;
- /** Padding added to the trajectory bounding box on each side (degrees). */
- trajectoryMarginDegrees: number;
- /** Particle count scalar (particles per screen pixel). Higher = denser. */
- particleDensity: number;
- /** Advection speed multiplier — how fast particles flow. */
- particleSpeed: number;
- /** Trail persistence in [0,1): fraction of each trail kept per frame. */
- trailPersistence: number;
- /** Wind speed (m/s) mapped to the top of the colour scale. */
- maxVelocity: number;
-}
-
-export const DEFAULT_WIND_SETTINGS: WindSettings = {
- enabled: false,
- step: 2.0,
- trajectoryStep: 1.0,
- prefetchIntervalMinutes: 15,
- maxFlightDurationHours: 4,
- maxRegionDegrees: 20,
- trajectoryMarginDegrees: 1.0,
- particleDensity: 1.0,
- particleSpeed: 1.0,
- trailPersistence: 0.92,
- maxVelocity: 30,
-};
-
-/** Wrap a longitude into the (-180, 180] range MapLibre renders. */
-function wrapLng(lng: number): number {
- let x = ((lng + 180) % 360) - 180;
- if (x <= -180) x += 360;
- return x;
-}
-
-/**
- * Rasterize a WindField into an array of wind vectors — one per grid cell.
- *
- * Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2)
- * rather than the raw dx/dy increments, because the predictor reports:
- * • longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and
- * • a *positive* dy even when the grid scans north→south (la1 = 90,
- * la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
- *
- * Stepping from the first point toward the last (la1→la2, lo1→lo2) and
- * wrapping longitudes into (-180, 180] places every arrow at its true
- * geographic position regardless of scan direction or longitude convention.
- */
-export function decodeWindField(field: WindField): WindVector[] {
- const [uComp, vComp] = field;
- const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
- const vectors: WindVector[] = [];
-
- // Per-step deltas taken from the grid extent so the last row/column lands
- // exactly on la2/lo2. Longitude span is taken the short way around the
- // globe to stay correct for boxes that cross the 0/360 seam.
- const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
- const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
- const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
-
- for (let j = 0; j < ny; j++) {
- const lat = la1 + j * latDelta;
- for (let i = 0; i < nx; i++) {
- const idx = j * nx + i;
- const u = uComp.data[idx];
- const v = vComp.data[idx];
- if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
- const lng = wrapLng(lo1 + i * lngDelta);
- const speed = Math.sqrt(u * u + v * v);
- const bearing = (Math.atan2(u, v) * 180) / Math.PI;
- vectors.push({ lat, lng, u, v, speed, bearing });
- }
- }
-
- return vectors;
-}
-
-/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
-export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
-
-/**
- * Build a bilinear interpolator over a WindField. Used by the particle
- * renderer to advect points through a continuous [u, v] field.
- *
- * Coordinate handling mirrors decodeWindField: longitudes are taken in the
- * grid's native 0..360 frame (so a query lng is brought into that frame),
- * and the per-step increments come from the grid extent so scan direction is
- * handled implicitly.
- */
-export function createWindInterpolator(field: WindField): WindInterpolator {
- const [uComp, vComp] = field;
- const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
- const u = uComp.data;
- const v = vComp.data;
-
- const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
- const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
- const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
-
- return (lng, lat) => {
- if (lngDelta === 0 || latDelta === 0) return null;
-
- const rj = (lat - la1) / latDelta;
- if (rj < 0 || rj > ny - 1) return null;
-
- // Eastward offset from lo1 in the grid's 0..360 frame.
- const dLon = (((lng - lo1) % 360) + 360) % 360;
- const ci = dLon / lngDelta;
- if (ci < 0 || ci > nx - 1) return null;
-
- const i0 = Math.floor(ci);
- const j0 = Math.floor(rj);
- const i1 = Math.min(i0 + 1, nx - 1);
- const j1 = Math.min(j0 + 1, ny - 1);
- const fi = ci - i0;
- const fj = rj - j0;
-
- const a = (1 - fi) * (1 - fj);
- const b = fi * (1 - fj);
- const c = (1 - fi) * fj;
- const d = fi * fj;
-
- const k00 = j0 * nx + i0;
- const k10 = j0 * nx + i1;
- const k01 = j1 * nx + i0;
- const k11 = j1 * nx + i1;
-
- const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
- const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
- if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
- return [ui, vi];
- };
-}
diff --git a/src/lib/features/mapchrome/MapChrome.svelte b/src/lib/features/mapchrome/MapChrome.svelte
new file mode 100644
index 0000000..d2f5fbd
--- /dev/null
+++ b/src/lib/features/mapchrome/MapChrome.svelte
@@ -0,0 +1,77 @@
+
diff --git a/src/lib/features/mapchrome/index.ts b/src/lib/features/mapchrome/index.ts
new file mode 100644
index 0000000..7cc8377
--- /dev/null
+++ b/src/lib/features/mapchrome/index.ts
@@ -0,0 +1 @@
+export { default as MapChrome } from './MapChrome.svelte';
diff --git a/src/lib/features/prediction/ControlPanel.svelte b/src/lib/features/prediction/ControlPanel.svelte
index 9d17c17..4e8b363 100644
--- a/src/lib/features/prediction/ControlPanel.svelte
+++ b/src/lib/features/prediction/ControlPanel.svelte
@@ -27,7 +27,10 @@
import { pointsApi } from '$api';
import {
DEFAULT_FLIGHT_PARAMETERS,
+ MAX_LAUNCH_LATITUDE,
PROFILE_IDENTIFIERS,
+ clampLaunchLatitude,
+ wrapLongitude,
toFixedNumber,
type FlightParameters,
type ProfileIdentifier,
@@ -75,7 +78,17 @@
function patchActive(patch: Partial) {
if (!active) return;
- workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
+ // Single choke point for launch latitude: typing it, clicking the map
+ // (updateLaunchPosition) and picking a saved point all land here, so one
+ // clamp covers every path.
+ const safe = { ...patch };
+ if (safe.launch_latitude !== undefined) {
+ safe.launch_latitude = clampLaunchLatitude(safe.launch_latitude);
+ }
+ if (safe.launch_longitude !== undefined) {
+ safe.launch_longitude = wrapLongitude(safe.launch_longitude);
+ }
+ workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...safe });
}
function handlePointSelection(newPointId: number | null) {
@@ -225,6 +238,8 @@
patchActive({
diff --git a/src/lib/features/prediction/ScenarioPanel.svelte b/src/lib/features/prediction/ScenarioPanel.svelte
index 05d9d5d..bcec060 100644
--- a/src/lib/features/prediction/ScenarioPanel.svelte
+++ b/src/lib/features/prediction/ScenarioPanel.svelte
@@ -10,7 +10,15 @@
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
import { scenariosApi } from '$api';
- import { PREDICTION_MODES, type SavedScenario } from '$domain';
+ import {
+ EXPORT_FORMATS,
+ PREDICTION_MODES,
+ exportFilename,
+ exportMimeType,
+ serializePrediction,
+ type ExportFormat,
+ type SavedScenario,
+ } from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { scenariosStore } from './pointsStore';
@@ -18,8 +26,32 @@
let selectedScenarioId = $state(-1);
let editorRef: ScenarioEditor | null = $state(null);
+ let exportFormat = $state('JSON');
let active = $derived(getActiveWorkspace($workspacesStore));
+
+ function handleExport() {
+ const result = active?.result;
+ if (!result) {
+ addToast({
+ header: $t('scenario.export'),
+ body: $t('scenario.exportNoResult'),
+ color: 'warning',
+ });
+ return;
+ }
+ const text = serializePrediction(result, exportFormat);
+ const url = URL.createObjectURL(
+ new Blob([text], { type: exportMimeType(exportFormat) }),
+ );
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = exportFilename(result, exportFormat);
+ a.click();
+ // Revoking immediately can abort the download in some browsers; let the
+ // navigation start first.
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
let scenarioUnsaved = $derived.by(() => {
if (!active) return false;
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
@@ -172,12 +204,12 @@
-
-
-
-
+
+ {#each EXPORT_FORMATS as f (f)}
+
+ {/each}
-
{#if w.bboxVisible && box}
{@const coords = formatBoundingBox(box)}
+
+