diff --git a/.gitignore b/.gitignore index 9405a0a..15dd615 100644 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,3 @@ 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 deleted file mode 100644 index 059469b..0000000 --- a/docs/POLAR_FINDINGS.md +++ /dev/null @@ -1,157 +0,0 @@ -# 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 deleted file mode 100644 index 7d57ff5..0000000 --- a/docs/superpowers/plans/2026-08-03-cesium-globe-migration.md +++ /dev/null @@ -1,700 +0,0 @@ -# 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 deleted file mode 100644 index 47d753e..0000000 --- a/docs/superpowers/specs/2026-08-03-restricted-area-box-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# 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 590252c..326f105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,16 @@ "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" + "luxon": "^3.6.1", + "maplibre-gl": "^4.0.0", + "svelte5-chartjs": "^1.0.0" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -43,57 +45,6 @@ "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", @@ -542,6 +493,81 @@ "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", @@ -572,54 +598,6 @@ "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", @@ -880,6 +858,47 @@ "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", @@ -978,11 +997,6 @@ "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", @@ -994,25 +1008,61 @@ "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/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/@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/@vincjo/datatables": { "version": "2.5.0", @@ -1042,17 +1092,6 @@ "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", @@ -1061,11 +1100,6 @@ "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", @@ -1081,18 +1115,6 @@ } ] }, - "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", @@ -1148,10 +1170,10 @@ "node": ">=6" } }, - "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/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" }, "node_modules/cookie": { "version": "0.6.0", @@ -1222,19 +1244,6 @@ "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", @@ -1293,6 +1302,11 @@ "@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", @@ -1321,10 +1335,66 @@ "node": "^8.16.0 || ^10.6.0 || >=11.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/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/is-reference": { "version": "3.0.3", @@ -1334,6 +1404,14 @@ "@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", @@ -1342,19 +1420,24 @@ "node": ">=14" } }, - "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/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/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", @@ -1364,26 +1447,11 @@ "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", @@ -1400,15 +1468,53 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "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/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/meshoptimizer": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.23.0.tgz", - "integrity": "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ==" + "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/mri": { "version": "1.2.0", @@ -1434,6 +1540,11 @@ "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", @@ -1452,25 +1563,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "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/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/picocolors": { "version": "1.1.1", @@ -1562,40 +1665,20 @@ "node": "^10 || ^12 || >=14" } }, - "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/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==" }, - "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/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/node_modules/quickselect": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", - "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" + "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/readdirp": { "version": "4.1.2", @@ -1610,6 +1693,14 @@ "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", @@ -1649,6 +1740,11 @@ "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", @@ -1690,6 +1786,14 @@ "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", @@ -1737,6 +1841,15 @@ "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", @@ -1753,18 +1866,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "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/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" }, "node_modules/totalist": { "version": "3.0.1", @@ -1775,11 +1880,6 @@ "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", @@ -1796,12 +1896,8 @@ "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==" - }, - "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==" + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true }, "node_modules/vite": { "version": "6.3.5", @@ -1891,6 +1987,53 @@ } } }, + "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 28404fd..da5d11b 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 '' ; node scripts/copy-cesium.js", + "prepare": "svelte-kit sync || echo ''", "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,16 +28,15 @@ "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" - }, - "overrides": { - "@zip.js/zip.js": "2.7.73" + "luxon": "^3.6.1", + "maplibre-gl": "^4.0.0", + "svelte5-chartjs": "^1.0.0" } } diff --git a/playwright.config.ts b/playwright.config.ts index 73358a1..8380a55 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,8 +15,7 @@ import { defineConfig, devices } from '@playwright/test'; * npm run test:e2e:ui # Playwright UI mode */ export default defineConfig({ - // Both tests/e2e (browser) and tests/unit (pure geometry, runs in Node). - testDir: './tests', + testDir: './tests/e2e', 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 deleted file mode 100644 index 378c5e5..0000000 --- a/scripts/copy-cesium.js +++ /dev/null @@ -1,24 +0,0 @@ -// 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 a34b57b..acdfe23 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 (Cesium, + * panel-container geometry, and overrides for third-party libs (MapLibre, * Bootstrap). Feature-specific styles live in the relevant Svelte component. */ @@ -93,6 +93,27 @@ 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 d2248ca..77a5ff5 100644 --- a/src/app.html +++ b/src/app.html @@ -4,11 +4,6 @@ - - %sveltekit.head% diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8b76940..82b37f9 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -3,4 +3,5 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry'; export { pointsApi } from './points'; export { profilesApi } from './profiles'; export { scenariosApi } from './scenarios'; -export { predictionsApi, buildLaunchDateTime } from './predictions'; +export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions'; +export { windApi, type WindFieldParams } from './wind'; diff --git a/src/lib/api/predictions.ts b/src/lib/api/predictions.ts index a4f9969..566577c 100644 --- a/src/lib/api/predictions.ts +++ b/src/lib/api/predictions.ts @@ -1,6 +1,18 @@ import { api } from './client'; import type { FlightParameters, RawPrediction } from '$domain'; +/** + * GFS datasets are published every 6 hours with a ~6 hour processing lag. + * Round down to the most recent available slot. + */ +export function getLatestDataset(now: Date = new Date()): string { + // const rounded = new Date(now); + // rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0); + // rounded.setUTCHours(rounded.getUTCHours() - 6); + // return rounded.toISOString(); + return "2025-04-06T00:00:00Z"; +} + export function buildLaunchDateTime(date: string, time: string): string { const fullTime = time.split(':').length === 2 ? `${time}:00` : time; return new Date(`${date}T${fullTime}Z`).toISOString(); @@ -12,17 +24,11 @@ export interface PredictionResponse { export const predictionsApi = { run: (params: FlightParameters, launchDateTime: string) => { - // `dataset` carries only what the operator actually chose. It used to fall - // back to a client-side guess at which GFS run the server holds — and the - // guess had degenerated into a hardcoded 2025-04-06, over a year stale. The - // client cannot know which runs are stored; the predictor refuses one it - // does not have, so an unset value must stay unset and let the server pick. - const { dataset, ...rest } = params; - const payload = { - ...rest, - ...(dataset ? { dataset } : {}), + const payload: FlightParameters & { launch_datetime: string } = { + ...params, + dataset: params.dataset || getLatestDataset(), launch_datetime: launchDateTime, - } as FlightParameters & { launch_datetime: string }; + }; if (payload.start_point === -1) delete payload.start_point; return api.post('/predictions/', payload); }, diff --git a/src/lib/api/wind.ts b/src/lib/api/wind.ts new file mode 100644 index 0000000..ea2d29f --- /dev/null +++ b/src/lib/api/wind.ts @@ -0,0 +1,58 @@ +/** + * 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 ca13cfe..bac560a 100644 --- a/src/lib/domain/boundingBox.ts +++ b/src/lib/domain/boundingBox.ts @@ -1,134 +1,26 @@ import type { LatLngTuple } from './geo'; /** - * 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. + * 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. */ export interface BoundingBox { - /** - * 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; + south: number; + west: number; + north: number; + east: number; } /** Default clearance (km) between the box edge and the nearest trajectory point. */ export const DEFAULT_BBOX_MARGIN_KM = 5; -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]; -} +// 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; /** - * 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`. + * Compute the bounding box of a flight path, expanded outward by `marginKm`. * Returns null for an empty path (callers treat that as "nothing to draw"). */ export function computeBoundingBox( @@ -137,71 +29,52 @@ export function computeBoundingBox( ): BoundingBox | null { if (path.length === 0) return null; - const points = path.map(toVec); + 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 margin = Math.max(0, marginKm); - - // 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)); + 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; return { - 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, + south: south - dLat, + north: north + dLat, + west: west - dLng, + east: east + dLng, }; } -/** 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. - */ +/** Closed ring (corners + repeated start) for drawing the box as a polyline. */ export function boundingBoxRing(box: BoundingBox): LatLngTuple[] { - 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; + return [ + [box.south, box.west], + [box.north, box.west], + [box.north, box.east], + [box.south, box.east], + [box.south, box.west], + ]; } /** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */ export function formatBoundingBox(box: BoundingBox): string { - return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n'); + 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'); } diff --git a/src/lib/domain/export.ts b/src/lib/domain/export.ts deleted file mode 100644 index 7ad7a11..0000000 --- a/src/lib/domain/export.ts +++ /dev/null @@ -1,148 +0,0 @@ -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 ec82787..f859c2c 100644 --- a/src/lib/domain/geo.ts +++ b/src/lib/domain/geo.ts @@ -1,9 +1,8 @@ /** * Geographic primitives used by map layers and predictions. * - * 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. + * LngLat convention matches MapLibre (longitude first) for on-map work; + * LatLng is preserved for API payloads and legacy Leaflet-era code paths. */ export interface LatLng { @@ -12,45 +11,6 @@ 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 4a24599..d3705ad 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 new file mode 100644 index 0000000..46455bc --- /dev/null +++ b/src/lib/domain/wind.ts @@ -0,0 +1,214 @@ +/** + * 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 deleted file mode 100644 index d2f5fbd..0000000 --- a/src/lib/features/mapchrome/MapChrome.svelte +++ /dev/null @@ -1,77 +0,0 @@ - diff --git a/src/lib/features/mapchrome/index.ts b/src/lib/features/mapchrome/index.ts deleted file mode 100644 index 7cc8377..0000000 --- a/src/lib/features/mapchrome/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as MapChrome } from './MapChrome.svelte'; diff --git a/src/lib/features/prediction/ControlPanel.svelte b/src/lib/features/prediction/ControlPanel.svelte index 4e8b363..9d17c17 100644 --- a/src/lib/features/prediction/ControlPanel.svelte +++ b/src/lib/features/prediction/ControlPanel.svelte @@ -27,10 +27,7 @@ import { pointsApi } from '$api'; import { DEFAULT_FLIGHT_PARAMETERS, - MAX_LAUNCH_LATITUDE, PROFILE_IDENTIFIERS, - clampLaunchLatitude, - wrapLongitude, toFixedNumber, type FlightParameters, type ProfileIdentifier, @@ -78,17 +75,7 @@ function patchActive(patch: Partial) { if (!active) return; - // 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 }); + workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch }); } function handlePointSelection(newPointId: number | null) { @@ -238,8 +225,6 @@ patchActive({ diff --git a/src/lib/features/prediction/ScenarioPanel.svelte b/src/lib/features/prediction/ScenarioPanel.svelte index bcec060..05d9d5d 100644 --- a/src/lib/features/prediction/ScenarioPanel.svelte +++ b/src/lib/features/prediction/ScenarioPanel.svelte @@ -10,15 +10,7 @@ } from '@sveltestrap/sveltestrap'; import { CollapsibleCard, SelectSearchable, addToast } from '$ui'; import { scenariosApi } from '$api'; - import { - EXPORT_FORMATS, - PREDICTION_MODES, - exportFilename, - exportMimeType, - serializePrediction, - type ExportFormat, - type SavedScenario, - } from '$domain'; + import { PREDICTION_MODES, type SavedScenario } from '$domain'; import { workspacesStore, getActiveWorkspace } from '$features/workspaces'; import { t } from '$i18n'; import { scenariosStore } from './pointsStore'; @@ -26,32 +18,8 @@ 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); @@ -204,12 +172,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 e78744c..1deb343 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 } from './store'; +export type { AppSettings, MapSettings, UnitsSettings, WindSettings } 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 8c6e4d2..22265ef 100644 --- a/src/lib/features/settings/schema.ts +++ b/src/lib/features/settings/schema.ts @@ -61,12 +61,10 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [ path: 'map.baseLayer', labelKey: 'settings.baseLayer', options: [ - { value: 'osm', labelKey: 'settings.baseLayerOsm' }, - { value: 'satellite', labelKey: 'settings.baseLayerSatellite' }, - { value: 'polar', labelKey: 'settings.baseLayerPolar' }, + { value: 'osm', labelKey: 'settings.baseLayer' }, + { value: 'satellite', labelKey: 'settings.baseLayer' }, ], }, - { kind: 'boolean', path: 'map.graticule', labelKey: 'settings.graticule' }, { kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' }, { kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' }, ], @@ -85,4 +83,90 @@ 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 44cdad1..c13a866 100644 --- a/src/lib/features/settings/store.ts +++ b/src/lib/features/settings/store.ts @@ -1,17 +1,13 @@ import { persisted } from '$state'; import type { Locale } from '$i18n'; -import type { BaseLayerId } from '$map'; +import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain'; + +export type { WindSettings }; export interface MapSettings { - baseLayer: BaseLayerId; + baseLayer: 'osm' | 'satellite'; 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 { @@ -22,12 +18,14 @@ 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, graticule: true }, + map: { baseLayer: 'osm', showScale: true, showNavigation: 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 new file mode 100644 index 0000000..3d47d44 --- /dev/null +++ b/src/lib/features/wind/ParticleField.ts @@ -0,0 +1,335 @@ +/** + * 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 new file mode 100644 index 0000000..1e3c15a --- /dev/null +++ b/src/lib/features/wind/WindRenderer.svelte @@ -0,0 +1,332 @@ + + +{#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 new file mode 100644 index 0000000..b70b760 --- /dev/null +++ b/src/lib/features/wind/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..5e34494 --- /dev/null +++ b/src/lib/features/wind/store.ts @@ -0,0 +1,61 @@ +/** + * 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 113e15d..1f2d333 100644 --- a/src/lib/features/workspaces/WorkspaceRenderer.svelte +++ b/src/lib/features/workspaces/WorkspaceRenderer.svelte @@ -73,15 +73,6 @@ 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 aebbca5..eed4ccb 100644 --- a/src/lib/features/workspaces/WorkspacesPanel.svelte +++ b/src/lib/features/workspaces/WorkspacesPanel.svelte @@ -199,15 +199,6 @@ {#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)} -