From eb7698e0340b3726540d8dd54c69c990a8a7e30e Mon Sep 17 00:00:00 2001 From: gili8420 Date: Mon, 3 Aug 2026 22:12:32 +0900 Subject: [PATCH] feat(globe): port to CeliumJS --- .gitignore | 7 + docs/POLAR_FINDINGS.md | 157 ++++ .../2026-08-03-cesium-globe-migration.md | 700 ++++++++++++++++++ .../2026-08-03-restricted-area-box-design.md | 133 ++++ package-lock.json | 671 +++++++---------- package.json | 11 +- playwright.config.ts | 3 +- scripts/copy-cesium.js | 24 + src/app.css | 23 +- src/app.html | 5 + src/lib/api/index.ts | 1 - src/lib/api/wind.ts | 58 -- src/lib/domain/boundingBox.ts | 223 ++++-- src/lib/domain/export.ts | 148 ++++ src/lib/domain/geo.ts | 44 +- src/lib/domain/index.ts | 2 +- src/lib/domain/wind.ts | 214 ------ src/lib/features/mapchrome/MapChrome.svelte | 77 ++ src/lib/features/mapchrome/index.ts | 1 + .../features/prediction/ControlPanel.svelte | 17 +- .../features/prediction/ScenarioPanel.svelte | 44 +- src/lib/features/settings/index.ts | 2 +- src/lib/features/settings/schema.ts | 92 +-- src/lib/features/settings/store.ts | 16 +- src/lib/features/wind/ParticleField.ts | 335 --------- src/lib/features/wind/WindRenderer.svelte | 332 --------- src/lib/features/wind/index.ts | 3 - src/lib/features/wind/store.ts | 61 -- .../workspaces/WorkspaceRenderer.svelte | 9 + .../workspaces/WorkspacesPanel.svelte | 9 + src/lib/i18n/locales/en.json | 19 +- src/lib/i18n/locales/ru.json | 19 +- src/lib/map/Map.svelte | 14 +- src/lib/map/cesium-scene.ts | 166 +++++ src/lib/map/cesium.ts | 303 ++++++++ src/lib/map/core.ts | 27 +- src/lib/map/index.ts | 2 +- src/lib/map/layers.ts | 9 +- src/lib/map/maplibre.ts | 324 -------- src/routes/predict/+page.svelte | 4 +- src/routes/track/+page.svelte | 2 + tests/e2e/bbox.spec.ts | 123 +++ tests/e2e/export.spec.ts | 121 +++ tests/e2e/fixtures.ts | 19 + tests/e2e/globe.spec.ts | 144 ++++ tests/e2e/map-controls.spec.ts | 291 ++++++++ tests/e2e/polar.spec.ts | 191 +++++ tests/e2e/smoke.spec.ts | 2 +- tests/e2e/track.spec.ts | 27 +- tests/e2e/workspaces.spec.ts | 84 ++- tests/unit/boundingBox.spec.ts | 200 +++++ 51 files changed, 3521 insertions(+), 1992 deletions(-) create mode 100644 docs/POLAR_FINDINGS.md create mode 100644 docs/superpowers/plans/2026-08-03-cesium-globe-migration.md create mode 100644 docs/superpowers/specs/2026-08-03-restricted-area-box-design.md create mode 100644 scripts/copy-cesium.js delete mode 100644 src/lib/api/wind.ts create mode 100644 src/lib/domain/export.ts delete mode 100644 src/lib/domain/wind.ts create mode 100644 src/lib/features/mapchrome/MapChrome.svelte create mode 100644 src/lib/features/mapchrome/index.ts delete mode 100644 src/lib/features/wind/ParticleField.ts delete mode 100644 src/lib/features/wind/WindRenderer.svelte delete mode 100644 src/lib/features/wind/index.ts delete mode 100644 src/lib/features/wind/store.ts create mode 100644 src/lib/map/cesium-scene.ts create mode 100644 src/lib/map/cesium.ts delete mode 100644 src/lib/map/maplibre.ts create mode 100644 tests/e2e/bbox.spec.ts create mode 100644 tests/e2e/export.spec.ts create mode 100644 tests/e2e/globe.spec.ts create mode 100644 tests/e2e/map-controls.spec.ts create mode 100644 tests/e2e/polar.spec.ts create mode 100644 tests/unit/boundingBox.spec.ts 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} - diff --git a/src/lib/features/settings/index.ts b/src/lib/features/settings/index.ts index 1deb343..e78744c 100644 --- a/src/lib/features/settings/index.ts +++ b/src/lib/features/settings/index.ts @@ -1,5 +1,5 @@ export { settingsStore, DEFAULT_SETTINGS } from './store'; -export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store'; +export type { AppSettings, MapSettings, UnitsSettings } from './store'; export { default as SettingsPanel } from './SettingsPanel.svelte'; export { SETTINGS_SCHEMA } from './schema'; export type { SettingsField, SettingsSection } from './schema'; diff --git a/src/lib/features/settings/schema.ts b/src/lib/features/settings/schema.ts index 22265ef..8c6e4d2 100644 --- a/src/lib/features/settings/schema.ts +++ b/src/lib/features/settings/schema.ts @@ -61,10 +61,12 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [ path: 'map.baseLayer', labelKey: 'settings.baseLayer', options: [ - { value: 'osm', labelKey: 'settings.baseLayer' }, - { value: 'satellite', labelKey: 'settings.baseLayer' }, + { value: 'osm', labelKey: 'settings.baseLayerOsm' }, + { value: 'satellite', labelKey: 'settings.baseLayerSatellite' }, + { value: 'polar', labelKey: 'settings.baseLayerPolar' }, ], }, + { kind: 'boolean', path: 'map.graticule', labelKey: 'settings.graticule' }, { kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' }, { kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' }, ], @@ -83,90 +85,4 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [ }, ], }, - { - titleKey: 'settings.wind', - fields: [ - { kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' }, - { - kind: 'number', - path: 'wind.step', - labelKey: 'settings.windStep', - min: 0.25, - max: 10, - step: 0.25, - }, - { - kind: 'number', - path: 'wind.trajectoryStep', - labelKey: 'settings.windTrajectoryStep', - min: 0.25, - max: 5, - step: 0.25, - }, - { - kind: 'number', - path: 'wind.prefetchIntervalMinutes', - labelKey: 'settings.windPrefetchInterval', - min: 5, - max: 60, - step: 5, - }, - { - kind: 'number', - path: 'wind.maxFlightDurationHours', - labelKey: 'settings.windMaxDuration', - min: 1, - max: 8, - step: 0.5, - }, - { - kind: 'number', - path: 'wind.maxRegionDegrees', - labelKey: 'settings.windMaxRegion', - min: 5, - max: 60, - step: 5, - }, - { - kind: 'number', - path: 'wind.trajectoryMarginDegrees', - labelKey: 'settings.windMargin', - min: 0.5, - max: 5, - step: 0.5, - }, - { - kind: 'number', - path: 'wind.particleDensity', - labelKey: 'settings.windParticleDensity', - min: 0.25, - max: 3, - step: 0.25, - }, - { - kind: 'number', - path: 'wind.particleSpeed', - labelKey: 'settings.windParticleSpeed', - min: 0.25, - max: 4, - step: 0.25, - }, - { - kind: 'number', - path: 'wind.trailPersistence', - labelKey: 'settings.windTrailPersistence', - min: 0.7, - max: 0.98, - step: 0.02, - }, - { - kind: 'number', - path: 'wind.maxVelocity', - labelKey: 'settings.windMaxVelocity', - min: 10, - max: 80, - step: 5, - }, - ], - }, ]; diff --git a/src/lib/features/settings/store.ts b/src/lib/features/settings/store.ts index c13a866..44cdad1 100644 --- a/src/lib/features/settings/store.ts +++ b/src/lib/features/settings/store.ts @@ -1,13 +1,17 @@ import { persisted } from '$state'; import type { Locale } from '$i18n'; -import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain'; - -export type { WindSettings }; +import type { BaseLayerId } from '$map'; export interface MapSettings { - baseLayer: 'osm' | 'satellite'; + baseLayer: BaseLayerId; showScale: boolean; showNavigation: boolean; + /** + * Optional because `persisted` does not merge defaults into an existing + * stored payload — settings saved before the graticule existed simply lack + * the key, so read sites default it to on. + */ + graticule?: boolean; } export interface UnitsSettings { @@ -18,14 +22,12 @@ export interface AppSettings { locale: Locale; map: MapSettings; units: UnitsSettings; - wind: WindSettings; } export const DEFAULT_SETTINGS: AppSettings = { locale: 'ru', - map: { baseLayer: 'osm', showScale: true, showNavigation: true }, + map: { baseLayer: 'osm', showScale: true, showNavigation: true, graticule: true }, units: { system: 'metric' }, - wind: { ...DEFAULT_WIND_SETTINGS }, }; export const settingsStore = persisted('settings', DEFAULT_SETTINGS); diff --git a/src/lib/features/wind/ParticleField.ts b/src/lib/features/wind/ParticleField.ts deleted file mode 100644 index 3d47d44..0000000 --- a/src/lib/features/wind/ParticleField.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * ParticleField — an animated wind-flow layer rendered to a 2D canvas - * overlaid on the MapLibre container, in the spirit of leaflet-velocity / - * cambecc's "earth". - * - * Particles live in CSS-pixel space. Each frame, every particle is unprojected - * to lng/lat, the wind [u, v] there is sampled, and that vector is pushed - * through the map projection's local Jacobian to obtain a pixel-space velocity - * (so motion is correct at any zoom/latitude). Trails are faded by compositing - * a translucent clear over the previous frame, leaving the basemap visible. - * - * The wind field can change every frame (the renderer interpolates between - * pre-fetched trajectory frames over time); only the lightweight interpolator - * closure is swapped, so particle motion stays continuous. See - * docs/wind-vis-math.tex §"Particle Advection". - */ - -import type { Map as MLMap } from 'maplibre-gl'; -import type { WindInterpolator } from '$domain'; - -export interface ParticleOptions { - /** Particles per screen pixel (scaled by the base multiplier). */ - density: number; - /** Advection speed multiplier. */ - speed: number; - /** Trail persistence in [0,1): fraction of the trail kept each frame. */ - trailPersistence: number; - /** Max frames a particle lives before it is respawned. */ - maxAge: number; - /** Trail line width (CSS px). */ - lineWidth: number; - /** Wind speed (m/s) at the bottom / top of the colour scale. */ - minVelocity: number; - maxVelocity: number; - /** Target frame rate (the field is re-evaluated at most this often). */ - frameRate: number; - /** Colour ramp from slow → fast wind. */ - colorScale: string[]; -} - -export const DEFAULT_COLOR_SCALE = [ - 'rgb(36,104,180)', - 'rgb(60,157,194)', - 'rgb(128,205,193)', - 'rgb(151,218,168)', - 'rgb(198,231,181)', - 'rgb(238,247,217)', - 'rgb(255,238,159)', - 'rgb(252,217,125)', - 'rgb(255,182,100)', - 'rgb(252,150,75)', - 'rgb(250,112,52)', - 'rgb(245,64,32)', - 'rgb(237,45,28)', - 'rgb(220,24,32)', - 'rgb(180,0,35)', -]; - -export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = { - density: 1.0, - speed: 1.0, - trailPersistence: 0.92, - maxAge: 100, - lineWidth: 1.4, - minVelocity: 0, - maxVelocity: 30, - frameRate: 30, - colorScale: DEFAULT_COLOR_SCALE, -}; - -/** Base particle count = pixels × this (kept modest for performance). */ -const PARTICLE_MULTIPLIER = 1 / 350; -const MAX_PARTICLES = 6000; - -interface Particle { - x: number; - y: number; - xt: number; - yt: number; - age: number; - speed: number; -} - -export class ParticleField { - private map: MLMap; - private host: HTMLElement; - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private opts: ParticleOptions; - private interp: WindInterpolator | null = null; - - private particles: Particle[] = []; - private raf = 0; - private then = 0; - private moving = false; - private width = 0; - private height = 0; - private debugLogged = false; - - constructor(map: MLMap, opts: Partial = {}) { - this.map = map; - this.opts = { ...DEFAULT_PARTICLE_OPTIONS, ...opts }; - - // Mount inside the MapLibre canvas container so the overlay sits above - // the basemap but below the control container and the app's panels. - this.host = map.getCanvasContainer(); - - const canvas = document.createElement('canvas'); - canvas.className = 'wind-particles'; - canvas.style.position = 'absolute'; - canvas.style.top = '0'; - canvas.style.left = '0'; - canvas.style.pointerEvents = 'none'; - canvas.style.zIndex = '3'; - this.host.appendChild(canvas); - this.canvas = canvas; - this.ctx = canvas.getContext('2d')!; - - this.map.on('movestart', this.onMoveStart); - this.map.on('moveend', this.onMoveEnd); - this.map.on('resize', this.onResize); - this.resize(); - } - - setOptions(opts: Partial): void { - const densityChanged = opts.density !== undefined && opts.density !== this.opts.density; - this.opts = { ...this.opts, ...opts }; - if (densityChanged) this.seedParticles(); - } - - /** Swap the wind field. Pass null to clear the flow. */ - setField(interp: WindInterpolator | null): void { - this.interp = interp; - if (interp && this.particles.length === 0) this.seedParticles(); - } - - start(): void { - if (this.raf) return; - this.then = performance.now(); - this.raf = requestAnimationFrame(this.frame); - } - - stop(): void { - if (this.raf) cancelAnimationFrame(this.raf); - this.raf = 0; - this.clear(); - } - - destroy(): void { - this.stop(); - this.map.off('movestart', this.onMoveStart); - this.map.off('moveend', this.onMoveEnd); - this.map.off('resize', this.onResize); - this.canvas.remove(); - } - - // ── Internals ───────────────────────────────────────────────────────────── - - private onMoveStart = (): void => { - this.moving = true; - this.clear(); - }; - - private onMoveEnd = (): void => { - this.moving = false; - this.seedParticles(); - }; - - private onResize = (): void => { - this.resize(); - }; - - private resize(): void { - const dpr = window.devicePixelRatio || 1; - // Size from the gl canvas: it always reports the true viewport size, - // whereas the canvas-container wrapper can measure 0 in some layouts. - const glCanvas = this.map.getCanvas(); - const w = glCanvas.clientWidth || this.map.getContainer().clientWidth; - const h = glCanvas.clientHeight || this.map.getContainer().clientHeight; - if (!w || !h) return; - this.width = w; - this.height = h; - this.canvas.style.width = `${w}px`; - this.canvas.style.height = `${h}px`; - this.canvas.width = Math.round(w * dpr); - this.canvas.height = Math.round(h * dpr); - this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space - this.seedParticles(); - } - - private particleCount(): number { - const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density; - return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n))); - } - - private seedParticles(): void { - const count = this.particleCount(); - this.particles = new Array(count); - for (let i = 0; i < count; i++) { - this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 }; - this.respawn(this.particles[i]); - this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge); - } - } - - /** Place a particle at a random pixel that has wind (a few retries). */ - private respawn(p: Particle): void { - for (let attempt = 0; attempt < 8; attempt++) { - const x = Math.random() * this.width; - const y = Math.random() * this.height; - if (!this.interp) { - p.x = p.xt = x; - p.y = p.yt = y; - break; - } - const ll = this.map.unproject([x, y]); - if (this.interp(ll.lng, ll.lat)) { - p.x = p.xt = x; - p.y = p.yt = y; - break; - } - p.x = p.xt = x; - p.y = p.yt = y; - } - p.age = 0; - p.speed = 0; - } - - private clear(): void { - this.ctx.clearRect(0, 0, this.width, this.height); - } - - private colorIndex(speed: number): number { - const { minVelocity, maxVelocity, colorScale } = this.opts; - const f = (speed - minVelocity) / (maxVelocity - minVelocity); - return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1)))); - } - - private evolve(): void { - const interp = this.interp; - if (!interp) return; - const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale - const eps = 0.02; // degrees, for the projection Jacobian - - for (const p of this.particles) { - if (p.age >= this.opts.maxAge) { - this.respawn(p); - continue; - } - const ll = this.map.unproject([p.x, p.y]); - const wind = interp(ll.lng, ll.lat); - if (!wind) { - p.age = this.opts.maxAge; // escaped the field → respawn next tick - continue; - } - const [u, v] = wind; - - // Local projection Jacobian: pixel deltas per degree at this point. - const east = this.map.project([ll.lng + eps, ll.lat]); - const north = this.map.project([ll.lng, ll.lat + eps]); - const jxLng = (east.x - p.x) / eps; - const jyLng = (east.y - p.y) / eps; - const jxLat = (north.x - p.x) / eps; - const jyLat = (north.y - p.y) / eps; - - p.xt = p.x + (jxLng * u + jxLat * v) * scale; - p.yt = p.y + (jyLng * u + jyLat * v) * scale; - p.speed = Math.sqrt(u * u + v * v); - p.age += 1; - } - } - - private draw(): void { - const ctx = this.ctx; - - // Fade existing trails toward transparent (keeps the basemap visible). - ctx.globalCompositeOperation = 'destination-in'; - ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`; - ctx.fillRect(0, 0, this.width, this.height); - ctx.globalCompositeOperation = 'source-over'; - - // Draw new trail segments, grouped by colour bucket. - const { colorScale } = this.opts; - ctx.lineWidth = this.opts.lineWidth; - const buckets: Particle[][] = colorScale.map(() => []); - for (const p of this.particles) { - if (p.age >= this.opts.maxAge || p.speed === 0) continue; - buckets[this.colorIndex(p.speed)].push(p); - } - let drawn = 0; - for (let i = 0; i < buckets.length; i++) { - const bucket = buckets[i]; - if (bucket.length === 0) continue; - drawn += bucket.length; - ctx.strokeStyle = colorScale[i]; - ctx.beginPath(); - for (const p of bucket) { - ctx.moveTo(p.x, p.y); - ctx.lineTo(p.xt, p.yt); - } - ctx.stroke(); - } - - if (import.meta.env.DEV && !this.debugLogged) { - this.debugLogged = true; - // One-shot diagnostic: confirms field, canvas size, and that segments - // are actually being drawn. Remove once the layer is verified. - // eslint-disable-next-line no-console - console.debug('[wind] first draw', { - hasInterp: !!this.interp, - canvas: `${this.width}x${this.height}`, - backing: `${this.canvas.width}x${this.canvas.height}`, - particles: this.particles.length, - drawnSegments: drawn, - host: this.host.className, - }); - } - - // Advance positions for the next frame. - for (const p of this.particles) { - p.x = p.xt; - p.y = p.yt; - } - } - - private frame = (now: number): void => { - this.raf = requestAnimationFrame(this.frame); - if (this.moving || !this.interp) return; - const frameTime = 1000 / this.opts.frameRate; - if (now - this.then < frameTime) return; - this.then = now - ((now - this.then) % frameTime); - this.evolve(); - this.draw(); - }; -} diff --git a/src/lib/features/wind/WindRenderer.svelte b/src/lib/features/wind/WindRenderer.svelte deleted file mode 100644 index 1e3c15a..0000000 --- a/src/lib/features/wind/WindRenderer.svelte +++ /dev/null @@ -1,332 +0,0 @@ - - -{#if windSettings.enabled && prefetchSkipReason} -
- - {#if prefetchSkipReason === 'wind.skippedLong'} - Wind sync skipped: flight > {windSettings.maxFlightDurationHours}h - {:else} - Wind sync skipped: region > {windSettings.maxRegionDegrees}° - {/if} -
-{/if} - - diff --git a/src/lib/features/wind/index.ts b/src/lib/features/wind/index.ts deleted file mode 100644 index b70b760..0000000 --- a/src/lib/features/wind/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as WindRenderer } from './WindRenderer.svelte'; -export { windCache } from './store'; -export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField'; diff --git a/src/lib/features/wind/store.ts b/src/lib/features/wind/store.ts deleted file mode 100644 index 5e34494..0000000 --- a/src/lib/features/wind/store.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Thin cache layer for wind field responses. - * - * Each unique set of request parameters is keyed by a stable JSON string so - * that the same (time, altitude, bbox, step) combination is fetched only once - * per session even if multiple effects request it concurrently. The cache is - * intentionally never invalidated during a session — the predictor's dataset - * does not change while the user is working. - */ - -import { windApi, type WindFieldParams } from '$api'; -import type { WindField } from '$domain'; - -function cacheKey(params: WindFieldParams): string { - return JSON.stringify({ - altitude: params.altitude ?? null, - step: params.step ?? null, - time: params.time ?? null, - min_lat: params.min_lat ?? null, - max_lat: params.max_lat ?? null, - min_lng: params.min_lng ?? null, - max_lng: params.max_lng ?? null, - }); -} - -class WindCache { - private readonly hits = new Map(); - private readonly pending = new Map>(); - - fetch(params: WindFieldParams): Promise { - const key = cacheKey(params); - - const hit = this.hits.get(key); - if (hit) return Promise.resolve(hit); - - const existing = this.pending.get(key); - if (existing) return existing; - - const promise = windApi - .field(params) - .then((field) => { - this.hits.set(key, field); - this.pending.delete(key); - return field; - }) - .catch((err: unknown) => { - this.pending.delete(key); - throw err; - }); - - this.pending.set(key, promise); - return promise; - } - - clear(): void { - this.hits.clear(); - this.pending.clear(); - } -} - -export const windCache = new WindCache(); diff --git a/src/lib/features/workspaces/WorkspaceRenderer.svelte b/src/lib/features/workspaces/WorkspaceRenderer.svelte index 1f2d333..113e15d 100644 --- a/src/lib/features/workspaces/WorkspaceRenderer.svelte +++ b/src/lib/features/workspaces/WorkspaceRenderer.svelte @@ -73,6 +73,15 @@ if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) { const scene = map.scene(name); plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity }); + // Frame a freshly-arrived result. A flight spans only tens of km, + // which is a few dozen pixels at the default camera height — the + // track ends up completely hidden under its own launch/burst/ + // landing markers and reads as a single dot. Deliberately not done + // for colour/opacity edits, so tweaking one workspace while + // comparing several does not yank the camera around. + if (!cached || cached.result !== w.result) { + map.fitBounds(w.result.flight_path, 50); + } ownedPlotScenes.add(name); plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity }); } diff --git a/src/lib/features/workspaces/WorkspacesPanel.svelte b/src/lib/features/workspaces/WorkspacesPanel.svelte index eed4ccb..aebbca5 100644 --- a/src/lib/features/workspaces/WorkspacesPanel.svelte +++ b/src/lib/features/workspaces/WorkspacesPanel.svelte @@ -199,6 +199,15 @@ {#if w.bboxVisible && box} {@const coords = formatBoundingBox(box)} + +
+ {box.widthKm.toFixed(1)} × {box.heightKm.toFixed(1)} km @ {box.centre[0].toFixed( + 4, + )}, {box.centre[1].toFixed(4)} +