mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into upstream/perimeters-stale
This commit is contained in:
145 files changed
+17257
-457
No files matched your search
@@ -38,6 +38,7 @@ How to read this:
|
|||||||
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
||||||
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
||||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||||
|
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||||
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products
|
||||||
|
(Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran
|
||||||
|
and Yemen products and the newest incident drop) from
|
||||||
|
github.com/s2underground/GhostMaps, with one chip per product and report
|
||||||
|
cards on click. `/api/ghostmaps` watches the repository's branch head every
|
||||||
|
15 s and pushes each new commit to open pages, which reload only the
|
||||||
|
products that changed (Black Pine Defense).
|
||||||
|
- Clear a temporary GhostMaps upstream error and notify open pages when the
|
||||||
|
repository becomes reachable again, including when no new edition was published.
|
||||||
|
- Release packed-image resources when a load is cancelled or fails, respect
|
||||||
|
injected or omitted GhostMaps sources, and wait for background cache warm-up
|
||||||
|
before removing provider test fixtures.
|
||||||
|
- Include Simplified Chinese translations and keep the combined voice prompt
|
||||||
|
within its existing size limit while retaining all spoken aliases.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Add a shared Voice Preview and Settings panel with browser-local preferences,
|
||||||
|
inactivity controls and accessible floating provider controls on compact layouts;
|
||||||
|
recover inactivity after confirmed audio-less replies, release retired audio
|
||||||
|
observers and keep cost warnings visible outside Settings
|
||||||
|
(manjunath22466, #1299).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- Add a Scope checkbox and extend feathering to 200%, retaining scope settings
|
||||||
|
across HUD layouts, share links and scenes. First run opens with Scope ON at
|
||||||
|
100% feather; explicit settings and historical links retain their values. Give
|
||||||
|
Tactical, Operator and Minimal text black backplates only when Scope is OFF,
|
||||||
|
including vertical metadata, without changing the original HUD text color, glow or
|
||||||
|
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410).
|
||||||
|
Keep title and tagline positions stable when toggling Scope, include the eye
|
||||||
|
in the title backing, and brighten the two secondary labels only with Scope OFF.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Restore a share link opened in an already-open tab. Pasting a link into the
|
||||||
|
address bar, following one, or using Back/Forward now moves the camera and
|
||||||
|
restores the style, map, panels and layers instead of changing only the URL
|
||||||
|
(samehkhamis, #1416).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Make Clear All clear the saved and shared layer selection. Layers that a
|
||||||
|
context mode had already switched off no longer stay in the share link and
|
||||||
|
come back on reload (samehkhamis, #1417).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Share links are now short: the share button makes `/share/<code>` links
|
||||||
|
that open on any copy of the app, follow the aircraft, military aircraft or
|
||||||
|
satellite you were following, and fall back to its last-known position with
|
||||||
|
a notice if it isn't reporting. A mistyped link is refused instead of
|
||||||
|
opening a different view. Long `#v=2` links keep working (samehkhamis, #1418).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Hosts can read share links on their server: the share-link codec and the
|
||||||
|
`/share/<code>` address helpers are now package exports,
|
||||||
|
`@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path`
|
||||||
|
(samehkhamis, #1419).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Keep a location search that found nothing, or failed, in the field so it can
|
||||||
|
be corrected: the query stays, focused and selected, and a persistent
|
||||||
|
announced message under the search row says what to do next, instead of a
|
||||||
|
toast followed by a cleared and closed field (samehkhamis, #1420).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- The visual styles and their presets mark their labels with the catalog-free
|
||||||
|
`msg()`, so the share-link codec, and any server that loads it, no longer
|
||||||
|
bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421).
|
||||||
@@ -598,6 +598,10 @@ state subscriptions retain their existing event contract.
|
|||||||
tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
|
tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
|
||||||
- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
|
- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
|
||||||
status notices and gesture/timer cleanup.
|
status notices and gesture/timer cleanup.
|
||||||
|
- `share/view-codec` is the share-link codec (`decodeShareView`,
|
||||||
|
`encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can
|
||||||
|
read a `/share/<code>` link, for example to describe it in link previews.
|
||||||
|
`share/link-path` reads and builds the `/share/<code>` address itself.
|
||||||
- `ui/visual-settings` owns style preferences, detection overrides, display inputs
|
- `ui/visual-settings` owns style preferences, detection overrides, display inputs
|
||||||
and IR cleanup. Its engine services and panel operations are explicit.
|
and IR cleanup. Its engine services and panel operations are explicit.
|
||||||
- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
|
- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
|
||||||
|
|||||||
+87
-12
@@ -196,6 +196,23 @@ Regression coverage lives in `src/tools/area.test.mjs`,
|
|||||||
`src/tools/queries/environment.test.mjs` and
|
`src/tools/queries/environment.test.mjs` and
|
||||||
`src/layers/perimeters/cards.test.mjs`.
|
`src/layers/perimeters/cards.test.mjs`.
|
||||||
|
|
||||||
|
## Scope display controls
|
||||||
|
|
||||||
|
Display's native Scope checkbox defaults ON with feather at 100%, adjustable
|
||||||
|
from 0–200%. OFF disables the slider while retaining its value. HUD layout
|
||||||
|
changes preserve both settings; share links and scenes restore them. The
|
||||||
|
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
|
||||||
|
|
||||||
|
With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates
|
||||||
|
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
|
||||||
|
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
|
||||||
|
and Active Style label with the shared secondary text color. The title plate
|
||||||
|
covers the eye and title without moving the title or tagline. Scope ON retains
|
||||||
|
the original HUD presentation; Cyber retains its existing skin.
|
||||||
|
|
||||||
|
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
|
||||||
|
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
|
||||||
|
|
||||||
## Cyber HUD — September 30, 2026
|
## Cyber HUD — September 30, 2026
|
||||||
|
|
||||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||||
@@ -554,6 +571,36 @@ replacement session; audio meters release failed initialization and reject revok
|
|||||||
frames. Delayed action results and post-capture continuations cannot resume a
|
frames. Delayed action results and post-capture continuations cannot resume a
|
||||||
stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md).
|
stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md).
|
||||||
|
|
||||||
|
The opt-in voice panel shares Preview and Voice Settings, with browser-local
|
||||||
|
Preview and inactivity preferences. Voice-requested timeout changes apply directly
|
||||||
|
without a confirmation question; results distinguish a live change from a saved
|
||||||
|
browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60,
|
||||||
|
or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit
|
||||||
|
Cockpit before opening the requested voice UI. `control_cockpit` also accepts
|
||||||
|
`voicePanel: "preview" | "settings"` for an explicit exit-and-show request.
|
||||||
|
Newer manual choices, Stop and provider replacement prevent an older operation
|
||||||
|
from revealing its UI. Ambiguous requests to open settings ask whether the user
|
||||||
|
means Voice Settings or Provider Settings before opening either panel. Named
|
||||||
|
Voice Settings applies directly; Provider Settings opens the existing local
|
||||||
|
provider dialog when available. Visual Presets targets the visual controls tray,
|
||||||
|
while Scenes targets saved scenes. Selecting Preview does not enable a disabled
|
||||||
|
Preview preference. Unverified speech captions use the localized Recent
|
||||||
|
speech label; ordinary user captions retain YOU. Caption attribution does not
|
||||||
|
control tool dispatch.
|
||||||
|
|
||||||
|
Voice inactivity waits for local input, output and actions to become quiet.
|
||||||
|
A matching OpenAI response that explicitly completes without audio can restore
|
||||||
|
idle eligibility; completion of an audio response does not prove playback has
|
||||||
|
drained. Missing or ambiguous output evidence remains conservative. Gemini
|
||||||
|
retires its per-audio observer wrappers on close, replacement or session end,
|
||||||
|
so repeated Stop/Start does not retain old audio instances until the panel closes.
|
||||||
|
The panel keeps the model tier and regular cost estimate in Voice Settings;
|
||||||
|
warning, capped and unknown cost states also appear in the dock heading when
|
||||||
|
Settings is closed. The existing hard cost cap still stops the session and
|
||||||
|
announces the reason.
|
||||||
|
|
||||||
|
Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority.
|
||||||
|
|
||||||
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
|
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
|
||||||
after the camera moves. An explicit new scope replaces that basis. Provider
|
after the camera moves. An explicit new scope replaces that basis. Provider
|
||||||
instructions require an analyst query for requested lists and use the requested
|
instructions require an analyst query for requested lists and use the requested
|
||||||
@@ -3776,6 +3823,7 @@ its criteria cannot be silently ignored.
|
|||||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||||
|
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
|
|
||||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||||
@@ -5124,7 +5172,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
- **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded.
|
- **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded.
|
||||||
- **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction.
|
- **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction.
|
||||||
- **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration.
|
- **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration.
|
||||||
- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`).
|
- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): hosts without the voice panel keep the `STD`/`MINI` toggle and running session-cost readout (`~$0.42`) in the heading. Panel hosts place these controls in Voice Settings and mirror warning, capped or unknown cost states in the heading.
|
||||||
- **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`.
|
- **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`.
|
||||||
- **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker.
|
- **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker.
|
||||||
- **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun.
|
- **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun.
|
||||||
@@ -5220,6 +5268,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P
|
|||||||
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
|
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
|
||||||
are omitted rather than framing the wrong part of the globe.
|
are omitted rather than framing the wrong part of the globe.
|
||||||
|
|
||||||
|
A location search that finds nothing, or fails, leaves the field open with the
|
||||||
|
query still in it, focused and selected, so it can be corrected and resubmitted
|
||||||
|
without reopening search. The miss is shown under the field as a persistent
|
||||||
|
`role="alert"` message ("Location not found." or "Search failed." with what to
|
||||||
|
do next) referenced by the input's `aria-describedby`, with `aria-invalid` set;
|
||||||
|
it is not a toast. Typing, a new search, or closing the emptied field clears it.
|
||||||
|
A search that arrives still clears and closes the field.
|
||||||
|
|
||||||
For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds
|
For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds
|
||||||
before computing their midpoint, then wraps that midpoint back into ±180 degrees.
|
before computing their midpoint, then wraps that midpoint back into ±180 degrees.
|
||||||
Malformed viewport bounds add no proximity bias. Nominatim omits a crossing
|
Malformed viewport bounds add no proximity bias. Nominatim omits a crossing
|
||||||
@@ -5299,6 +5355,28 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
||||||
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
||||||
|
|
||||||
|
### S2 GhostMaps (October 2026)
|
||||||
|
|
||||||
|
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
||||||
|
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
|
||||||
|
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
|
||||||
|
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
|
||||||
|
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||||
|
- After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent.
|
||||||
|
- The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once.
|
||||||
|
- The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe.
|
||||||
|
- The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop.
|
||||||
|
- **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page.
|
||||||
|
- **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed.
|
||||||
|
- **Voice and locales.** S2 can be toggled through its registered id and aliases. The model already sees the S2 GhostMaps brand in the id, and compact alias-hint separators keep the complete voice prompt within its existing 52,000-character limit; accepted aliases are unchanged. The row and reports are translated in Spanish, Russian and Simplified Chinese.
|
||||||
|
- **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product.
|
||||||
|
- Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves.
|
||||||
|
- Lines and polygon outlines are ground-clamped polylines.
|
||||||
|
- Styles, StyleMaps and inline styles are honoured. Icons come only from images packed in the same KMZ and at most 512 KB. Anything else uses a per-product round marker.
|
||||||
|
- Network links, overlays, models and remote hrefs are never followed. Hidden placemarks are skipped. Bounds are 20,000 placemarks and 250,000 positions per product.
|
||||||
|
- **Reports.** Clicking a marker opens a card with the product, folder, edition date and the placemark's report fields. `src/layers/ghostMaps/records.js` reads ArcGIS popup tables as text only: `<Null>` and internal fields are dropped and values are capped. Description markup is never inserted into the page. The first `http(s)` research link opens from the card. The card is `stateless`, so it comes and goes with the click, as CCTV cards do. It is also `horizonCull`ed, so it hides when its marker turns behind the planet. Picks are registered under `s2-ghostmaps`.
|
||||||
|
- **Validation.** Unit tests: `src/layers/ghostMaps/*.test.mjs` cover editions, the catalog, ref parsing, description tables and wrapping; `server/providers/ghostMaps.test.mjs` covers commit detection, events, pinned downloads, cache pruning, restart and routing. `node scripts/qa-ghostmaps.mjs --url http://localhost:4173` covers the live load, the open stream, picks and cards (asserted on the overlay host's diagnostics, since the `desynchronized` overlay canvas can read back a stale buffer headless), far-side culling, a synthetic push, chip persistence, disable and phone width.
|
||||||
|
|
||||||
### Import Your Own Files (October 2026)
|
### Import Your Own Files (October 2026)
|
||||||
|
|
||||||
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||||
@@ -5545,10 +5623,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a
|
|||||||
separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
|
separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
|
||||||
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D).
|
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D).
|
||||||
|
|
||||||
**The 2026-08-24 defaults** (superseding the interim 08-23 values of 8%/3%):
|
**Current defaults** (scope feather revised to 100% on 2026-10-25;
|
||||||
|
detection and outside opacity retain the 2026-08-24 defaults):
|
||||||
the first-run look is Detection DENSE
|
the first-run look is Detection DENSE
|
||||||
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`,
|
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather
|
||||||
and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
|
`100%`, and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
|
||||||
brackets keep their approved brightness exactly at the new OUTSIDE default. Two
|
brackets keep their approved brightness exactly at the new OUTSIDE default. Two
|
||||||
terms that must never be conflated: scope FEATHER softens the black scope-mask
|
terms that must never be conflated: scope FEATHER softens the black scope-mask
|
||||||
edge; detection FADE is the label/card fading band around the keyhole. The
|
edge; detection FADE is the label/card fading band around the keyhole. The
|
||||||
@@ -5618,14 +5697,10 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
|
calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
|
||||||
default, and the mapping follows bracket brightness rather than slider
|
default, and the mapping follows bracket brightness rather than slider
|
||||||
position. The `ko` PARSE fallback stays at `5`.
|
position. The `ko` PARSE fallback stays at `5`.
|
||||||
- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day,
|
- **Scope: `ON`, feather: `100%`**. `SCOPE_FEATHER_RATIO_DEFAULT` in
|
||||||
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
|
`src/scopeMask.js` matches the slider, readout and share generator. The range
|
||||||
mirrored by `#scope-feather-slider`'s markup value AND readout and
|
is 0–200%; explicit 0% is a hard edge. Missing-`scf` links retain the historical
|
||||||
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans
|
35% parse fallback.
|
||||||
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
|
|
||||||
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
|
|
||||||
at `35`: a link predating `scf` was authored when 35 was what its author saw,
|
|
||||||
and restoring the author's view is what a share link is for.
|
|
||||||
- **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section
|
- **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section
|
||||||
above and the DISPLAY-rail entry below (was `OFF`).
|
above and the DISPLAY-rail entry below (was `OFF`).
|
||||||
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
|
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# Share-link format
|
||||||
|
|
||||||
|
A share link restores a view: where the camera is, what it follows, the visual
|
||||||
|
style, data layers and panel layout. There are two forms.
|
||||||
|
|
||||||
|
- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by
|
||||||
|
`src/sharelink.js`. The address bar follows the live view in this form, and
|
||||||
|
links already in the wild keep working; the long form is never removed.
|
||||||
|
- **Short form** — `<app>/share/<code>`, where `<app>` is wherever the app is
|
||||||
|
served (an origin's root, or a base path) and `<code>` is encoded and
|
||||||
|
decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and
|
||||||
|
needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec`
|
||||||
|
(a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on
|
||||||
|
any copy of the app. `src/share/shareLinkPath.js` reads and builds the
|
||||||
|
address, and `src/share/legacyShareState.js` converts between the long
|
||||||
|
form's parsed state and the code's state.
|
||||||
|
|
||||||
|
The same view in both forms (a real session's link):
|
||||||
|
|
||||||
|
| Form | Characters |
|
||||||
|
| ----- | ----------------- |
|
||||||
|
| Long | 302 (the hash) |
|
||||||
|
| Short | 39 (the `<code>`) |
|
||||||
|
|
||||||
|
For example, a camera-only place view is a 24-character code (31 with its
|
||||||
|
created-at time).
|
||||||
|
Following an aircraft is 42–50: 42 when the link is made from the app's own
|
||||||
|
follow camera with the fix taken as the link is made, 44 with a fix four
|
||||||
|
seconds older, and 50 after orbiting to another heading and pitch. A view in
|
||||||
|
any other style that keeps the style's own look adds only the style (two
|
||||||
|
bytes): the 42-character followed aircraft in Noir is 44.
|
||||||
|
|
||||||
|
## Opening a short link
|
||||||
|
|
||||||
|
At start-up the app reads `<code>` from an address whose path ends in
|
||||||
|
`share/<code>` (a host can pass the code instead, as `initialShareCode` to
|
||||||
|
`createApplicationControls`). A code that decodes runs the same restore as a
|
||||||
|
long link; once the app next writes the address, `/share/<code>` is replaced
|
||||||
|
by the app's own address and the long form, without a new history entry, so
|
||||||
|
a reload follows the live view. A code that is rejected restores nothing: the
|
||||||
|
app starts as usual and says the link can't be opened.
|
||||||
|
|
||||||
|
A thing view turns on its entity's layer and follows the entity by id, using
|
||||||
|
the same restore as a long link's followed flight or satellite (and its
|
||||||
|
expiry windows); a vessel is selected once the vessel feed has it. Opening is
|
||||||
|
best effort: until the entity is found, and if it is not found in time, the
|
||||||
|
camera is where the link was made, looking at the entity's last-known fix,
|
||||||
|
and the app says that the entity isn't reporting and when the fix was taken.
|
||||||
|
The share button makes a thing view while the camera follows an aircraft,
|
||||||
|
military aircraft or satellite. A selected vessel is not followed (the camera
|
||||||
|
stays where the user puts it), so a link made then is a place view; vessel
|
||||||
|
thing views open, but the app does not make them yet.
|
||||||
|
|
||||||
|
## Payload
|
||||||
|
|
||||||
|
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
||||||
|
|
||||||
|
1. one **version byte**, currently `1`;
|
||||||
|
2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type`
|
||||||
|
followed by its value;
|
||||||
|
3. one **check byte**: the CRC-8 of everything before it.
|
||||||
|
|
||||||
|
| Wire type | Value |
|
||||||
|
| --------- | ----------------------------------------------------------- |
|
||||||
|
| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 |
|
||||||
|
| 1 fixed64 | 8 bytes (not used by v1; skipped) |
|
||||||
|
| 2 LEN | varint length, then that many bytes (text or a sub-message) |
|
||||||
|
| 5 fixed32 | 4 bytes (not used by v1; skipped) |
|
||||||
|
|
||||||
|
Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable
|
||||||
|
ASCII. Fields are written in ascending order; the decoder accepts any order.
|
||||||
|
|
||||||
|
A field whose value equals its default is **omitted**. The decoder fills every
|
||||||
|
omitted field from the same defaults table the encoder reads (for a
|
||||||
|
style-dependent field, the decoded style's defaults), so an empty
|
||||||
|
payload (code `AQc`: the version byte and its check byte) is the all-default
|
||||||
|
view.
|
||||||
|
|
||||||
|
### Check byte
|
||||||
|
|
||||||
|
The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial
|
||||||
|
value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the
|
||||||
|
ASCII digits `123456789` is `0xF4`). A decoder that computes a different value
|
||||||
|
rejects the code (`checksum`) before reading any field, so a mistyped code
|
||||||
|
never opens a different view. A degree-8 CRC catches every error burst of up
|
||||||
|
to 8 bits; one base64url character is 6 bits, so **every single-character
|
||||||
|
typo is rejected** (a test tries every substitution at every position). Two
|
||||||
|
swapped neighbours, a dropped or an extra character are caught with a
|
||||||
|
probability of about 255 in 256 (many of them already fail as base64url).
|
||||||
|
|
||||||
|
## Field registry (top level)
|
||||||
|
|
||||||
|
| # | State key | Type | Default | Encoding |
|
||||||
|
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
|
||||||
|
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
|
||||||
|
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||||
|
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||||
|
| 4 | `alt` | sint | 800 | whole metres |
|
||||||
|
| 5 | `heading` | angle | 0 | whole degrees, see angles |
|
||||||
|
| 6 | `pitch` | sint | −35 | whole degrees |
|
||||||
|
| 7 | `roll` | angle | 0 | whole degrees |
|
||||||
|
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||||
|
| 9 | booleans | flags | 0 | bit set when the value differs from default |
|
||||||
|
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
|
||||||
|
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
|
||||||
|
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||||
|
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||||
|
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
|
||||||
|
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||||
|
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
|
||||||
|
| 17 | `bloomIntensity` | uint | style | |
|
||||||
|
| 18 | `bloomVersion` | uint | 2 | |
|
||||||
|
| 19 | `sharpenIntensity` | uint | style | |
|
||||||
|
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
|
||||||
|
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
|
||||||
|
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
|
||||||
|
| 23 | `detectionFadePct` | uint | style | 0–40 |
|
||||||
|
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
|
||||||
|
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||||
|
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||||
|
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||||
|
|
||||||
|
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||||
|
so v1 never writes it; a later view kind writes its code.
|
||||||
|
|
||||||
|
Enum codes are the list position (0, 1, 2…). A name not in the list is written
|
||||||
|
as LEN text in the same field, so the app can add a style or map before this
|
||||||
|
table learns its code. A new style's settings are encoded against its own
|
||||||
|
defaults, while a decoder that does not know the style uses Normal's; adding a
|
||||||
|
style also extends the pinned style-defaults table.
|
||||||
|
|
||||||
|
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||||
|
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||||
|
|
||||||
|
### Style defaults
|
||||||
|
|
||||||
|
A default marked **style** is what the view's style applies when it is
|
||||||
|
selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's
|
||||||
|
first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`),
|
||||||
|
with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's
|
||||||
|
shader parameters at the preset's values, else the shader's own. The encoder
|
||||||
|
compares against the defaults of the view's style, and the decoder fills
|
||||||
|
omitted settings from the defaults of the style it decoded (an unknown style
|
||||||
|
uses Normal's). So a view that keeps its style's look costs its camera and
|
||||||
|
style alone. (A recipient-side preference the app remembers, such as the
|
||||||
|
detection allocation, is not a style default: a sender who changed it pays
|
||||||
|
for it.) In v1:
|
||||||
|
|
||||||
|
| Setting | Every style |
|
||||||
|
| ------------------------- | ------------- |
|
||||||
|
| bloom, intensity | off, 0 |
|
||||||
|
| sharpen, intensity | on, 49 |
|
||||||
|
| HUD visible, variant | on, tactical |
|
||||||
|
| detection mode, density | DENSE, 75 |
|
||||||
|
| allocation, fade, outside | ELASTIC, 7, 1 |
|
||||||
|
| celestial ring | off |
|
||||||
|
|
||||||
|
| Style | Shader parameters (field 27 defaults) |
|
||||||
|
| ------------ | --------------------------------------------------------------- |
|
||||||
|
| normal | none |
|
||||||
|
| retro | pixelation 1, distortion 0, instability 0.42 |
|
||||||
|
| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 |
|
||||||
|
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
|
||||||
|
| anime | saturation 1, edgeThick 0.5 |
|
||||||
|
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
|
||||||
|
| snow | density 0.6, wind 0.5 |
|
||||||
|
|
||||||
|
These are frozen like every other default: the codec reads them from the
|
||||||
|
app's tables today, and a test pins them, so if a table moves the old values
|
||||||
|
are frozen in the codec rather than changing what shared links mean.
|
||||||
|
|
||||||
|
### Precision
|
||||||
|
|
||||||
|
| Value | Stored as | Matches the long form |
|
||||||
|
| --------------------------- | ------------------------- | --------------------- |
|
||||||
|
| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` |
|
||||||
|
| Altitude, range | whole metres | `Math.round` |
|
||||||
|
| Heading, pitch, roll | whole degrees | `Math.round` |
|
||||||
|
| Intensities and percentages | integers | `Math.round` |
|
||||||
|
| Style parameters | hundredths after clamping | `sp` (× 100) |
|
||||||
|
| Created-at and observed-at | whole seconds | `at` |
|
||||||
|
|
||||||
|
Angles: heading and roll are normalized into [0, 360) — the long form can
|
||||||
|
write `roll=360` for a camera at 359.6°, which the compact form stores as 0 —
|
||||||
|
and written as the nearer signed turn, so 350° costs one byte (−10).
|
||||||
|
When a state is encoded, out-of-range input is clamped (latitude, pitch) or
|
||||||
|
wrapped (longitude). A payload that stores a latitude or longitude beyond
|
||||||
|
±90/±180 cannot come from the encoder and is rejected.
|
||||||
|
|
||||||
|
### Layers (fields 10 and 11)
|
||||||
|
|
||||||
|
A layer's number is derived from its permanent layer-state token
|
||||||
|
(`src/data/layerStateTokenReservations.json`): a one-character token is its
|
||||||
|
index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The
|
||||||
|
token ledger never reassigns a token, so layer numbers never change and new
|
||||||
|
layers need no change here.
|
||||||
|
|
||||||
|
Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set
|
||||||
|
when layer _n_ is on. Present-but-empty means "every layer off"; an absent
|
||||||
|
field leaves the recipient's layers alone (the long form's `l=` versus no `l`).
|
||||||
|
A thing view's entity layer is always on, so its bit is not written: in a
|
||||||
|
thing view, present-but-empty means "only the entity's layer", and an absent
|
||||||
|
field opens with only the entity's layer on (the app writes the field
|
||||||
|
whenever it has a layer set).
|
||||||
|
|
||||||
|
Field 11 repeats `(layer number, option key, value)` for each option that
|
||||||
|
differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The
|
||||||
|
option key is `option token index × 2 + kind`: kind 0 means the value is a
|
||||||
|
zigzag varint (an option whose long-form spelling is a canonical integer),
|
||||||
|
kind 1 means a varint length and ASCII text (the long-form spelling). Values
|
||||||
|
are decoded by the layer-state codec's own option decoders.
|
||||||
|
|
||||||
|
A followed aircraft, military aircraft or satellite is a thing view in the
|
||||||
|
compact form, not a tracking option: the encoder moves it out of the layer
|
||||||
|
options, and the converter moves it back for the long form.
|
||||||
|
|
||||||
|
### Panels (field 12)
|
||||||
|
|
||||||
|
Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where
|
||||||
|
collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that
|
||||||
|
differ from the frozen default layout are written, plus a collapsed-0 entry
|
||||||
|
for each panel the state has no opinion about (an older long-form link that
|
||||||
|
predates the panel); a panel with no entry takes its default. Values of 3 are
|
||||||
|
reserved: a decoder ignores that entry, as for collapsed 0.
|
||||||
|
|
||||||
|
| # | Panel | Long-form token | Default |
|
||||||
|
| --- | ---------------------- | --------------- | ------------------- |
|
||||||
|
| 0 | `control-panel` | `c` | collapsed, unpinned |
|
||||||
|
| 1 | `location-bar` | `l` | collapsed, unpinned |
|
||||||
|
| 2 | `data-panel` | `d` | collapsed |
|
||||||
|
| 3 | `cctv-panel` | `v` | collapsed |
|
||||||
|
| 4 | `recent-imagery-panel` | `i` | expanded |
|
||||||
|
| 5 | `street-level-panel` | `t` | collapsed |
|
||||||
|
| 6 | `radio-panel` | `r` | collapsed |
|
||||||
|
| 7 | `scene-panel` | `s` | collapsed |
|
||||||
|
| 8 | `global-context-panel` | `g` | collapsed |
|
||||||
|
| 9 | `pp-toggles` | `p` | collapsed |
|
||||||
|
| 10 | `param-slider-panel` | `m` | expanded |
|
||||||
|
|
||||||
|
### Style parameters (field 27)
|
||||||
|
|
||||||
|
Pairs of varints `(token index, value × 100)` for the parameters of the
|
||||||
|
view's style that differ from its defaults, using the long form's `sp` tokens
|
||||||
|
(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter
|
||||||
|
of the style: the stored ones over the style's defaults. A long link without
|
||||||
|
`sp` converts to the style's defaults.
|
||||||
|
|
||||||
|
## View kinds
|
||||||
|
|
||||||
|
A **place** view (field 13 absent) opens at its camera, fields 2–7.
|
||||||
|
|
||||||
|
A **thing** view (field 13 present) follows an entity. Field 13 is a
|
||||||
|
sub-message:
|
||||||
|
|
||||||
|
| # | Field | Encoding | Default |
|
||||||
|
| --- | ----------- | -------------------------------------------------------- | ------- |
|
||||||
|
| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | |
|
||||||
|
| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | |
|
||||||
|
| 3 | vessel | entity id: varint (MMSI) or LEN text | |
|
||||||
|
| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | |
|
||||||
|
| 5 | range | uint metres from the entity to the camera | 2000 |
|
||||||
|
| 6 | heading | angle, the camera's heading while following | 0 |
|
||||||
|
| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 |
|
||||||
|
| 8 | fix lat | coord, the entity's last-known latitude | none |
|
||||||
|
| 9 | fix lon | coord, its last-known longitude | none |
|
||||||
|
| 10 | fix alt | sint metres | 0 |
|
||||||
|
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
|
||||||
|
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
|
||||||
|
|
||||||
|
The entity kind is the field that carries the id (its code in
|
||||||
|
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
|
||||||
|
present (two is malformed). Entity ids are the ones the layers look up: an
|
||||||
|
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
||||||
|
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
||||||
|
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
||||||
|
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
||||||
|
|
||||||
|
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
|
||||||
|
the entity from `range` metres away, heading clockwise from north, in the
|
||||||
|
entity's local east-north-up frame. The defaults match the app's own follow
|
||||||
|
camera, which looks north at about −35°, so only its range is written.
|
||||||
|
|
||||||
|
Fields 8–12 are the last-known fix, the entity's position when the link was
|
||||||
|
made. Fields 8 and 9 are both written whenever there is a fix; their presence
|
||||||
|
marks it. Its time is the created-at time (field 8 at the top level) minus
|
||||||
|
field 11, or field 12 when the view has no created-at time.
|
||||||
|
|
||||||
|
**The camera of a thing view with a fix is derived, not stored**: fields 2–7
|
||||||
|
are not written, and the camera is where following the fix from the offset
|
||||||
|
puts it (`followCameraFor` in `src/share/follow.js`): the camera sits
|
||||||
|
`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up
|
||||||
|
of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m,
|
||||||
|
and looks back at the fix (its heading and pitch are those of that direction
|
||||||
|
at the camera, roll 0; at follow distances they equal h and p). A
|
||||||
|
decoder ignores fields 2–7 when the fix is present. A thing view without a
|
||||||
|
fix (one converted from a long link, which records neither offset nor fix)
|
||||||
|
writes its camera in fields 2–7 like a place view.
|
||||||
|
|
||||||
|
A decoder that meets a view kind or an entity kind it does not know (an id in
|
||||||
|
a thing field it skips) opens the view as a place at its camera — the derived
|
||||||
|
one when there is a fix — and reports `fallback`.
|
||||||
|
|
||||||
|
## Decoding rules
|
||||||
|
|
||||||
|
`decodeShareView(code)` takes the bare code and never throws. It returns
|
||||||
|
`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`.
|
||||||
|
|
||||||
|
Rejected (the whole link): input over 2048 characters, bad base64url
|
||||||
|
(padding, foreign characters, impossible length, stray bits), fewer than two
|
||||||
|
bytes, a check byte that does not match (`checksum`), an unknown version byte,
|
||||||
|
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
|
||||||
|
overlong varint, a LEN longer than what remains (at any level, including
|
||||||
|
inside the thing sub-message), a reserved wire type, field number 0, a known
|
||||||
|
field with the wrong wire type or repeated, two entity ids in one thing, a
|
||||||
|
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||||
|
longitude (camera or fix) beyond ±90/±180.
|
||||||
|
|
||||||
|
Tolerated and reported in `unknownFields`: unknown fields at any level
|
||||||
|
(skipped by wire type), unknown enum codes and names (the default is used),
|
||||||
|
unknown flag bits, layers, panel values and style parameters, and layer
|
||||||
|
options the layer-state codec rejects or that are filed under a layer other
|
||||||
|
than their owner. Detection mode and allocation names outside their lists
|
||||||
|
fall back to the default rather than reaching the long form's lenient
|
||||||
|
migration. The map stack and HUD variant are open: a name outside their lists
|
||||||
|
that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what
|
||||||
|
it means) and is not reported; only a name outside that grammar is. A
|
||||||
|
created-at time in the future is dropped, as the long form's `at` is.
|
||||||
|
|
||||||
|
## Changing the format
|
||||||
|
|
||||||
|
These rules keep every link that was ever shared working, in every version of
|
||||||
|
the app that can open it.
|
||||||
|
|
||||||
|
1. **Field numbers are permanent.** Never renumber, never reuse. A field that
|
||||||
|
is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`.
|
||||||
|
2. **A field's type and wire type are permanent.** To change one, add a new
|
||||||
|
field.
|
||||||
|
3. **Defaults are frozen meanings.** A default is what an omitted field meant
|
||||||
|
when links were written. If the app's default moves, leave the table alone:
|
||||||
|
the encoder will start writing the new value explicitly. Style defaults
|
||||||
|
are read from the app's style tables; when those move, freeze the old
|
||||||
|
values in the codec (see Style defaults). The same applies
|
||||||
|
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
|
||||||
|
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
|
||||||
|
numbers and style parameter tokens only grow.
|
||||||
|
5. **Add, don't change.** A new setting takes the next free field number
|
||||||
|
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new
|
||||||
|
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||||
|
its default.
|
||||||
|
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
||||||
|
id takes the next free thing field (13 onwards), so older apps skip it and
|
||||||
|
fall back to the camera. A thing that needs more than an id (for example a
|
||||||
|
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||||
|
fields the same way.
|
||||||
|
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
||||||
|
(a test fails until it has one).
|
||||||
|
8. **Bump the version byte only** for a change older decoders must refuse.
|
||||||
|
Decoders reject any version they do not know. The trailing CRC-8 check
|
||||||
|
byte belongs to every version.
|
||||||
|
9. **Derivations are frozen too.** `followCameraFor` and the follow-offset
|
||||||
|
defaults are part of the format: changing them moves the camera of every
|
||||||
|
thing link already shared.
|
||||||
|
|
||||||
|
Add a test for every change: a round trip, and a payload written with the new
|
||||||
|
field that the previous decoder still reads.
|
||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||||
names a glyph this list is missing, because a missing glyph does not draw
|
names a glyph this list is missing, because a missing glyph does not draw
|
||||||
a box — the ligature never forms and the element renders the literal word. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- gev:template scene-chrome -->
|
<!-- gev:template scene-chrome -->
|
||||||
|
|||||||
@@ -168,6 +168,7 @@
|
|||||||
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
||||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
"./layers/gnss": "./src/layers/gnss/index.js",
|
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||||
|
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||||
@@ -222,6 +223,8 @@
|
|||||||
"./data/lifecycle": "./src/data/lifecycle.js",
|
"./data/lifecycle": "./src/data/lifecycle.js",
|
||||||
"./ui/navigation": "./src/ui/navigationController.js",
|
"./ui/navigation": "./src/ui/navigationController.js",
|
||||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||||
|
"./share/view-codec": "./src/share/viewCodec.js",
|
||||||
|
"./share/link-path": "./src/share/shareLinkPath.js",
|
||||||
"./ui/visual-settings": "./src/ui/visualSettings.js",
|
"./ui/visual-settings": "./src/ui/visualSettings.js",
|
||||||
"./ui/panel-chrome": "./src/ui/panelChrome.js",
|
"./ui/panel-chrome": "./src/ui/panelChrome.js",
|
||||||
"./layers/flights/records": "./src/layers/flights/records.js",
|
"./layers/flights/records": "./src/layers/flights/records.js",
|
||||||
@@ -244,6 +247,7 @@
|
|||||||
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
||||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||||
|
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
||||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||||
|
|||||||
@@ -194,6 +194,7 @@
|
|||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualInput.test.mjs",
|
"src/ui/visualInput.test.mjs",
|
||||||
"scripts/qa-visual-input.mjs",
|
"scripts/qa-visual-input.mjs",
|
||||||
|
"scripts/qa-scope-hud.mjs",
|
||||||
"scripts/qa-segmented-controls.mjs",
|
"scripts/qa-segmented-controls.mjs",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/segmentedControl.test.mjs",
|
"src/ui/segmentedControl.test.mjs",
|
||||||
@@ -871,6 +872,18 @@
|
|||||||
"src/layers/gnss/source.test.mjs",
|
"src/layers/gnss/source.test.mjs",
|
||||||
"src/layers/gnss/ownership.test.mjs",
|
"src/layers/gnss/ownership.test.mjs",
|
||||||
"scripts/qa-gnss.mjs",
|
"scripts/qa-gnss.mjs",
|
||||||
|
"server/providers/ghostMaps.js",
|
||||||
|
"server/providers/ghostMaps.test.mjs",
|
||||||
|
"src/app/layers/ghostMaps.js",
|
||||||
|
"src/layers/ghostMaps/index.js",
|
||||||
|
"src/layers/ghostMaps/catalog.js",
|
||||||
|
"src/layers/ghostMaps/kml.js",
|
||||||
|
"src/layers/ghostMaps/records.js",
|
||||||
|
"src/layers/ghostMaps/source.js",
|
||||||
|
"src/layers/ghostMaps/catalog.test.mjs",
|
||||||
|
"src/layers/ghostMaps/records.test.mjs",
|
||||||
|
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||||
|
"scripts/qa-ghostmaps.mjs",
|
||||||
"src/layers/cyclones/index.js",
|
"src/layers/cyclones/index.js",
|
||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
@@ -1088,5 +1101,21 @@
|
|||||||
"src/dockerTrustedPeers.test.mjs",
|
"src/dockerTrustedPeers.test.mjs",
|
||||||
"src/cameraGlobeRotation.test.mjs",
|
"src/cameraGlobeRotation.test.mjs",
|
||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs"
|
"src/hudDms.test.mjs",
|
||||||
|
"src/voice/panel/preferences.test.mjs",
|
||||||
|
"src/voice/panel/host.test.mjs",
|
||||||
|
"src/voice/panel/manualPanel.test.mjs",
|
||||||
|
"src/voice/panel/nativeActivity.test.mjs",
|
||||||
|
"src/voice/panel/inactivity.test.mjs",
|
||||||
|
"src/voice/panel/actionObserver.test.mjs",
|
||||||
|
"src/voice/panel/testDom.mjs",
|
||||||
|
"src/voice/realtimeCostPanel.test.mjs",
|
||||||
|
"docs/SHARE-LINK-FORMAT.md",
|
||||||
|
"src/share/wire.test.mjs",
|
||||||
|
"src/share/viewCodec.test.mjs",
|
||||||
|
"src/share/legacyShareState.test.mjs",
|
||||||
|
"src/testSupport/shareViewFixtures.mjs",
|
||||||
|
"src/ui/shareLinks.test.mjs",
|
||||||
|
"src/ui/shareButton.test.mjs",
|
||||||
|
"src/share/shareLinkPath.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -463,11 +463,7 @@
|
|||||||
"src/styles/anime.js",
|
"src/styles/anime.js",
|
||||||
"src/styles/noir.js",
|
"src/styles/noir.js",
|
||||||
"src/styles/snow.js",
|
"src/styles/snow.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/marker.js"
|
||||||
"src/i18n/locale.js",
|
|
||||||
"src/i18n/locales/es.json",
|
|
||||||
"src/i18n/locales/ru.json",
|
|
||||||
"src/i18n/locales/zh-CN.json"
|
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -770,6 +766,9 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/actionButton.js",
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
|
"src/ui/shareFollow.js",
|
||||||
|
"src/share/follow.js",
|
||||||
|
"src/data/trackedCamera.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
"src/ui/shellFeedback.js",
|
"src/ui/shellFeedback.js",
|
||||||
@@ -805,7 +804,8 @@
|
|||||||
"src/ui/keyGuidance.js",
|
"src/ui/keyGuidance.js",
|
||||||
"src/ui/layerNames.js",
|
"src/ui/layerNames.js",
|
||||||
"src/ui/statusText.js",
|
"src/ui/statusText.js",
|
||||||
"src/ui/statusBadge.js"
|
"src/ui/statusBadge.js",
|
||||||
|
"src/scopeMaskPolicy.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1655,6 +1655,12 @@
|
|||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
|
"src/voice/panel/actionObserver.js",
|
||||||
|
"src/voice/panel/host.js",
|
||||||
|
"src/voice/panel/inactivity.js",
|
||||||
|
"src/voice/panel/manualPanel.js",
|
||||||
|
"src/voice/panel/nativeActivity.js",
|
||||||
|
"src/voice/panel/preferences.js",
|
||||||
"src/voice/session.js",
|
"src/voice/session.js",
|
||||||
"src/voice/sessionCommands.js",
|
"src/voice/sessionCommands.js",
|
||||||
"src/voice/realtimeSession.js",
|
"src/voice/realtimeSession.js",
|
||||||
@@ -2188,6 +2194,11 @@
|
|||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
|
"src/share/follow.js",
|
||||||
|
"src/share/legacyShareState.js",
|
||||||
|
"src/share/shareLinkPath.js",
|
||||||
|
"src/share/viewCodec.js",
|
||||||
|
"src/share/wire.js",
|
||||||
"src/sharelink.js",
|
"src/sharelink.js",
|
||||||
"src/sources/live/aircraft.js",
|
"src/sources/live/aircraft.js",
|
||||||
"src/sources/live/contract.js",
|
"src/sources/live/contract.js",
|
||||||
@@ -2280,6 +2291,7 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/actionButton.js",
|
"src/ui/actionButton.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
|
"src/ui/shareFollow.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
"src/ui/shellFeedback.js",
|
"src/ui/shellFeedback.js",
|
||||||
@@ -2292,6 +2304,12 @@
|
|||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
|
"src/voice/panel/actionObserver.js",
|
||||||
|
"src/voice/panel/host.js",
|
||||||
|
"src/voice/panel/inactivity.js",
|
||||||
|
"src/voice/panel/manualPanel.js",
|
||||||
|
"src/voice/panel/nativeActivity.js",
|
||||||
|
"src/voice/panel/preferences.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
"src/agent/worldPacket.js",
|
"src/agent/worldPacket.js",
|
||||||
@@ -2356,6 +2374,12 @@
|
|||||||
"src/layers/gnss/index.js",
|
"src/layers/gnss/index.js",
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
"src/layers/gnss/records.js",
|
"src/layers/gnss/records.js",
|
||||||
|
"src/app/layers/ghostMaps.js",
|
||||||
|
"src/layers/ghostMaps/index.js",
|
||||||
|
"src/layers/ghostMaps/catalog.js",
|
||||||
|
"src/layers/ghostMaps/records.js",
|
||||||
|
"src/layers/ghostMaps/kml.js",
|
||||||
|
"src/layers/ghostMaps/source.js",
|
||||||
"src/layers/cctv/sourcePolicy.js",
|
"src/layers/cctv/sourcePolicy.js",
|
||||||
"src/sources/reference.js",
|
"src/sources/reference.js",
|
||||||
"src/search/nominatim.js",
|
"src/search/nominatim.js",
|
||||||
@@ -2574,7 +2598,8 @@
|
|||||||
"src/voice/geminiAudio.js",
|
"src/voice/geminiAudio.js",
|
||||||
"src/voice/geminiCaptureWorklet.js",
|
"src/voice/geminiCaptureWorklet.js",
|
||||||
"src/ui/statusBadge.js",
|
"src/ui/statusBadge.js",
|
||||||
"src/sceneCommandRelease.js"
|
"src/sceneCommandRelease.js",
|
||||||
|
"src/scopeMaskPolicy.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -2990,6 +3015,12 @@
|
|||||||
"src/layers/gnss/index.js",
|
"src/layers/gnss/index.js",
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
"src/layers/gnss/records.js",
|
"src/layers/gnss/records.js",
|
||||||
|
"src/app/layers/ghostMaps.js",
|
||||||
|
"src/layers/ghostMaps/index.js",
|
||||||
|
"src/layers/ghostMaps/catalog.js",
|
||||||
|
"src/layers/ghostMaps/records.js",
|
||||||
|
"src/layers/ghostMaps/kml.js",
|
||||||
|
"src/layers/ghostMaps/source.js",
|
||||||
"src/layers/cctv/sourcePolicy.js",
|
"src/layers/cctv/sourcePolicy.js",
|
||||||
"src/search/nominatim.js",
|
"src/search/nominatim.js",
|
||||||
"src/nominatimGeocode.js",
|
"src/nominatimGeocode.js",
|
||||||
@@ -3142,7 +3173,10 @@
|
|||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/ui/statusText.js",
|
"src/ui/statusText.js",
|
||||||
"src/ui/layerNames.js",
|
"src/ui/layerNames.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||||
|
"src/data/geoImportImages.js",
|
||||||
|
"src/data/geoImportPins.js",
|
||||||
|
"src/data/geoFileImport.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3254,6 +3288,7 @@
|
|||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/loadingFeedback.js",
|
"src/loadingFeedback.js",
|
||||||
"src/navigationPolicy.js",
|
"src/navigationPolicy.js",
|
||||||
|
"src/share/follow.js",
|
||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/uiLifetime.js",
|
"src/ui/uiLifetime.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
@@ -3291,11 +3326,14 @@
|
|||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/ui/segmentedControl.js",
|
"src/ui/segmentedControl.js",
|
||||||
|
"src/i18n/marker.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/zh-CN.json"
|
"src/i18n/locales/zh-CN.json",
|
||||||
|
"src/scopeMaskPolicy.js",
|
||||||
|
"src/ui/displayControls.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -3846,6 +3884,68 @@
|
|||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
"share-view-codec": {
|
||||||
|
"exports": ["./share/view-codec"],
|
||||||
|
"modules": [
|
||||||
|
"src/share/viewCodec.js",
|
||||||
|
"src/share/follow.js",
|
||||||
|
"src/share/wire.js",
|
||||||
|
"src/ui/visualPresets.js",
|
||||||
|
"src/bloom.js",
|
||||||
|
"src/styles/anime.js",
|
||||||
|
"src/styles/noir.js",
|
||||||
|
"src/styles/retro.js",
|
||||||
|
"src/styles/snow.js",
|
||||||
|
"src/styles/surveillance.js",
|
||||||
|
"src/styles/thermal.js",
|
||||||
|
"src/view/index.js",
|
||||||
|
"src/data/layerState.js",
|
||||||
|
"src/data/layerStateTokenReservations.json",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
|
"src/data/detectionPolicy.js",
|
||||||
|
"src/scopeMaskPolicy.js",
|
||||||
|
"src/i18n/marker.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
|
"share-link-path": {
|
||||||
|
"exports": ["./share/link-path"],
|
||||||
|
"modules": ["src/share/shareLinkPath.js"],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
|
"ghostmaps": {
|
||||||
|
"exports": ["./layers/ghostmaps"],
|
||||||
|
"modules": [
|
||||||
|
"src/layers/ghostMaps/index.js",
|
||||||
|
"src/layers/ghostMaps/catalog.js",
|
||||||
|
"src/layers/ghostMaps/records.js",
|
||||||
|
"src/layers/ghostMaps/kml.js",
|
||||||
|
"src/layers/ghostMaps/source.js",
|
||||||
|
"src/sources/httpBody.js",
|
||||||
|
"src/data/geoFileImport.js",
|
||||||
|
"src/i18n/marker.js",
|
||||||
|
"src/i18n/index.js",
|
||||||
|
"src/i18n/locale.js",
|
||||||
|
"src/i18n/locales/es.json",
|
||||||
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/i18n/locales/zh-CN.json",
|
||||||
|
"src/data/geoImportImages.js",
|
||||||
|
"src/data/geoImportPins.js",
|
||||||
|
"src/layers/streetLevel/tileMath.js",
|
||||||
|
"src/layers/streetLevel/view.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
|
},
|
||||||
|
"ghostmaps-source": {
|
||||||
|
"exports": ["./layers/ghostmaps/source"],
|
||||||
|
"modules": [
|
||||||
|
"src/layers/ghostMaps/source.js",
|
||||||
|
"src/layers/ghostMaps/catalog.js",
|
||||||
|
"src/sources/httpBody.js",
|
||||||
|
"src/i18n/marker.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"perimeters-source": {
|
"perimeters-source": {
|
||||||
"exports": ["./layers/perimeters/source"],
|
"exports": ["./layers/perimeters/source"],
|
||||||
"modules": [
|
"modules": [
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer
|
||||||
|
* (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from
|
||||||
|
* github.com/s2underground/GhostMaps.
|
||||||
|
*
|
||||||
|
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||||
|
*
|
||||||
|
* (i) LIVE — enabling the layer loads the default products (Common
|
||||||
|
* Intelligence Picture and the 30-day kinetic pull) from the live
|
||||||
|
* repository through /api/ghostmaps, one data source per product,
|
||||||
|
* matching the proxy's catalog commit; the row shows one chip per
|
||||||
|
* catalog product with the defaults active, and the page holds an
|
||||||
|
* open event stream to /api/ghostmaps/events.
|
||||||
|
* (ii) PICK — a Common Intelligence Picture marker at the screen centre
|
||||||
|
* picks as this layer's entity; clicking it paints its report card
|
||||||
|
* and clicking empty ground clears it.
|
||||||
|
* (iii) PUSH (synthetic) — the event stream is replaced in the page and a
|
||||||
|
* catalog naming a new edition of one product is pushed: only that
|
||||||
|
* product reloads (a new data source replaces the old one, no
|
||||||
|
* duplicates), the other keeps its data source. A catalog without a
|
||||||
|
* product removes it.
|
||||||
|
* (iv) CHIPS — clicking an active product chip removes that product,
|
||||||
|
* clicking it again restores it; the choice survives a reload.
|
||||||
|
* (v) DISABLE — every GhostMaps data source leaves the viewer.
|
||||||
|
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips
|
||||||
|
* fit the panel width with no horizontal overflow.
|
||||||
|
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||||
|
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||||
|
* shown; back over North America the near-side pins show again.
|
||||||
|
*
|
||||||
|
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||||
|
*
|
||||||
|
* Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173
|
||||||
|
* Optional: --timeout-ms 180000 for a slow boot, --shots <dir>.
|
||||||
|
* Exits non-zero on any FAIL. Does not commit anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const getOpt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||||
|
const HEADFUL = argv.includes('--headful');
|
||||||
|
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000'));
|
||||||
|
const SHOTS_DIR = path.resolve(
|
||||||
|
getOpt('--shots', path.join(__dirname, '..', 'qa-shots')),
|
||||||
|
);
|
||||||
|
const LAYER_ID = 's2-ghostmaps';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function boot(page) {
|
||||||
|
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||||
|
{ timeout: TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await sleep(5000);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GhostMaps data sources on the viewer: name → entity count. */
|
||||||
|
function ghostSources(prefix) {
|
||||||
|
const { dataSources } = window.__godsEyeView.viewer;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < dataSources.length; i++) {
|
||||||
|
const ds = dataSources.get(i);
|
||||||
|
if (ds.name?.startsWith(prefix))
|
||||||
|
out.push({ name: ds.name, count: ds.entities.values.length });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintedOverlayPixels() {
|
||||||
|
const canvas = document.getElementById('world-overlay-canvas');
|
||||||
|
if (!canvas) return -1;
|
||||||
|
const pixels = canvas
|
||||||
|
.getContext('2d')
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableAndSettle(page) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
const panel = document.getElementById('data-panel');
|
||||||
|
if (panel?.classList.contains('collapsed'))
|
||||||
|
panel.querySelector('[data-collapse-target="data-panel"]')?.click();
|
||||||
|
return window.__godsEyeView.dataManager.setEnabled(id, true);
|
||||||
|
}, LAYER_ID);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const stats = window.__godsEyeView.dataManager.layers
|
||||||
|
.get(id)
|
||||||
|
.module.getStats();
|
||||||
|
return !stats.loading && stats.count > 0;
|
||||||
|
},
|
||||||
|
{ timeout: TIMEOUT_MS, polling: 500 },
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = (page) =>
|
||||||
|
page.evaluate(
|
||||||
|
(id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(),
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
const sources = (page) =>
|
||||||
|
page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`);
|
||||||
|
|
||||||
|
async function showRow(page) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
document
|
||||||
|
.querySelector(`[data-layer-id="${id}"]`)
|
||||||
|
?.scrollIntoView({ block: 'center' });
|
||||||
|
}, LAYER_ID);
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: !HEADFUL,
|
||||||
|
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
|
||||||
|
defaultViewport: { width: 1440, height: 900 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// ---- (i) LIVE ---------------------------------------------------------
|
||||||
|
const page = await browser.newPage();
|
||||||
|
// Start clean once; a reload must keep what the page stored.
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem('qa-ghostmaps-clean')) return;
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.setItem('qa-ghostmaps-clean', '1');
|
||||||
|
} catch {
|
||||||
|
/* sandboxed frames have no storage */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', (error) =>
|
||||||
|
console.log(` [page error] ${error.message}`),
|
||||||
|
);
|
||||||
|
await boot(page);
|
||||||
|
const catalog = await page.evaluate(() =>
|
||||||
|
fetch('/api/ghostmaps/catalog').then((r) => r.json()),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'LIVE: proxy catalog names a commit and the default products',
|
||||||
|
/^[0-9a-f]{40}$/.test(catalog.commit || '') &&
|
||||||
|
['cip', 'kinetic-30d'].every((id) =>
|
||||||
|
catalog.products.some((p) => p.id === id && p.defaultOn),
|
||||||
|
),
|
||||||
|
`${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`,
|
||||||
|
);
|
||||||
|
const t0 = Date.now();
|
||||||
|
await enableAndSettle(page);
|
||||||
|
const live = await stats(page);
|
||||||
|
const liveSources = await sources(page);
|
||||||
|
record(
|
||||||
|
'LIVE: default products load as one data source each',
|
||||||
|
liveSources.length === 2 &&
|
||||||
|
liveSources.every((s) => s.count > 0) &&
|
||||||
|
live.products.sort().join() === 'cip,kinetic-30d',
|
||||||
|
`${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'LIVE: layer reports the proxy commit and no error',
|
||||||
|
live.commit === catalog.commit && !live.error,
|
||||||
|
`commit ${live.commit?.slice(0, 7)}, error ${live.error}`,
|
||||||
|
);
|
||||||
|
await showRow(page);
|
||||||
|
const chips = await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
[
|
||||||
|
...document.querySelectorAll(
|
||||||
|
`[data-layer-id="${id}"] .data-toggle-chip`,
|
||||||
|
),
|
||||||
|
].map((chip) => ({
|
||||||
|
id: chip.dataset.chipId,
|
||||||
|
active: chip.getAttribute('aria-pressed') === 'true',
|
||||||
|
})),
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'LIVE: one chip per product, defaults pressed',
|
||||||
|
chips.length === catalog.products.length &&
|
||||||
|
chips.every(
|
||||||
|
(chip) =>
|
||||||
|
chip.active ===
|
||||||
|
catalog.products.find((p) => p.id === chip.id)?.defaultOn,
|
||||||
|
),
|
||||||
|
chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '),
|
||||||
|
);
|
||||||
|
const stream = await page
|
||||||
|
.waitForFunction(
|
||||||
|
(id) =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get(id).module.getStats()
|
||||||
|
.stream === 'open',
|
||||||
|
{ timeout: 15000, polling: 250 },
|
||||||
|
LAYER_ID,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||||
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||||
|
|
||||||
|
// ---- (ii) PICK ----------------------------------------------------------
|
||||||
|
const target = await page.evaluate((id) => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const ds = [...Array(gev.viewer.dataSources.length).keys()]
|
||||||
|
.map((i) => gev.viewer.dataSources.get(i))
|
||||||
|
.find((item) => item.name === `${id}:cip`);
|
||||||
|
const entity = ds.entities.values.find((e) => e.billboard);
|
||||||
|
const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic(
|
||||||
|
entity.position.getValue(gev.viewer.clock.currentTime),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: carto.longitude,
|
||||||
|
latitude: carto.latitude,
|
||||||
|
height: 40000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
return entity.id;
|
||||||
|
}, LAYER_ID);
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
// The horizon cull reveals the pin on the frame after the camera moves.
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const v = window.__godsEyeView.viewer;
|
||||||
|
v.scene.requestRender?.();
|
||||||
|
const c = v.scene.canvas;
|
||||||
|
return v.scene
|
||||||
|
.drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8)
|
||||||
|
.some((p) =>
|
||||||
|
String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 15000, polling: 500 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
const probe = await page.evaluate(() => {
|
||||||
|
const v = window.__godsEyeView.viewer;
|
||||||
|
const c = v.scene.canvas;
|
||||||
|
const box = c.getBoundingClientRect();
|
||||||
|
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||||
|
const picked = v.scene.drillPick(centre, 8);
|
||||||
|
// Empty ground: the first point below the marker (the card is drawn
|
||||||
|
// above it) that picks nothing at all; markers can be dense in a city.
|
||||||
|
let empty = null;
|
||||||
|
for (let r = 120; r < 420 && !empty; r += 60)
|
||||||
|
for (const a of [2, 1, 3, 0, 4]) {
|
||||||
|
const p = {
|
||||||
|
x: centre.x + r * Math.cos((a * Math.PI) / 4),
|
||||||
|
y: centre.y + r * Math.sin((a * Math.PI) / 4),
|
||||||
|
};
|
||||||
|
if (!v.scene.pick(p)) {
|
||||||
|
empty = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
empty ||= { x: centre.x + 400, y: centre.y + 300 };
|
||||||
|
return {
|
||||||
|
ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')),
|
||||||
|
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||||
|
empty: { x: empty.x + box.left, y: empty.y + box.top },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'PICK: the centred marker picks as a GhostMaps entity',
|
||||||
|
probe.ids.some((id) => id.startsWith('ghostmaps:cip:')),
|
||||||
|
`${target} → [${probe.ids.slice(0, 3).join(', ')}]`,
|
||||||
|
);
|
||||||
|
const before = await page.evaluate(paintedOverlayPixels);
|
||||||
|
await page.mouse.click(probe.centre.x, probe.centre.y);
|
||||||
|
const painted = await page
|
||||||
|
.waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, {
|
||||||
|
timeout: 10000,
|
||||||
|
polling: 250,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const after = await page.evaluate(paintedOverlayPixels);
|
||||||
|
record(
|
||||||
|
'PICK: clicking the marker paints its report card',
|
||||||
|
painted,
|
||||||
|
`${before} → ${after} pixels`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') });
|
||||||
|
const cardState = () =>
|
||||||
|
page.evaluate((id) => {
|
||||||
|
const d = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||||
|
return {
|
||||||
|
entries: d?.entriesBySource?.[id] ?? null,
|
||||||
|
painted: d?.paintedBySource?.[id] ?? null,
|
||||||
|
};
|
||||||
|
}, LAYER_ID);
|
||||||
|
const selectedState = await cardState();
|
||||||
|
record(
|
||||||
|
'PICK: the overlay host holds and paints exactly one card',
|
||||||
|
selectedState.entries === 1 && selectedState.painted === 1,
|
||||||
|
JSON.stringify(selectedState),
|
||||||
|
);
|
||||||
|
await page.mouse.click(probe.empty.x, probe.empty.y);
|
||||||
|
// The overlay canvas is `desynchronized`, so headless read-back can
|
||||||
|
// return a stale buffer; the host's own model is the authority here.
|
||||||
|
const cleared = await page
|
||||||
|
.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const d = window.__gevWorldOverlay.getDiagnostics();
|
||||||
|
return (
|
||||||
|
d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 10000, polling: 250 },
|
||||||
|
LAYER_ID,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'PICK: clicking empty ground clears the card',
|
||||||
|
cleared,
|
||||||
|
JSON.stringify(await cardState()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- (vii) FAR SIDE ---------------------------------------------------
|
||||||
|
// Pins skip the depth test; with Google 3D tiles nothing else hides the
|
||||||
|
// far side. A pin clearly behind the planet (sphere test with margin) must
|
||||||
|
// be hidden, and pins facing the camera must still show.
|
||||||
|
const horizonCounts = (lon, lat) =>
|
||||||
|
page.evaluate(
|
||||||
|
async (id, lo, la) => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const d2r = Math.PI / 180;
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination:
|
||||||
|
gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: lo * d2r,
|
||||||
|
latitude: la * d2r,
|
||||||
|
height: 18_000_000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
const c = gev.viewer.camera.positionWC;
|
||||||
|
const norm = (v) => Math.hypot(v.x, v.y, v.z);
|
||||||
|
let far = 0;
|
||||||
|
let nearShown = 0;
|
||||||
|
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||||
|
const ds = gev.viewer.dataSources.get(i);
|
||||||
|
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||||
|
for (const e of ds.entities.values) {
|
||||||
|
if (!e.billboard) continue;
|
||||||
|
const p = e.position.getValue(gev.viewer.clock.currentTime);
|
||||||
|
// Angle from the camera's nadir to the pin, against the
|
||||||
|
// geometric horizon angle; the cull's 1 km margin shows pins up
|
||||||
|
// to about 1° past it, so "clearly behind" is beyond 2°.
|
||||||
|
const angle = Math.acos(
|
||||||
|
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
|
||||||
|
);
|
||||||
|
const horizon = Math.acos(norm(p) / norm(c));
|
||||||
|
const margin = (2 * Math.PI) / 180;
|
||||||
|
if (e.show && angle > horizon + margin) far++;
|
||||||
|
if (e.show && angle < horizon - margin) nearShown++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { far, nearShown };
|
||||||
|
},
|
||||||
|
LAYER_ID,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
);
|
||||||
|
const fromIndianOcean = await horizonCounts(80, -30);
|
||||||
|
record(
|
||||||
|
'FAR SIDE: pins behind the planet are hidden',
|
||||||
|
fromIndianOcean.far === 0,
|
||||||
|
JSON.stringify(fromIndianOcean),
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'),
|
||||||
|
});
|
||||||
|
const fromAmerica = await horizonCounts(-98, 39);
|
||||||
|
record(
|
||||||
|
'FAR SIDE: pins facing the camera show again',
|
||||||
|
fromAmerica.far === 0 && fromAmerica.nearShown > 100,
|
||||||
|
JSON.stringify(fromAmerica),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- (iv) CHIPS -------------------------------------------------------
|
||||||
|
const clickChip = async (id) => {
|
||||||
|
// Row controls render on the next frame after the layer settles.
|
||||||
|
await page.waitForSelector(
|
||||||
|
`[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
return page.evaluate(
|
||||||
|
(layer, chip) =>
|
||||||
|
document
|
||||||
|
.querySelector(
|
||||||
|
`[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`,
|
||||||
|
)
|
||||||
|
?.click(),
|
||||||
|
LAYER_ID,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await clickChip('kinetic-30d');
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) =>
|
||||||
|
!window.__godsEyeView.dataManager.layers
|
||||||
|
.get(id)
|
||||||
|
.module.getStats()
|
||||||
|
.products.includes('kinetic-30d'),
|
||||||
|
{ timeout: 15000 },
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
const without = await sources(page);
|
||||||
|
record(
|
||||||
|
'CHIPS: turning a product off removes only its data source',
|
||||||
|
without.length === 1 && without[0].name === `${LAYER_ID}:cip`,
|
||||||
|
without.map((s) => s.name).join(', '),
|
||||||
|
);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await sleep(3000);
|
||||||
|
await enableAndSettle(page);
|
||||||
|
const kept = await stats(page);
|
||||||
|
record(
|
||||||
|
'CHIPS: the product choice survives a reload',
|
||||||
|
kept.products.join() === 'cip',
|
||||||
|
kept.products.join(', '),
|
||||||
|
);
|
||||||
|
await showRow(page);
|
||||||
|
await clickChip('kinetic-30d');
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get(id)
|
||||||
|
.module.getStats()
|
||||||
|
.products.includes('kinetic-30d'),
|
||||||
|
{ timeout: 30000 },
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'CHIPS: turning it back on restores it',
|
||||||
|
(await sources(page)).length === 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- (v) DISABLE ------------------------------------------------------
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__godsEyeView.dataManager.setEnabled(id, false),
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
await sleep(500);
|
||||||
|
record(
|
||||||
|
'DISABLE: every GhostMaps data source leaves the viewer',
|
||||||
|
(await sources(page)).length === 0,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
// ---- (iii) PUSH (synthetic) ----------------------------------------------
|
||||||
|
const push = await browser.newPage();
|
||||||
|
await push.evaluateOnNewDocument(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
// A controllable stand-in for the real stream.
|
||||||
|
class QaEventSource {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.listeners = new Set();
|
||||||
|
(window.__qaStreams ||= []).push(this);
|
||||||
|
}
|
||||||
|
addEventListener(type, listener) {
|
||||||
|
if (type === 'catalog') this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
removeEventListener(type, listener) {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
emit(catalog) {
|
||||||
|
for (const listener of this.listeners)
|
||||||
|
listener({ data: JSON.stringify(catalog) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.EventSource = QaEventSource;
|
||||||
|
});
|
||||||
|
await boot(push);
|
||||||
|
await enableAndSettle(push);
|
||||||
|
const identity = () =>
|
||||||
|
push.evaluate((id) => {
|
||||||
|
const { dataSources } = window.__godsEyeView.viewer;
|
||||||
|
window.__qaIds ||= new WeakMap();
|
||||||
|
window.__qaNext ||= 1;
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < dataSources.length; i++) {
|
||||||
|
const ds = dataSources.get(i);
|
||||||
|
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||||
|
if (!window.__qaIds.has(ds))
|
||||||
|
window.__qaIds.set(ds, window.__qaNext++);
|
||||||
|
out[ds.name] = window.__qaIds.get(ds);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, LAYER_ID);
|
||||||
|
const first = await identity();
|
||||||
|
const pushed = Date.now();
|
||||||
|
await push.evaluate(async (id) => {
|
||||||
|
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
|
||||||
|
r.json(),
|
||||||
|
);
|
||||||
|
const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d');
|
||||||
|
kinetic.version = 'f'.repeat(40);
|
||||||
|
catalog.message = 'QA push';
|
||||||
|
window.__qaStreams
|
||||||
|
.filter((s) => !s.closed)
|
||||||
|
.forEach((s) => s.emit(catalog));
|
||||||
|
}, LAYER_ID);
|
||||||
|
await push.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const layer = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||||
|
return (
|
||||||
|
!layer.getStats().loading &&
|
||||||
|
layer.getRowControls().info.includes('QA push')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 20000, polling: 100 },
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
await sleep(500);
|
||||||
|
const second = await identity();
|
||||||
|
record(
|
||||||
|
'PUSH: a new edition reloads only that product',
|
||||||
|
Object.keys(second).length === 2 &&
|
||||||
|
second[`${LAYER_ID}:kinetic-30d`] !==
|
||||||
|
first[`${LAYER_ID}:kinetic-30d`] &&
|
||||||
|
second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`],
|
||||||
|
`${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`,
|
||||||
|
);
|
||||||
|
await push.evaluate(async () => {
|
||||||
|
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
|
||||||
|
r.json(),
|
||||||
|
);
|
||||||
|
catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d');
|
||||||
|
window.__qaStreams
|
||||||
|
.filter((s) => !s.closed)
|
||||||
|
.forEach((s) => s.emit(catalog));
|
||||||
|
});
|
||||||
|
await sleep(1000);
|
||||||
|
const third = await identity();
|
||||||
|
record(
|
||||||
|
'PUSH: a product leaving the catalog leaves the globe',
|
||||||
|
Object.keys(third).join() === `${LAYER_ID}:cip`,
|
||||||
|
Object.keys(third).join(', '),
|
||||||
|
);
|
||||||
|
await push.close();
|
||||||
|
|
||||||
|
// ---- (vi) VIEWPORT ----------------------------------------------------
|
||||||
|
const phone = await browser.newPage();
|
||||||
|
await phone.setViewport({ width: 390, height: 844, isMobile: true });
|
||||||
|
await phone.evaluateOnNewDocument(() => localStorage.clear());
|
||||||
|
await boot(phone);
|
||||||
|
await enableAndSettle(phone);
|
||||||
|
await showRow(phone);
|
||||||
|
const fit = await phone.evaluate((id) => {
|
||||||
|
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||||
|
const panel = document.getElementById('data-panel');
|
||||||
|
const controls = row?.querySelector('.data-toggle-controls');
|
||||||
|
return {
|
||||||
|
row: Boolean(row),
|
||||||
|
overflow: controls ? controls.scrollWidth - controls.clientWidth : -1,
|
||||||
|
pageOverflow:
|
||||||
|
document.documentElement.scrollWidth -
|
||||||
|
document.documentElement.clientWidth,
|
||||||
|
panelRight: panel?.getBoundingClientRect().right,
|
||||||
|
width: window.innerWidth,
|
||||||
|
};
|
||||||
|
}, LAYER_ID);
|
||||||
|
record(
|
||||||
|
'VIEWPORT: chips fit the phone-width panel',
|
||||||
|
fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0,
|
||||||
|
JSON.stringify(fit),
|
||||||
|
);
|
||||||
|
await phone.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'),
|
||||||
|
});
|
||||||
|
await phone.close();
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(
|
||||||
|
`\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`,
|
||||||
|
);
|
||||||
|
process.exit(failed.length ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Scope and HUD browser acceptance. Requires the development server.
|
||||||
|
* Run: node scripts/qa-scope-hud.mjs
|
||||||
|
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
|
||||||
|
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
|
||||||
|
* feather 0/11/100/200, layout independence,
|
||||||
|
* share/scene restore, and visible text backing including rotated metadata.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal', '--enable-gpu']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, passed, detail) => {
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!passed) {
|
||||||
|
failures += 1;
|
||||||
|
if (detail) console.log(JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const readScope = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
|
||||||
|
const canvas = document.getElementById('scope-mask');
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const radialX = Math.round(
|
||||||
|
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enabled: scope.enabled,
|
||||||
|
feather: scope.featherPct / 100,
|
||||||
|
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
|
||||||
|
radialAlpha: context.getImageData(
|
||||||
|
radialX,
|
||||||
|
Math.round(canvas.height / 2),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
).data[3],
|
||||||
|
checked: document.getElementById('scope-toggle').checked,
|
||||||
|
disabled: document.getElementById('scope-feather-slider').disabled,
|
||||||
|
value: Number(document.getElementById('scope-feather-slider').value),
|
||||||
|
readout: document.getElementById('scope-feather-value').textContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readHudGlyphs = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const hud = document.getElementById('intel-hud');
|
||||||
|
const probe = document.createElement('span');
|
||||||
|
probe.style.display = 'none';
|
||||||
|
hud.appendChild(probe);
|
||||||
|
const baseline = {
|
||||||
|
color: getComputedStyle(probe).color,
|
||||||
|
shadow: getComputedStyle(probe).textShadow,
|
||||||
|
};
|
||||||
|
probe.remove();
|
||||||
|
const glyphs = [
|
||||||
|
'.hud-mode',
|
||||||
|
'.hud-system',
|
||||||
|
'.hud-summary-label',
|
||||||
|
'.hud-summary',
|
||||||
|
'.hud-orbital',
|
||||||
|
'#hud-mgrs',
|
||||||
|
'#hud-coll',
|
||||||
|
].map((selector) => {
|
||||||
|
const el = hud.querySelector(selector);
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
let opacity = 1;
|
||||||
|
for (
|
||||||
|
let current = el;
|
||||||
|
current && current !== hud;
|
||||||
|
current = current.parentElement
|
||||||
|
)
|
||||||
|
opacity *= Number(getComputedStyle(current).opacity);
|
||||||
|
return {
|
||||||
|
selector,
|
||||||
|
color: style.color,
|
||||||
|
shadow: style.textShadow,
|
||||||
|
opacity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { baseline, glyphs };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function settleTextBackings(enabled) {
|
||||||
|
// The pre-existing active-style value transitions all properties for 300ms.
|
||||||
|
// Wait for its backing to finish changing before inspecting or capturing it.
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
(scopeEnabled) => {
|
||||||
|
const expected = scopeEnabled
|
||||||
|
? 'rgba(0, 0, 0, 0)'
|
||||||
|
: 'rgba(0, 0, 0, 0.7)';
|
||||||
|
return [
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'#intel-hud .hud-readout, .scene-text-holdout',
|
||||||
|
),
|
||||||
|
].every(
|
||||||
|
(el) =>
|
||||||
|
getComputedStyle(el).backgroundColor === expected &&
|
||||||
|
getComputedStyle(el).boxShadow === 'none',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 2_000 },
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readSceneChrome = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const title = document.querySelector('#title-bar h1');
|
||||||
|
const eye = title.querySelector('.title-logo');
|
||||||
|
const subtitle = document.querySelector('#title-bar .subtitle');
|
||||||
|
const label = document.querySelector('#style-indicator .indicator-label');
|
||||||
|
const rect = (el, text = false) => {
|
||||||
|
if (!el.getClientRects().length) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
const box = text
|
||||||
|
? range.getBoundingClientRect()
|
||||||
|
: el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: box.left,
|
||||||
|
top: box.top,
|
||||||
|
right: box.right,
|
||||||
|
bottom: box.bottom,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const titleBox = rect(title);
|
||||||
|
const eyeBox = rect(eye);
|
||||||
|
const backing = getComputedStyle(title, '::before');
|
||||||
|
const plate = {
|
||||||
|
left: titleBox.left + parseFloat(backing.left),
|
||||||
|
top: titleBox.top + parseFloat(backing.top),
|
||||||
|
right: titleBox.right - parseFloat(backing.right),
|
||||||
|
bottom: titleBox.bottom - parseFloat(backing.bottom),
|
||||||
|
};
|
||||||
|
const contains = (box) =>
|
||||||
|
Object.entries(box).every(([side, value]) =>
|
||||||
|
['left', 'top'].includes(side)
|
||||||
|
? plate[side] <= value + 0.5
|
||||||
|
: plate[side] >= value - 0.5,
|
||||||
|
);
|
||||||
|
const probe = document.createElement('span');
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
probe.style.color = 'var(--text-dim)';
|
||||||
|
const dim = getComputedStyle(probe).color;
|
||||||
|
probe.style.color = 'var(--text-secondary)';
|
||||||
|
const secondary = getComputedStyle(probe).color;
|
||||||
|
probe.remove();
|
||||||
|
return {
|
||||||
|
positions: {
|
||||||
|
title: titleBox,
|
||||||
|
eye: eyeBox,
|
||||||
|
text: rect(title.querySelector('[translate="no"]'), true),
|
||||||
|
subtitle: rect(subtitle, true),
|
||||||
|
},
|
||||||
|
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
|
||||||
|
titleCovered:
|
||||||
|
backing.position === 'absolute' &&
|
||||||
|
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
|
||||||
|
contains(titleBox) &&
|
||||||
|
contains(eyeBox),
|
||||||
|
leftAligned:
|
||||||
|
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
|
||||||
|
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
|
||||||
|
dim,
|
||||||
|
secondary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkSceneChrome(name, before, after) {
|
||||||
|
const stable = Object.keys(before.positions).every((key) => {
|
||||||
|
const a = before.positions[key];
|
||||||
|
const b = after.positions[key];
|
||||||
|
return a && b
|
||||||
|
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
|
||||||
|
: a === b;
|
||||||
|
});
|
||||||
|
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
|
||||||
|
before.titleUnbacked && after.titleCovered && after.leftAligned,
|
||||||
|
after,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${name}: secondary labels brighten only with Scope OFF`,
|
||||||
|
before.colors.every((color) => color === before.dim) &&
|
||||||
|
after.colors.every((color) => color === after.secondary),
|
||||||
|
{ before: before.colors, after: after.colors },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApplication(url) {
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const cover = document.getElementById('loading-screen');
|
||||||
|
return (
|
||||||
|
cover?.classList.contains('hidden') &&
|
||||||
|
Number(getComputedStyle(cover).opacity) === 0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||||
|
url.searchParams.set('welcome', '0');
|
||||||
|
// This browser has a fresh profile. Start without authored state so the
|
||||||
|
// first assertion exercises the defaults rather than a share-link restore.
|
||||||
|
url.hash = '';
|
||||||
|
await openApplication(url);
|
||||||
|
const fresh = await readScope();
|
||||||
|
check(
|
||||||
|
'bare-URL fresh boot opens Scope ON with 100% feather',
|
||||||
|
fresh.enabled &&
|
||||||
|
fresh.checked &&
|
||||||
|
!fresh.disabled &&
|
||||||
|
fresh.feather === 1 &&
|
||||||
|
fresh.value === 100 &&
|
||||||
|
fresh.readout === '100%' &&
|
||||||
|
fresh.cornerAlpha > 0,
|
||||||
|
fresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use a known city view for canvas and readability checks below.
|
||||||
|
url.hash =
|
||||||
|
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
|
||||||
|
// Changing only the hash does not rerun startup restoration. Leave the app
|
||||||
|
// document first so this fixture is applied by a new application boot.
|
||||||
|
await page.goto('about:blank');
|
||||||
|
await openApplication(url);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager.setPanelCollapsed('pp-toggles', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
manager.hud.setMode('on');
|
||||||
|
manager.setStyle('normal');
|
||||||
|
});
|
||||||
|
const initial = await readScope();
|
||||||
|
check(
|
||||||
|
'Austin fixture restores Scope ON and 100% feather',
|
||||||
|
initial.enabled &&
|
||||||
|
initial.checked &&
|
||||||
|
!initial.disabled &&
|
||||||
|
initial.feather === 1 &&
|
||||||
|
initial.value === 100 &&
|
||||||
|
initial.readout === '100%' &&
|
||||||
|
initial.cornerAlpha > 0,
|
||||||
|
initial,
|
||||||
|
);
|
||||||
|
const name = await page.$eval('#scope-toggle', (el) => ({
|
||||||
|
type: el.type,
|
||||||
|
label: el.labels?.[0]?.textContent.trim(),
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
'Scope uses a native checkbox with a visible label',
|
||||||
|
name.type === 'checkbox' && name.label?.includes('Scope'),
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
await page.click('#scope-toggle');
|
||||||
|
const off = await readScope();
|
||||||
|
check(
|
||||||
|
'click disables Scope and its feather slider',
|
||||||
|
!off.enabled &&
|
||||||
|
!off.checked &&
|
||||||
|
off.disabled &&
|
||||||
|
off.feather === 1 &&
|
||||||
|
off.cornerAlpha === 0,
|
||||||
|
off,
|
||||||
|
);
|
||||||
|
await page.focus('#scope-toggle');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
const on = await readScope();
|
||||||
|
check(
|
||||||
|
'Space re-enables the native checkbox without losing feather',
|
||||||
|
on.enabled &&
|
||||||
|
on.checked &&
|
||||||
|
!on.disabled &&
|
||||||
|
on.feather === 1 &&
|
||||||
|
on.cornerAlpha > 0,
|
||||||
|
on,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paintedAlpha = [];
|
||||||
|
for (const pct of [0, 11, 100, 200]) {
|
||||||
|
await page.$eval(
|
||||||
|
'#scope-feather-slider',
|
||||||
|
(el, value) => {
|
||||||
|
el.value = String(value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
},
|
||||||
|
pct,
|
||||||
|
);
|
||||||
|
const state = await readScope();
|
||||||
|
paintedAlpha.push(state.radialAlpha);
|
||||||
|
check(
|
||||||
|
`feather ${pct}% reaches the mask and readout`,
|
||||||
|
state.value === pct &&
|
||||||
|
state.feather === pct / 100 &&
|
||||||
|
state.readout === `${pct}%`,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'painted gradient widens beyond the old 100% ceiling',
|
||||||
|
paintedAlpha[0] === 0 &&
|
||||||
|
paintedAlpha[1] === 0 &&
|
||||||
|
paintedAlpha[2] > 0 &&
|
||||||
|
paintedAlpha[3] > paintedAlpha[2],
|
||||||
|
paintedAlpha,
|
||||||
|
);
|
||||||
|
await page.focus('#scope-feather-slider');
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
check('native Home reaches the hard edge', (await readScope()).feather === 0);
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
check(
|
||||||
|
'native End reaches the expanded 200% maximum',
|
||||||
|
(await readScope()).feather === 2,
|
||||||
|
);
|
||||||
|
await page.click('#scope-toggle');
|
||||||
|
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||||
|
await page.select('#hud-layout-select', layout);
|
||||||
|
const state = await readScope();
|
||||||
|
check(
|
||||||
|
`Scope OFF and 200% survive ${layout}`,
|
||||||
|
!state.enabled &&
|
||||||
|
!state.checked &&
|
||||||
|
state.disabled &&
|
||||||
|
state.feather === 2 &&
|
||||||
|
state.value === 200 &&
|
||||||
|
state.cornerAlpha === 0,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = await page.evaluate(async () => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
const links = manager.shareLinkManager;
|
||||||
|
manager._syncShareState();
|
||||||
|
const params = links._buildHashParams();
|
||||||
|
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
|
||||||
|
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
|
||||||
|
await manager._visualSettings.restoreShareState({
|
||||||
|
scopeEnabled: encoded.enabled === '1',
|
||||||
|
scopeFeatherPct: Number(encoded.feather),
|
||||||
|
});
|
||||||
|
const shared = manager.getVisualState().scope;
|
||||||
|
await manager.applyVisualState({
|
||||||
|
scope: { enabled: true, featherPct: 200 },
|
||||||
|
});
|
||||||
|
const scene = manager.getVisualState().scope;
|
||||||
|
await manager.applyVisualState({
|
||||||
|
scope: { enabled: false, featherPct: 200 },
|
||||||
|
});
|
||||||
|
return { encoded, shared, scene };
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'share state serializes and restores Scope OFF with 200%',
|
||||||
|
restored.encoded.enabled === '0' &&
|
||||||
|
restored.encoded.feather === '200' &&
|
||||||
|
restored.shared.enabled === false &&
|
||||||
|
restored.shared.featherPct === 200,
|
||||||
|
restored,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'scene state restores Scope ON with 200%',
|
||||||
|
restored.scene.enabled === true && restored.scene.featherPct === 200,
|
||||||
|
restored,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const layout of ['minimal', 'operator', 'tactical']) {
|
||||||
|
await page.select('#hud-layout-select', layout);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.applyVisualState({
|
||||||
|
scope: { enabled: true, featherPct: 100 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settleTextBackings(true);
|
||||||
|
const originalGlyphs = await readHudGlyphs();
|
||||||
|
const originalChrome = await readSceneChrome();
|
||||||
|
const original = await page.evaluate(() => {
|
||||||
|
const hud = document.getElementById('intel-hud');
|
||||||
|
const wrappers = [...hud.querySelectorAll('.hud-readout')];
|
||||||
|
const scene = [...document.querySelectorAll('.scene-text-holdout')];
|
||||||
|
const unexpectedBackings = [...wrappers, ...scene]
|
||||||
|
.map((el) => {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
selector: el.id || el.className,
|
||||||
|
background: style.backgroundColor,
|
||||||
|
shadow: style.boxShadow,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(style) =>
|
||||||
|
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
|
||||||
|
unbacked: unexpectedBackings.length === 0,
|
||||||
|
unexpectedBackings,
|
||||||
|
wrappersTransparent: wrappers.every(
|
||||||
|
(el) => getComputedStyle(el).display === 'contents',
|
||||||
|
),
|
||||||
|
operatorFrame:
|
||||||
|
hud.dataset.variant !== 'operator' ||
|
||||||
|
(getComputedStyle(hud.querySelector('.hud-corner'))
|
||||||
|
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
|
||||||
|
getComputedStyle(hud.querySelector('.hud-top-bar'))
|
||||||
|
.backgroundImage !== 'none'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
`${layout}: Scope ON retains the original presentation without new backplates`,
|
||||||
|
original.markerAbsent &&
|
||||||
|
original.unbacked &&
|
||||||
|
original.wrappersTransparent &&
|
||||||
|
original.operatorFrame,
|
||||||
|
original,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${layout}: original HUD color, glow and metadata alpha remain intact`,
|
||||||
|
originalGlyphs.glyphs.every(
|
||||||
|
(glyph) =>
|
||||||
|
glyph.color === originalGlyphs.baseline.color &&
|
||||||
|
glyph.shadow === originalGlyphs.baseline.shadow,
|
||||||
|
) &&
|
||||||
|
originalGlyphs.glyphs.find(
|
||||||
|
(glyph) => glyph.selector === '.hud-summary-label',
|
||||||
|
).opacity === 0.55 &&
|
||||||
|
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
|
||||||
|
.opacity === 0.7 &&
|
||||||
|
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
|
||||||
|
.opacity === 0.5,
|
||||||
|
originalGlyphs,
|
||||||
|
);
|
||||||
|
if (process.env.QA_SCREENSHOT_DIR) {
|
||||||
|
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(
|
||||||
|
process.env.QA_SCREENSHOT_DIR,
|
||||||
|
`scope-on-${layout}.png`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.applyVisualState({
|
||||||
|
scope: { enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settleTextBackings(false);
|
||||||
|
const backedGlyphs = await readHudGlyphs();
|
||||||
|
checkSceneChrome(layout, originalChrome, await readSceneChrome());
|
||||||
|
check(
|
||||||
|
`${layout}: Scope OFF adds backing without changing glyph styles`,
|
||||||
|
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
|
||||||
|
backedGlyphs,
|
||||||
|
);
|
||||||
|
const coverage = await page.evaluate(() => {
|
||||||
|
const hud = document.getElementById('intel-hud');
|
||||||
|
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
|
||||||
|
const missing = [];
|
||||||
|
let visible = 0;
|
||||||
|
let node;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (
|
||||||
|
!node.textContent.trim() ||
|
||||||
|
node.parentElement.closest('.hud-bracket')
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
if (
|
||||||
|
![...range.getClientRects()].some(
|
||||||
|
(rect) => rect.width > 0 && rect.height > 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
visible += 1;
|
||||||
|
const backing = node.parentElement.closest('.hud-readout');
|
||||||
|
const color = backing && getComputedStyle(backing).backgroundColor;
|
||||||
|
if (
|
||||||
|
!backing ||
|
||||||
|
color !== 'rgba(0, 0, 0, 0.7)' ||
|
||||||
|
getComputedStyle(backing).boxShadow !== 'none' ||
|
||||||
|
backing.parentElement.closest('.hud-readout')
|
||||||
|
)
|
||||||
|
missing.push(node.textContent.trim());
|
||||||
|
}
|
||||||
|
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
|
||||||
|
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
|
||||||
|
);
|
||||||
|
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
|
||||||
|
const overlaps = [];
|
||||||
|
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
|
||||||
|
const siblings = wrapped.filter((el) => el.parentElement === parent);
|
||||||
|
for (let i = 1; i < siblings.length; i += 1) {
|
||||||
|
const previous = siblings[i - 1].getBoundingClientRect();
|
||||||
|
const current = siblings[i].getBoundingClientRect();
|
||||||
|
const horizontal =
|
||||||
|
Math.min(previous.right, current.right) -
|
||||||
|
Math.max(previous.left, current.left);
|
||||||
|
const vertical =
|
||||||
|
Math.min(previous.bottom, current.bottom) -
|
||||||
|
Math.max(previous.top, current.top);
|
||||||
|
// Horizontal bars share a vertical band. Only intersecting text boxes
|
||||||
|
// can stack their backing alpha; allow subpixel layout rounding.
|
||||||
|
if (horizontal > 0.5 && vertical > 0.5) {
|
||||||
|
overlaps.push({
|
||||||
|
parent: parent.className,
|
||||||
|
previous: siblings[i - 1].innerText,
|
||||||
|
current: siblings[i].innerText,
|
||||||
|
vertical,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lineIds = [
|
||||||
|
'hud-mgrs',
|
||||||
|
'hud-latlon',
|
||||||
|
'hud-gsd',
|
||||||
|
'hud-alt',
|
||||||
|
'hud-timestamp',
|
||||||
|
'hud-bottom-line',
|
||||||
|
];
|
||||||
|
const multiline = lineIds.filter((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el.getClientRects().length) return false;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
return (
|
||||||
|
range.getBoundingClientRect().height >
|
||||||
|
parseFloat(getComputedStyle(el).lineHeight) * 1.5
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const edges = [...hud.querySelectorAll('.hud-edge')]
|
||||||
|
.filter((el) => getComputedStyle(el).display !== 'none')
|
||||||
|
.map((el) => {
|
||||||
|
const box = el.querySelector('.hud-readout').getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
getComputedStyle(el).transform !== 'none' &&
|
||||||
|
box.height > box.width &&
|
||||||
|
Number(getComputedStyle(el).opacity) === 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const broadBackings = [
|
||||||
|
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
|
||||||
|
].filter(
|
||||||
|
(el) =>
|
||||||
|
el.offsetWidth > 0 &&
|
||||||
|
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
scopeOff: document.documentElement.dataset.scopeMask === 'off',
|
||||||
|
visible,
|
||||||
|
missing,
|
||||||
|
empty,
|
||||||
|
overlaps,
|
||||||
|
multiline,
|
||||||
|
edges,
|
||||||
|
broadBackings: broadBackings.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
`${layout}: all visible HUD text has one backing`,
|
||||||
|
coverage.scopeOff &&
|
||||||
|
coverage.visible > 0 &&
|
||||||
|
coverage.missing.length === 0 &&
|
||||||
|
coverage.empty === 0 &&
|
||||||
|
coverage.broadBackings === 0,
|
||||||
|
coverage,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${layout}: adjacent readout backings do not overlap`,
|
||||||
|
coverage.overlaps.length === 0,
|
||||||
|
coverage,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
|
||||||
|
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
|
||||||
|
coverage,
|
||||||
|
);
|
||||||
|
if (process.env.QA_SCREENSHOT_DIR) {
|
||||||
|
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(
|
||||||
|
process.env.QA_SCREENSHOT_DIR,
|
||||||
|
`scope-off-${layout}.png`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const details = await page.evaluate(() => {
|
||||||
|
const summary = document.getElementById('hud-summary');
|
||||||
|
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
|
||||||
|
const ais = document.getElementById('hud-ais-vessel');
|
||||||
|
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
|
||||||
|
return {
|
||||||
|
summaryClipped:
|
||||||
|
summary.scrollWidth > summary.clientWidth &&
|
||||||
|
getComputedStyle(summary).textOverflow === 'ellipsis',
|
||||||
|
aisMultiline:
|
||||||
|
getComputedStyle(ais).whiteSpace === 'pre-line' &&
|
||||||
|
ais.textContent.split('\n').length === 2 &&
|
||||||
|
ais.getBoundingClientRect().height + 0.5 >=
|
||||||
|
parseFloat(getComputedStyle(ais).lineHeight) * 2,
|
||||||
|
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'summary clipping, multiline AIS and red REC dot retain their behavior',
|
||||||
|
details.summaryClipped &&
|
||||||
|
details.aisMultiline &&
|
||||||
|
details.recColor === 'rgb(255, 51, 51)',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
|
await page.select('#hud-layout-select', 'minimal');
|
||||||
|
const narrow = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('#intel-hud .hud-readout')]
|
||||||
|
.filter(
|
||||||
|
(el) =>
|
||||||
|
el.getClientRects().length &&
|
||||||
|
getComputedStyle(el).display !== 'contents',
|
||||||
|
)
|
||||||
|
.every((el) => {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return box.left >= 0 && box.right <= innerWidth;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check('narrow HUD text backings stay within the viewport', narrow);
|
||||||
|
for (const layout of ['minimal', 'operator', 'tactical']) {
|
||||||
|
await page.select('#hud-layout-select', layout);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.applyVisualState({
|
||||||
|
scope: { enabled: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settleTextBackings(true);
|
||||||
|
const before = await readSceneChrome();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.applyVisualState({
|
||||||
|
scope: { enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settleTextBackings(false);
|
||||||
|
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
|
||||||
|
}
|
||||||
|
check('runtime has no uncaught browser errors', errors.length === 0, errors);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(`RESULT: ${failures} failures`);
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
GHOSTMAPS_MAX_FILE_BYTES,
|
||||||
|
GHOSTMAPS_REPO,
|
||||||
|
buildGhostMapsCatalog,
|
||||||
|
ghostMapsRawUrl,
|
||||||
|
parseGitRefAdvertisement,
|
||||||
|
} from '../../src/layers/ghostMaps/catalog.js';
|
||||||
|
import {
|
||||||
|
readResponseBytesCapped,
|
||||||
|
readResponseJsonCapped,
|
||||||
|
readResponseTextCapped,
|
||||||
|
} from './common/http.js';
|
||||||
|
|
||||||
|
// S2 Underground GhostMaps (public GitHub repository of KMZ intel products).
|
||||||
|
// The repository is not ours, so there is no webhook: the watcher reads the
|
||||||
|
// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the
|
||||||
|
// REST API rate limit) every few seconds, and only a new commit costs REST
|
||||||
|
// calls (tree + commit). Browsers hear about a new commit over server-sent
|
||||||
|
// events and fetch only the products whose blob changed. Files are fetched by
|
||||||
|
// commit-pinned raw URLs and kept on disk by blob sha.
|
||||||
|
|
||||||
|
const DEFAULT_POLL_MS = 15_000;
|
||||||
|
const MIN_POLL_MS = 5_000;
|
||||||
|
const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000];
|
||||||
|
const TIMEOUT_MS = 15_000;
|
||||||
|
const FILE_TIMEOUT_MS = 120_000;
|
||||||
|
const HEARTBEAT_MS = 25_000;
|
||||||
|
const MAX_SUBSCRIBERS = 64;
|
||||||
|
const MAX_REF_BYTES = 64 * 1024;
|
||||||
|
const MAX_TREE_BYTES = 16 * 1024 * 1024;
|
||||||
|
const MAX_COMMIT_BYTES = 2 * 1024 * 1024;
|
||||||
|
const USER_AGENT = 'gods-eye-view-ghostmaps/1.0';
|
||||||
|
const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`;
|
||||||
|
|
||||||
|
/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */
|
||||||
|
export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) {
|
||||||
|
const seconds = Number(raw);
|
||||||
|
if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS;
|
||||||
|
return Math.max(MIN_POLL_MS, Math.round(seconds * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository watcher and file cache, independent of HTTP routing.
|
||||||
|
* @returns {{start: Function, stop: Function, catalog: Function,
|
||||||
|
* checkNow: Function, file: Function, subscribe: Function}}
|
||||||
|
*/
|
||||||
|
export function createGhostMapsWatcher({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
now = () => Date.now(),
|
||||||
|
pollMs = ghostMapsPollMs(),
|
||||||
|
cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'),
|
||||||
|
token = () => String(process.env.GITHUB_TOKEN || '').trim(),
|
||||||
|
log = (...args) => console.log(...args),
|
||||||
|
warn = (...args) => console.warn(...args),
|
||||||
|
} = {}) {
|
||||||
|
let state = {
|
||||||
|
commit: null,
|
||||||
|
committedAt: null,
|
||||||
|
message: null,
|
||||||
|
products: [],
|
||||||
|
checkedAt: null,
|
||||||
|
changedAt: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
let timer = null;
|
||||||
|
let running = false;
|
||||||
|
let checking = null;
|
||||||
|
let failures = 0;
|
||||||
|
let diskLoaded = false;
|
||||||
|
const listeners = new Set();
|
||||||
|
const downloads = new Map();
|
||||||
|
|
||||||
|
const apiHeaders = () => ({
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function get(url, headers, timeoutMs = TIMEOUT_MS) {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel().catch(() => {});
|
||||||
|
throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHead() {
|
||||||
|
const response = await get(
|
||||||
|
`${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`,
|
||||||
|
{ 'User-Agent': USER_AGENT },
|
||||||
|
);
|
||||||
|
const sha = parseGitRefAdvertisement(
|
||||||
|
await readResponseTextCapped(response, MAX_REF_BYTES),
|
||||||
|
);
|
||||||
|
if (!sha) throw new Error('GhostMaps branch head not advertised');
|
||||||
|
return sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCommit(sha) {
|
||||||
|
const api = `https://api.github.com/repos/${REPO_PATH}`;
|
||||||
|
const tree = await readResponseJsonCapped(
|
||||||
|
await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()),
|
||||||
|
MAX_TREE_BYTES,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed');
|
||||||
|
if (tree.truncated) warn('[ghostmaps] repository tree listing truncated');
|
||||||
|
let committedAt = null;
|
||||||
|
let message = null;
|
||||||
|
try {
|
||||||
|
const commit = await readResponseJsonCapped(
|
||||||
|
await get(`${api}/commits/${sha}`, apiHeaders()),
|
||||||
|
MAX_COMMIT_BYTES,
|
||||||
|
);
|
||||||
|
committedAt = Date.parse(commit?.commit?.committer?.date) || null;
|
||||||
|
message = String(commit?.commit?.message || '')
|
||||||
|
.split('\n')[0]
|
||||||
|
.slice(0, 200);
|
||||||
|
} catch {
|
||||||
|
// The commit date is decoration; the tree is what matters.
|
||||||
|
}
|
||||||
|
return { products: buildGhostMapsCatalog(tree.tree), committedAt, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDisk() {
|
||||||
|
if (diskLoaded) return;
|
||||||
|
diskLoaded = true;
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(
|
||||||
|
await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products))
|
||||||
|
state = { ...state, ...saved, error: null };
|
||||||
|
} catch {
|
||||||
|
/* first run */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDisk() {
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(cacheDir, { recursive: true });
|
||||||
|
const { commit, committedAt, message, products, changedAt } = state;
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(cacheDir, 'catalog.json'),
|
||||||
|
JSON.stringify({ commit, committedAt, message, products, changedAt }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
warn('[ghostmaps] could not save catalog:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
const event = publicCatalog();
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch {
|
||||||
|
/* a broken subscriber must not stop the rest */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
await loadDisk();
|
||||||
|
const head = await readHead();
|
||||||
|
state.checkedAt = now();
|
||||||
|
if (head === state.commit) {
|
||||||
|
if (state.error !== null) {
|
||||||
|
state.error = null;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const next = await readCommit(head);
|
||||||
|
const previous = new Map(state.products.map((p) => [p.id, p.version]));
|
||||||
|
const changed = next.products
|
||||||
|
.filter((p) => previous.get(p.id) !== p.version)
|
||||||
|
.map((p) => p.id);
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
...next,
|
||||||
|
commit: head,
|
||||||
|
changedAt: now(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
log(
|
||||||
|
`[ghostmaps] commit ${head.slice(0, 8)}${
|
||||||
|
next.message ? ` "${next.message}"` : ''
|
||||||
|
}; updated: ${changed.join(', ') || 'none'}`,
|
||||||
|
);
|
||||||
|
await saveDisk();
|
||||||
|
emit();
|
||||||
|
// Warm the default products so the first browser load is local.
|
||||||
|
for (const product of state.products)
|
||||||
|
if (product.defaultOn) file(product.id).catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one check now, coalescing with one already in flight. */
|
||||||
|
function checkNow() {
|
||||||
|
if (!checking)
|
||||||
|
checking = check()
|
||||||
|
.then(
|
||||||
|
(changed) => {
|
||||||
|
failures = 0;
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
failures += 1;
|
||||||
|
state.error = error.message;
|
||||||
|
state.checkedAt = now();
|
||||||
|
warn('[ghostmaps] check failed:', error.message);
|
||||||
|
emit();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
checking = null;
|
||||||
|
});
|
||||||
|
return checking;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!running) return;
|
||||||
|
const delay = failures
|
||||||
|
? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1]
|
||||||
|
: pollMs;
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
await checkNow();
|
||||||
|
schedule();
|
||||||
|
}, delay);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start watching (idempotent); the first check runs immediately. */
|
||||||
|
function start() {
|
||||||
|
if (running) return checking || Promise.resolve(false);
|
||||||
|
running = true;
|
||||||
|
const first = checkNow();
|
||||||
|
first.then(schedule);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
running = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicCatalog() {
|
||||||
|
return {
|
||||||
|
repo: GHOSTMAPS_REPO.url,
|
||||||
|
branch: GHOSTMAPS_REPO.branch,
|
||||||
|
commit: state.commit,
|
||||||
|
committedAt: state.committedAt,
|
||||||
|
message: state.message,
|
||||||
|
checkedAt: state.checkedAt,
|
||||||
|
changedAt: state.changedAt,
|
||||||
|
pollMs,
|
||||||
|
error: state.error,
|
||||||
|
products: state.products.map(
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
defaultOn,
|
||||||
|
fileName,
|
||||||
|
fileTitle,
|
||||||
|
version,
|
||||||
|
size,
|
||||||
|
dateMs,
|
||||||
|
}) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
defaultOn,
|
||||||
|
fileName,
|
||||||
|
fileTitle,
|
||||||
|
version,
|
||||||
|
size,
|
||||||
|
dateMs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes of one product's current edition, from disk or a commit-pinned
|
||||||
|
* download. Only catalog paths are ever fetched.
|
||||||
|
*/
|
||||||
|
async function file(id) {
|
||||||
|
const product = state.products.find((p) => p.id === id);
|
||||||
|
if (!product) return null;
|
||||||
|
const target = path.join(cacheDir, 'files', `${product.version}.kmz`);
|
||||||
|
try {
|
||||||
|
return { product, bytes: await fsp.readFile(target) };
|
||||||
|
} catch {
|
||||||
|
/* not cached yet */
|
||||||
|
}
|
||||||
|
const commit = state.commit;
|
||||||
|
let pending = downloads.get(product.version);
|
||||||
|
if (!pending) {
|
||||||
|
pending = (async () => {
|
||||||
|
const response = await get(
|
||||||
|
ghostMapsRawUrl(commit, product.path),
|
||||||
|
{ 'User-Agent': USER_AGENT },
|
||||||
|
FILE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const bytes = Buffer.from(
|
||||||
|
await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES),
|
||||||
|
);
|
||||||
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
const temp = `${target}.${process.pid}.tmp`;
|
||||||
|
await fsp.writeFile(temp, bytes);
|
||||||
|
await fsp.rename(temp, target);
|
||||||
|
await pruneFiles();
|
||||||
|
return bytes;
|
||||||
|
})().finally(() => downloads.delete(product.version));
|
||||||
|
downloads.set(product.version, pending);
|
||||||
|
}
|
||||||
|
return { product, bytes: await pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only files that a current product still points to. */
|
||||||
|
async function pruneFiles() {
|
||||||
|
const keep = new Set(state.products.map((p) => `${p.version}.kmz`));
|
||||||
|
const dir = path.join(cacheDir, 'files');
|
||||||
|
for (const name of await fsp.readdir(dir).catch(() => [])) {
|
||||||
|
if (!keep.has(name) && !name.endsWith('.tmp'))
|
||||||
|
await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
checkNow,
|
||||||
|
catalog: publicCatalog,
|
||||||
|
file,
|
||||||
|
subscribe,
|
||||||
|
subscribers: () => listeners.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-origin GhostMaps routes for dev and preview. */
|
||||||
|
export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) {
|
||||||
|
const json = (res, status, value) => {
|
||||||
|
if (res.destroyed) return;
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
function events(req, res) {
|
||||||
|
if (watcher.subscribers() >= MAX_SUBSCRIBERS)
|
||||||
|
return json(res, 429, { error: 'too_many_subscribers' });
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
const send = (catalog) =>
|
||||||
|
res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`);
|
||||||
|
res.write('retry: 5000\n\n');
|
||||||
|
send(watcher.catalog());
|
||||||
|
const unsubscribe = watcher.subscribe(send);
|
||||||
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
const close = () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
req.on('close', close);
|
||||||
|
res.on('error', close);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req, res) {
|
||||||
|
if (req.method !== 'GET')
|
||||||
|
return json(res, 405, { error: 'method_not_allowed' });
|
||||||
|
const url = new URL(req.url || '/', 'http://local');
|
||||||
|
const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname);
|
||||||
|
if (!['/catalog', '/events'].includes(url.pathname) && !match)
|
||||||
|
return json(res, 404, { error: 'unknown_route' });
|
||||||
|
// Watching starts with the first request, so an install that never turns
|
||||||
|
// the layer on never contacts GitHub.
|
||||||
|
const first = watcher.start();
|
||||||
|
if (url.pathname === '/catalog') {
|
||||||
|
if (!watcher.catalog().commit) await first;
|
||||||
|
return json(res, 200, watcher.catalog());
|
||||||
|
}
|
||||||
|
if (url.pathname === '/events') return events(req, res);
|
||||||
|
try {
|
||||||
|
if (!watcher.catalog().commit) await first;
|
||||||
|
const found = await watcher.file(match[1]);
|
||||||
|
if (!found) return json(res, 404, { error: 'unknown_product' });
|
||||||
|
const pinned = url.searchParams.get('v') === found.product.version;
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/vnd.google-earth.kmz',
|
||||||
|
'Content-Length': found.bytes.length,
|
||||||
|
'X-GhostMaps-Version': found.product.version,
|
||||||
|
// A versioned URL names one immutable blob.
|
||||||
|
'Cache-Control': pinned
|
||||||
|
? 'private, max-age=31536000, immutable'
|
||||||
|
: 'no-store',
|
||||||
|
});
|
||||||
|
return res.end(found.bytes);
|
||||||
|
} catch {
|
||||||
|
return json(res, 502, { error: 'ghostmaps_unavailable' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'ghostmaps',
|
||||||
|
configureServer({ middlewares }) {
|
||||||
|
middlewares.use('/api/ghostmaps', handler);
|
||||||
|
},
|
||||||
|
configurePreviewServer({ middlewares }) {
|
||||||
|
middlewares.use('/api/ghostmaps', handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
createGhostMapsWatcher,
|
||||||
|
ghostMapsPollMs,
|
||||||
|
ghostMapsProxy,
|
||||||
|
} from './ghostMaps.js';
|
||||||
|
|
||||||
|
const MASTER =
|
||||||
|
'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/';
|
||||||
|
const KINETIC =
|
||||||
|
'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz';
|
||||||
|
const sha = (c) => c.repeat(40);
|
||||||
|
|
||||||
|
/** A fake GitHub: ref advertisement, tree, commit and raw files. */
|
||||||
|
function fakeGitHub() {
|
||||||
|
const repo = { head: sha('1'), trees: new Map(), calls: [] };
|
||||||
|
repo.trees.set(sha('1'), [
|
||||||
|
{
|
||||||
|
path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`,
|
||||||
|
type: 'blob',
|
||||||
|
size: 4,
|
||||||
|
sha: sha('a'),
|
||||||
|
},
|
||||||
|
{ path: KINETIC, type: 'blob', size: 4, sha: sha('b') },
|
||||||
|
]);
|
||||||
|
const body = (text, status = 200) =>
|
||||||
|
new Response(text, {
|
||||||
|
status,
|
||||||
|
headers: { 'content-length': String(Buffer.byteLength(text)) },
|
||||||
|
});
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
repo.calls.push(url);
|
||||||
|
if (url.endsWith('/info/refs?service=git-upload-pack'))
|
||||||
|
return body(
|
||||||
|
`001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`,
|
||||||
|
);
|
||||||
|
const tree = /git\/trees\/([0-9a-f]{40})/.exec(url);
|
||||||
|
if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) }));
|
||||||
|
if (/\/commits\//.test(url))
|
||||||
|
return body(
|
||||||
|
JSON.stringify({
|
||||||
|
commit: {
|
||||||
|
message: 'Oct 18 Update\nmore',
|
||||||
|
committer: { date: '2026-10-18T00:00:00Z' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (url.startsWith('https://raw.githubusercontent.com/'))
|
||||||
|
return body('KMZ!');
|
||||||
|
return body('nope', 404);
|
||||||
|
};
|
||||||
|
return { repo, fetchImpl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withCache(fn) {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-'));
|
||||||
|
try {
|
||||||
|
await fn(dir);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNow announces the catalog before background warm-up finishes. Join the
|
||||||
|
// same file reads before changing editions or removing the temporary cache.
|
||||||
|
async function warmDefaults(watcher) {
|
||||||
|
await Promise.all(
|
||||||
|
watcher
|
||||||
|
.catalog()
|
||||||
|
.products.filter((product) => product.defaultOn)
|
||||||
|
.map((product) => watcher.file(product.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a new commit is announced once, naming only the products that changed', () =>
|
||||||
|
withCache(async (cacheDir) => {
|
||||||
|
const { repo, fetchImpl } = fakeGitHub();
|
||||||
|
const watcher = createGhostMapsWatcher({
|
||||||
|
fetchImpl,
|
||||||
|
cacheDir,
|
||||||
|
log: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
watcher.subscribe((catalog) => events.push(catalog));
|
||||||
|
|
||||||
|
assert.equal(await watcher.checkNow(), true);
|
||||||
|
await warmDefaults(watcher);
|
||||||
|
const first = watcher.catalog();
|
||||||
|
assert.equal(first.commit, sha('1'));
|
||||||
|
assert.equal(first.message, 'Oct 18 Update');
|
||||||
|
assert.deepEqual(
|
||||||
|
first.products.map((p) => p.id),
|
||||||
|
['cip', 'kinetic-30d'],
|
||||||
|
);
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
// Same head: one ref read, no REST calls, no event.
|
||||||
|
repo.calls.length = 0;
|
||||||
|
assert.equal(await watcher.checkNow(), false);
|
||||||
|
assert.equal(repo.calls.length, 1);
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
// A new upload: the CIP gets a newer edition, the 30-day pull is untouched.
|
||||||
|
repo.head = sha('2');
|
||||||
|
repo.trees.set(sha('2'), [
|
||||||
|
...repo.trees.get(sha('1')),
|
||||||
|
{
|
||||||
|
path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`,
|
||||||
|
type: 'blob',
|
||||||
|
size: 4,
|
||||||
|
sha: sha('c'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(await watcher.checkNow(), true);
|
||||||
|
await warmDefaults(watcher);
|
||||||
|
assert.equal(events.length, 2);
|
||||||
|
const cip = events[1].products.find((p) => p.id === 'cip');
|
||||||
|
assert.equal(cip.version, sha('c'));
|
||||||
|
assert.match(cip.fileName, /October_18/);
|
||||||
|
assert.equal(
|
||||||
|
events[1].products.find((p) => p.id === 'kinetic-30d').version,
|
||||||
|
sha('b'),
|
||||||
|
);
|
||||||
|
// Repository paths stay on the server.
|
||||||
|
assert.equal('path' in cip, false);
|
||||||
|
watcher.stop();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () =>
|
||||||
|
withCache(async (cacheDir) => {
|
||||||
|
const { repo, fetchImpl } = fakeGitHub();
|
||||||
|
const watcher = createGhostMapsWatcher({
|
||||||
|
fetchImpl,
|
||||||
|
cacheDir,
|
||||||
|
log: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
});
|
||||||
|
await watcher.checkNow();
|
||||||
|
const found = await watcher.file('kinetic-30d');
|
||||||
|
assert.equal(found.bytes.toString(), 'KMZ!');
|
||||||
|
const raw = repo.calls.filter((url) =>
|
||||||
|
url.includes('raw.githubusercontent'),
|
||||||
|
);
|
||||||
|
assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`)));
|
||||||
|
assert.ok(
|
||||||
|
raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')),
|
||||||
|
);
|
||||||
|
const before = repo.calls.length;
|
||||||
|
await watcher.file('kinetic-30d');
|
||||||
|
assert.equal(repo.calls.length, before, 'second read comes from disk');
|
||||||
|
assert.equal(await watcher.file('not-a-product'), null);
|
||||||
|
|
||||||
|
// When the edition changes, the old blob leaves the disk cache.
|
||||||
|
await watcher.file('cip');
|
||||||
|
repo.head = sha('2');
|
||||||
|
repo.trees.set(sha('2'), [
|
||||||
|
{
|
||||||
|
path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`,
|
||||||
|
type: 'blob',
|
||||||
|
size: 4,
|
||||||
|
sha: sha('c'),
|
||||||
|
},
|
||||||
|
{ path: KINETIC, type: 'blob', size: 4, sha: sha('b') },
|
||||||
|
]);
|
||||||
|
await watcher.checkNow();
|
||||||
|
await watcher.file('cip');
|
||||||
|
const files = (await readdir(path.join(cacheDir, 'files'))).sort();
|
||||||
|
assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]);
|
||||||
|
watcher.stop();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('the catalog survives a restart from disk, and upstream failures are reported', () =>
|
||||||
|
withCache(async (cacheDir) => {
|
||||||
|
const { fetchImpl } = fakeGitHub();
|
||||||
|
const quiet = { log: () => {}, warn: () => {} };
|
||||||
|
const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet });
|
||||||
|
await online.checkNow();
|
||||||
|
await warmDefaults(online);
|
||||||
|
const offline = createGhostMapsWatcher({
|
||||||
|
fetchImpl: async () => new Response('down', { status: 503 }),
|
||||||
|
cacheDir,
|
||||||
|
...quiet,
|
||||||
|
});
|
||||||
|
assert.equal(await offline.checkNow(), false);
|
||||||
|
const catalog = offline.catalog();
|
||||||
|
assert.equal(catalog.commit, sha('1'));
|
||||||
|
assert.equal(catalog.products.length, 2);
|
||||||
|
assert.match(catalog.error, /HTTP 503/);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an unchanged head clears an upstream outage and announces recovery once', () =>
|
||||||
|
withCache(async (cacheDir) => {
|
||||||
|
const { fetchImpl } = fakeGitHub();
|
||||||
|
let unavailable = false;
|
||||||
|
let checkedAt = 1000;
|
||||||
|
const requests = [];
|
||||||
|
const watcher = createGhostMapsWatcher({
|
||||||
|
cacheDir,
|
||||||
|
now: () => checkedAt,
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
requests.push(url);
|
||||||
|
if (unavailable) return new Response('down', { status: 503 });
|
||||||
|
return fetchImpl(url, options);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
watcher.subscribe((catalog) => events.push(catalog));
|
||||||
|
await watcher.checkNow();
|
||||||
|
await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]);
|
||||||
|
const healthy = watcher.catalog();
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
unavailable = true;
|
||||||
|
checkedAt = 2000;
|
||||||
|
assert.equal(await watcher.checkNow(), false);
|
||||||
|
assert.match(watcher.catalog().error, /HTTP 503/);
|
||||||
|
assert.equal(events.length, 2);
|
||||||
|
|
||||||
|
unavailable = false;
|
||||||
|
checkedAt = 3000;
|
||||||
|
requests.length = 0;
|
||||||
|
assert.equal(await watcher.checkNow(), false, 'no new edition arrived');
|
||||||
|
const recovered = watcher.catalog();
|
||||||
|
assert.equal(recovered.error, null);
|
||||||
|
assert.equal(recovered.checkedAt, checkedAt);
|
||||||
|
assert.equal(recovered.commit, healthy.commit);
|
||||||
|
assert.equal(recovered.changedAt, healthy.changedAt);
|
||||||
|
assert.deepEqual(recovered.products, healthy.products);
|
||||||
|
assert.equal(requests.length, 1, 'recovery needs only the ref read');
|
||||||
|
assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/);
|
||||||
|
assert.equal(events.length, 3, 'subscribers see the recovered catalog');
|
||||||
|
assert.equal(events[2].error, null);
|
||||||
|
assert.equal(events[2].checkedAt, checkedAt);
|
||||||
|
|
||||||
|
checkedAt = 4000;
|
||||||
|
assert.equal(await watcher.checkNow(), false);
|
||||||
|
assert.equal(events.length, 3, 'healthy unchanged polls stay silent');
|
||||||
|
watcher.stop();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('poll interval is configurable with a 5 s floor', () => {
|
||||||
|
assert.equal(ghostMapsPollMs(undefined), 15000);
|
||||||
|
assert.equal(ghostMapsPollMs('30'), 30000);
|
||||||
|
assert.equal(ghostMapsPollMs('1'), 5000);
|
||||||
|
assert.equal(ghostMapsPollMs('soon'), 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes: unknown paths and methods are refused without contacting GitHub', async () => {
|
||||||
|
let started = 0;
|
||||||
|
const watcher = {
|
||||||
|
start: () => {
|
||||||
|
started += 1;
|
||||||
|
return Promise.resolve(false);
|
||||||
|
},
|
||||||
|
catalog: () => ({ commit: sha('1'), products: [] }),
|
||||||
|
file: async () => null,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
subscribers: () => 0,
|
||||||
|
};
|
||||||
|
let handler;
|
||||||
|
ghostMapsProxy({ watcher }).configureServer({
|
||||||
|
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||||
|
});
|
||||||
|
const call = async (method, url) => {
|
||||||
|
const res = {
|
||||||
|
destroyed: false,
|
||||||
|
writeHead(status, headers) {
|
||||||
|
this.status = status;
|
||||||
|
this.headers = headers;
|
||||||
|
},
|
||||||
|
end(body) {
|
||||||
|
this.body = String(body ?? '');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await handler({ method, url }, res);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
assert.equal((await call('GET', '/nope')).status, 404);
|
||||||
|
assert.equal((await call('POST', '/catalog')).status, 405);
|
||||||
|
assert.equal((await call('GET', '/file/../../etc')).status, 404);
|
||||||
|
assert.equal(started, 0);
|
||||||
|
assert.equal((await call('GET', '/file/cip')).status, 404);
|
||||||
|
const catalog = await call('GET', '/catalog');
|
||||||
|
assert.equal(catalog.status, 200);
|
||||||
|
assert.equal(JSON.parse(catalog.body).commit, sha('1'));
|
||||||
|
assert.equal(started, 2);
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ import { keySetupEndpoint } from '../standalone/key-setup.js';
|
|||||||
import { weatherProxy } from './weather.js';
|
import { weatherProxy } from './weather.js';
|
||||||
import { firePerimetersProxy } from './firePerimeters.js';
|
import { firePerimetersProxy } from './firePerimeters.js';
|
||||||
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
||||||
|
import { ghostMapsProxy } from './ghostMaps.js';
|
||||||
import { cycloneProxy } from './cyclones.js';
|
import { cycloneProxy } from './cyclones.js';
|
||||||
import { windProxy } from './wind.js';
|
import { windProxy } from './wind.js';
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) {
|
|||||||
mapillaryProxy(),
|
mapillaryProxy(),
|
||||||
firePerimetersProxy(),
|
firePerimetersProxy(),
|
||||||
gnssIntegrityProxy(),
|
gnssIntegrityProxy(),
|
||||||
|
ghostMapsProxy(),
|
||||||
keySetupEndpoint(),
|
keySetupEndpoint(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function realtimeInstructions(
|
|||||||
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
|
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
|
||||||
'Only control the app by calling the provided tools. Never invent tool names or arguments.',
|
'Only control the app by calling the provided tools. Never invent tool names or arguments.',
|
||||||
'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.',
|
'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.',
|
||||||
'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.',
|
'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.',
|
||||||
'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.',
|
'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.',
|
||||||
'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.',
|
'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.',
|
||||||
'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.',
|
'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.',
|
||||||
@@ -18,13 +18,12 @@ function realtimeInstructions(
|
|||||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||||
'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.',
|
'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.',
|
||||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.',
|
||||||
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
|
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
|
||||||
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
|
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
|
||||||
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
||||||
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
|
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
|
||||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
|
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
|
||||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
|
||||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||||
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
||||||
'Do not mention disabled layers or stale selections.',
|
'Do not mention disabled layers or stale selections.',
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
},
|
},
|
||||||
select_nearest_aircraft: {
|
select_nearest_aircraft: {
|
||||||
description:
|
description:
|
||||||
'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
|
'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.',
|
||||||
$position: 1,
|
$position: 1,
|
||||||
parameters: {
|
parameters: {
|
||||||
properties: {
|
properties: {
|
||||||
@@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
set_panel_open: {
|
set_panel_open: {
|
||||||
description: 'Open or close a GEV UI panel/dropdown.',
|
description:
|
||||||
|
'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.',
|
||||||
|
$position: 1,
|
||||||
|
},
|
||||||
|
set_voice_inactivity_timeout: {
|
||||||
|
description:
|
||||||
|
'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.',
|
||||||
$position: 1,
|
$position: 1,
|
||||||
},
|
},
|
||||||
set_context_mode: {
|
set_context_mode: {
|
||||||
description:
|
description:
|
||||||
'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
|
'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.',
|
||||||
$position: 1,
|
$position: 1,
|
||||||
parameters: {
|
parameters: {
|
||||||
properties: {
|
properties: {
|
||||||
@@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
},
|
},
|
||||||
control_cockpit: {
|
control_cockpit: {
|
||||||
description:
|
description:
|
||||||
'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
|
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
|
||||||
$position: 1,
|
$position: 1,
|
||||||
parameters: {
|
parameters: {
|
||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
description:
|
description:
|
||||||
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
|
'previous/next (or prev) cycles nearby Cockpit contacts.',
|
||||||
$position: 2,
|
$position: 2,
|
||||||
},
|
},
|
||||||
targetLayer: {
|
targetLayer: {
|
||||||
description:
|
description:
|
||||||
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
|
'Optional next/previous layer filter (e.g. military-only).',
|
||||||
$position: 2,
|
$position: 2,
|
||||||
},
|
},
|
||||||
aircraftClass: {
|
aircraftClass: {
|
||||||
description:
|
description:
|
||||||
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
|
'Optional next/previous aircraft class filter (e.g. helicopter).',
|
||||||
$position: 1,
|
$position: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
|||||||
import { createApplicationAirQuality } from './layers/airQuality.js';
|
import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||||
|
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||||
import { createApplicationCables } from './layers/submarineCables.js';
|
import { createApplicationCables } from './layers/submarineCables.js';
|
||||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||||
@@ -126,6 +127,7 @@ export function createApplicationCatalog({
|
|||||||
createApplicationGnssIntegrity({
|
createApplicationGnssIntegrity({
|
||||||
source: sources['gnss-integrity'],
|
source: sources['gnss-integrity'],
|
||||||
}),
|
}),
|
||||||
|
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
createApplicationStreetLevel({
|
createApplicationStreetLevel({
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
surface: fixtureSurface(b.signal),
|
surface: fixtureSurface(b.signal),
|
||||||
});
|
});
|
||||||
assert.equal(first.layers.length, 32);
|
assert.equal(first.layers.length, 33);
|
||||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||||
@@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
);
|
);
|
||||||
assert.ok(first.get('fire-perimeters'));
|
assert.ok(first.get('fire-perimeters'));
|
||||||
assert.ok(first.get('gnss-integrity'));
|
assert.ok(first.get('gnss-integrity'));
|
||||||
|
assert.ok(first.get('s2-ghostmaps'));
|
||||||
assert.ok(first.get('transit'));
|
assert.ok(first.get('transit'));
|
||||||
const order = first.layers.map(({ id }) => id);
|
const order = first.layers.map(({ id }) => id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
|||||||
surface: fixtureSurface(lifetime.signal),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
};
|
};
|
||||||
const catalog = createApplicationCatalog(options);
|
const catalog = createApplicationCatalog(options);
|
||||||
assert.equal(catalog.layers.length, 32);
|
assert.equal(catalog.layers.length, 33);
|
||||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
catalogControlServices(catalog).streetLevelLayer,
|
catalogControlServices(catalog).streetLevelLayer,
|
||||||
|
|||||||
Vendored
+8
-1
@@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js';
|
|||||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** Construct the existing controls and camera presentation. */
|
/**
|
||||||
|
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||||
|
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||||
|
* whose address does not carry one.
|
||||||
|
*/
|
||||||
export function createApplicationControls({
|
export function createApplicationControls({
|
||||||
scene: { viewer, mapStackController, operations },
|
scene: { viewer, mapStackController, operations },
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
@@ -13,6 +17,7 @@ export function createApplicationControls({
|
|||||||
catalog,
|
catalog,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
defer,
|
defer,
|
||||||
|
initialShareCode = null,
|
||||||
}) {
|
}) {
|
||||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||||
const styleManager = new Controls(viewer, {
|
const styleManager = new Controls(viewer, {
|
||||||
@@ -27,6 +32,8 @@ export function createApplicationControls({
|
|||||||
requestServices: operations.requests,
|
requestServices: operations.requests,
|
||||||
mapStackController,
|
mapStackController,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
|
// A share code to open at start-up instead of the page's address.
|
||||||
|
initialShareCode,
|
||||||
});
|
});
|
||||||
defer(() => styleManager.orbitController.stop());
|
defer(() => styleManager.orbitController.stop());
|
||||||
defer(() => styleManager.hud.destroy());
|
defer(() => styleManager.hud.destroy());
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||||
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
|
|
||||||
|
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||||
|
export function createApplicationGhostMaps(options) {
|
||||||
|
return createGhostMapsLayer({
|
||||||
|
overlayHost: {
|
||||||
|
setEntries: overlays.setOverlayEntries,
|
||||||
|
setVisible: overlays.setOverlaySourceVisible,
|
||||||
|
clearSource: overlays.clearOverlaySource,
|
||||||
|
hitTest: overlays.hitTestWorldOverlay,
|
||||||
|
},
|
||||||
|
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||||
|
screenSpaceEventHandlerFactory: (viewer) =>
|
||||||
|
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||||
|
picking,
|
||||||
|
pointer: { isPointerFree },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
|||||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||||
|
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
|||||||
|
|
||||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||||
const actual = catalog({}, t);
|
const actual = catalog({}, t);
|
||||||
assert.equal(actual.layers.length, 32);
|
assert.equal(actual.layers.length, 33);
|
||||||
const manager = new LayerLifecycle(
|
const manager = new LayerLifecycle(
|
||||||
{},
|
{},
|
||||||
{ getSourceAvailability: actual.getSourceAvailability },
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
@@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => {
|
||||||
|
let fetches = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
fetches++;
|
||||||
|
throw new Error('Unexpected standalone acquisition');
|
||||||
|
});
|
||||||
|
const missing = catalog({}, t);
|
||||||
|
assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false);
|
||||||
|
const manager = new LayerLifecycle(
|
||||||
|
{},
|
||||||
|
{ getSourceAvailability: missing.getSourceAvailability },
|
||||||
|
);
|
||||||
|
manager.register(missing.get('s2-ghostmaps'));
|
||||||
|
assert.equal(await manager.setEnabled('s2-ghostmaps', true), false);
|
||||||
|
assert.equal(manager.layers.get('s2-ghostmaps').initialized, false);
|
||||||
|
assert.equal(
|
||||||
|
createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
let reads = 0;
|
||||||
|
let subscriptions = 0;
|
||||||
|
let unsubscribed = 0;
|
||||||
|
const source = {
|
||||||
|
getCatalog: async () => {
|
||||||
|
reads++;
|
||||||
|
return { commit: 'a'.repeat(40), products: [] };
|
||||||
|
},
|
||||||
|
getFile: async () => {
|
||||||
|
throw new Error('Empty catalog must not read files');
|
||||||
|
},
|
||||||
|
subscribe: () => {
|
||||||
|
subscriptions++;
|
||||||
|
return () => {
|
||||||
|
unsubscribed++;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source });
|
||||||
|
assert.equal(supplied['s2-ghostmaps'], source);
|
||||||
|
const configured = catalog(supplied, t);
|
||||||
|
assert.equal(
|
||||||
|
configured.getSourceAvailability('s2-ghostmaps').available,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||||
|
globalThis.document = { addEventListener() {}, removeEventListener() {} };
|
||||||
|
t.after(() => {
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'document', previous);
|
||||||
|
else delete globalThis.document;
|
||||||
|
});
|
||||||
|
const layer = configured.get('s2-ghostmaps');
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
layer.init({ scene: { requestRender() {} } });
|
||||||
|
layer.enable();
|
||||||
|
await layer.update();
|
||||||
|
layer.disable();
|
||||||
|
assert.equal(reads, 1);
|
||||||
|
assert.equal(subscriptions, 1);
|
||||||
|
assert.equal(unsubscribed, 1);
|
||||||
|
assert.equal(layer.getStats().commit, 'a'.repeat(40));
|
||||||
|
assert.equal(fetches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||||
const actual = catalog({}, t);
|
const actual = catalog({}, t);
|
||||||
const manager = new LayerLifecycle(
|
const manager = new LayerLifecycle(
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
|
|||||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
|
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
|
||||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout'
|
|||||||
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
|
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Clear All during the Contacts settle stops the snapshot coming back', () => {
|
||||||
|
const restore = _restoreContextSession.toString();
|
||||||
|
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(');
|
||||||
|
const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff);
|
||||||
|
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff);
|
||||||
|
assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles');
|
||||||
|
assert.ok(fanout > cancelled, 'and before the snapshot fans out');
|
||||||
|
});
|
||||||
|
|
||||||
test('context restore preserves its exact pending target after a failed transition', () => {
|
test('context restore preserves its exact pending target after a failed transition', () => {
|
||||||
const restore = _restoreContextSession.toString();
|
const restore = _restoreContextSession.toString();
|
||||||
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
||||||
|
|||||||
@@ -448,6 +448,14 @@ export const DATA_CREDITS = [
|
|||||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 's2-ghostmaps',
|
||||||
|
html:
|
||||||
|
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
|
||||||
|
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
|
||||||
|
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||||
|
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'drivebc-cctv',
|
key: 'drivebc-cctv',
|
||||||
html:
|
html:
|
||||||
|
|||||||
@@ -1134,7 +1134,8 @@ export function repairKmlRootNamespaces(value, what = 'KML') {
|
|||||||
return text.slice(0, start) + root + text.slice(end);
|
return text.slice(0, start) + root + text.slice(end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseXml(text, what) {
|
/** Parse KML or GPX with the shared namespace repairs and XML validation. */
|
||||||
|
export function parseXml(text, what) {
|
||||||
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
||||||
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
||||||
const doc = new DOMParser().parseFromString(
|
const doc = new DOMParser().parseFromString(
|
||||||
@@ -1150,7 +1151,8 @@ function parseXml(text, what) {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeXml(bytes) {
|
/** Decode XML bytes, honouring a UTF-16 byte-order mark. */
|
||||||
|
export function decodeXml(bytes) {
|
||||||
const encoding =
|
const encoding =
|
||||||
bytes[0] === 0xff && bytes[1] === 0xfe
|
bytes[0] === 0xff && bytes[1] === 0xfe
|
||||||
? 'utf-16le'
|
? 'utf-16le'
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
|||||||
* out-of-grammar ID is rejected outright, because half an address is a
|
* out-of-grammar ID is rejected outright, because half an address is a
|
||||||
* DIFFERENT aircraft, not a shorter name for the same one.
|
* DIFFERENT aircraft, not a shorter name for the same one.
|
||||||
*/
|
*/
|
||||||
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
||||||
/**
|
/**
|
||||||
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
||||||
* complete reserved one-character space plus every two-character base-36
|
* complete reserved one-character space plus every two-character base-36
|
||||||
@@ -655,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||||||
token: 'x',
|
token: 'x',
|
||||||
disposition: 'enabled-only',
|
disposition: 'enabled-only',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 's2-ghostmaps',
|
||||||
|
token: '5',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'satellites',
|
id: 'satellites',
|
||||||
token: 's',
|
token: 's',
|
||||||
|
|||||||
+145
-13
@@ -202,8 +202,8 @@ function encode(state) {
|
|||||||
|
|
||||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||||
assert.equal(validateLayerStateRegistry(), true);
|
assert.equal(validateLayerStateRegistry(), true);
|
||||||
assert.equal(REGISTERED_LAYER_IDS.length, 31);
|
assert.equal(REGISTERED_LAYER_IDS.length, 32);
|
||||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
|
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
|
||||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||||
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||||
}
|
}
|
||||||
assert.equal(nextLayerStateToken(), '5');
|
assert.equal(nextLayerStateToken(), '6');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({
|
nextLayerStateToken({
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
alpha: '5',
|
alpha: '6',
|
||||||
bravo: '6',
|
bravo: '7',
|
||||||
}),
|
}),
|
||||||
'7',
|
'8',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||||
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
future: '5',
|
future: '6',
|
||||||
next: '6',
|
next: '7',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
future: '00',
|
future: '00',
|
||||||
}),
|
}),
|
||||||
/next free token 5/,
|
/next free token 6/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
|
||||||
),
|
),
|
||||||
/next free token 6/,
|
/next free token 7/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||||
@@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated
|
|||||||
coordinator.destroy();
|
coordinator.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function enabledLayerParam(sink) {
|
||||||
|
return new URLSearchParams(encode(sink.provider())).get('l');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Clear All drops a saved layer a context mode had already switched off', async () => {
|
||||||
|
const manager = productionManager();
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const sink = shareSink();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||||
|
await coordinator.start();
|
||||||
|
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||||
|
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||||
|
'earthquakes',
|
||||||
|
'flights',
|
||||||
|
]);
|
||||||
|
// Context isolation is a temporary, passive takeover: the saved choice stays.
|
||||||
|
await manager.setEnabled('earthquakes', false, { origin: 'programmatic' });
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||||
|
'earthquakes',
|
||||||
|
'flights',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatesBefore = sink.updates;
|
||||||
|
const result = await manager.clearSelectedLayers({ origin: 'user' });
|
||||||
|
assert.deepEqual(result.targetIds, ['flights']);
|
||||||
|
assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh');
|
||||||
|
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||||
|
assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers');
|
||||||
|
assert.deepEqual(
|
||||||
|
parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY))
|
||||||
|
.enabledLayerIds,
|
||||||
|
[],
|
||||||
|
'a reload restores nothing',
|
||||||
|
);
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => {
|
||||||
|
let manager;
|
||||||
|
manager = productionManager({
|
||||||
|
// Contacts-style owner: its own teardown releases a borrowed dependency
|
||||||
|
// without awaiting it, which supersedes Clear All's turn for that layer.
|
||||||
|
'military-awareness': {
|
||||||
|
disable() {
|
||||||
|
void manager.setEnabled('flights', false, { origin: 'programmatic' });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const sink = shareSink();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||||
|
await coordinator.start();
|
||||||
|
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||||
|
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
await manager.setEnabled('military-awareness', true, { origin: 'user' });
|
||||||
|
|
||||||
|
const result = await manager.clearSelectedLayers({ origin: 'user' });
|
||||||
|
assert.equal(
|
||||||
|
result.items.find((item) => item.id === 'flights')?.superseded,
|
||||||
|
true,
|
||||||
|
'the fixture reproduces the superseded flights turn',
|
||||||
|
);
|
||||||
|
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||||
|
assert.equal(enabledLayerParam(sink), '');
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear All keeps a layer a newer explicit request turned back on', async () => {
|
||||||
|
const manager = productionManager();
|
||||||
|
const sink = shareSink();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, sink, {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
});
|
||||||
|
await coordinator.start();
|
||||||
|
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||||
|
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
const clearing = manager.clearSelectedLayers({ origin: 'user' });
|
||||||
|
// A direct request issued during the batch owns its layer.
|
||||||
|
const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||||
|
await Promise.all([clearing, reenable]);
|
||||||
|
assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']);
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||||
|
'earthquakes',
|
||||||
|
]);
|
||||||
|
assert.equal(enabledLayerParam(sink), 'e');
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a passive clear never rewrites saved layers', async () => {
|
||||||
|
const manager = productionManager();
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
await coordinator.start();
|
||||||
|
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
await manager.setEnabled('flights', false, { origin: 'programmatic' });
|
||||||
|
const writes = storage.writes.length;
|
||||||
|
await manager.clearSelectedLayers({ origin: 'programmatic' });
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']);
|
||||||
|
assert.equal(storage.writes.length, writes);
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear All cancels a saved restore still waiting to start', async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const manager = productionManager();
|
||||||
|
const sink = shareSink();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, sink, {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
restoreGate: gate.promise,
|
||||||
|
});
|
||||||
|
const params = new URLSearchParams('v=2&l=e.f');
|
||||||
|
const restore = coordinator.start({
|
||||||
|
shareLayerState: decodeLayerStateParams(params),
|
||||||
|
});
|
||||||
|
await manager.clearSelectedLayers({ origin: 'user' });
|
||||||
|
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||||
|
gate.resolve();
|
||||||
|
const results = await restore;
|
||||||
|
for (const id of ['earthquakes', 'flights']) {
|
||||||
|
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||||
|
assert.equal(enabledLayerParam(sink), '');
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
|
|||||||
return this.restorePromise;
|
return this.restorePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the running layer state with a share opened later in this tab
|
||||||
|
* (hash navigation). Every in-flight restore and pending shared Follow is
|
||||||
|
* superseded; the new state restores through the same passive share origin
|
||||||
|
* as start-up, so recipient-local preferences are never written. A share
|
||||||
|
* without a layer payload restores the defaults, as a reload of it would.
|
||||||
|
*/
|
||||||
|
applyShareLayerState({
|
||||||
|
shareLayerState = null,
|
||||||
|
shareCreatedAtMs = null,
|
||||||
|
} = {}) {
|
||||||
|
if (this._destroyed) return Promise.resolve([]);
|
||||||
|
for (const controller of this._restoreControllers.values())
|
||||||
|
controller.abort('superseded-by-share-navigation');
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-share-navigation');
|
||||||
|
const selected = shareLayerState
|
||||||
|
? normalizeLayerState(shareLayerState)
|
||||||
|
: null;
|
||||||
|
this._source = selected ? 'share' : 'legacy-share';
|
||||||
|
this._shareCreatedAtMs =
|
||||||
|
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
||||||
|
this._durableState = selected || createDefaultLayerState();
|
||||||
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
|
this._notifyDurableState();
|
||||||
|
this.restorePromise = this._restoreSelectedState(
|
||||||
|
LAYER_RESTORE_ORIGINS.share,
|
||||||
|
);
|
||||||
|
return this.restorePromise;
|
||||||
|
}
|
||||||
|
|
||||||
get source() {
|
get source() {
|
||||||
return this._source;
|
return this._source;
|
||||||
}
|
}
|
||||||
@@ -220,6 +250,30 @@ export class LayerStateCoordinator {
|
|||||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||||
}
|
}
|
||||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||||
|
if (change.type === 'selection-cleared') {
|
||||||
|
// Only layers a newer request turned back on survive Clear All. Any
|
||||||
|
// other saved layer would reappear from the share link or a reload.
|
||||||
|
const stillEnabled = new Set(change.enabledLayerIds || []);
|
||||||
|
const dropped = this._durableState.enabledLayerIds.filter(
|
||||||
|
(id) => !stillEnabled.has(id),
|
||||||
|
);
|
||||||
|
if (dropped.length === 0) return;
|
||||||
|
for (const layerId of dropped) {
|
||||||
|
this._restoreControllers
|
||||||
|
.get(layerId)
|
||||||
|
?.abort('superseded-by-explicit-visibility');
|
||||||
|
}
|
||||||
|
if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) {
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||||
|
}
|
||||||
|
this._commitExplicit({
|
||||||
|
...this._durableState,
|
||||||
|
enabledLayerIds: this._durableState.enabledLayerIds.filter((id) =>
|
||||||
|
stillEnabled.has(id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (change.type === 'visibility') {
|
if (change.type === 'visibility') {
|
||||||
this._restoreControllers
|
this._restoreControllers
|
||||||
.get(change.layerId)
|
.get(change.layerId)
|
||||||
@@ -312,15 +366,19 @@ export class LayerStateCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _restoreSelectedState(origin) {
|
async _restoreSelectedState(origin) {
|
||||||
|
// One controller set per run: a superseded run must not clear the newer
|
||||||
|
// run's controllers when it settles.
|
||||||
|
const controllers = new Map();
|
||||||
for (const entry of LAYER_STATE_REGISTRY) {
|
for (const entry of LAYER_STATE_REGISTRY) {
|
||||||
this._restoreControllers.set(entry.id, new AbortController());
|
controllers.set(entry.id, new AbortController());
|
||||||
}
|
}
|
||||||
|
this._restoreControllers = controllers;
|
||||||
try {
|
try {
|
||||||
await this._waitForRestoreGate();
|
await this._waitForRestoreGate();
|
||||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||||
const controller = this._restoreControllers.get(entry.id);
|
const controller = controllers.get(entry.id);
|
||||||
const targetEnabled = enabled.has(entry.id);
|
const targetEnabled = enabled.has(entry.id);
|
||||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||||
@@ -370,7 +428,7 @@ export class LayerStateCoordinator {
|
|||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.lastRestoreResults = settled.map((result, index) => {
|
const results = settled.map((result, index) => {
|
||||||
if (result.status === 'fulfilled') return result.value;
|
if (result.status === 'fulfilled') return result.value;
|
||||||
const entry = LAYER_STATE_REGISTRY[index];
|
const entry = LAYER_STATE_REGISTRY[index];
|
||||||
return {
|
return {
|
||||||
@@ -387,9 +445,13 @@ export class LayerStateCoordinator {
|
|||||||
succeeded: false,
|
succeeded: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
// A run superseded by a newer share keeps the newer run's results.
|
||||||
|
if (this._restoreControllers === controllers)
|
||||||
|
this.lastRestoreResults = results;
|
||||||
|
return results.map((result) => ({ ...result }));
|
||||||
} finally {
|
} finally {
|
||||||
this._restoreControllers.clear();
|
if (this._restoreControllers === controllers)
|
||||||
|
this._restoreControllers.clear();
|
||||||
this._notifyDurableState();
|
this._notifyDurableState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||||
);
|
);
|
||||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
|
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||||
{
|
{
|
||||||
@@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
'street-level': '0',
|
'street-level': '0',
|
||||||
'gnss-integrity': '3',
|
'gnss-integrity': '3',
|
||||||
'air-quality': '4',
|
'air-quality': '4',
|
||||||
|
's2-ghostmaps': '5',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
/Duplicate layer-state token reservation/,
|
/Duplicate layer-state token reservation/,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
|
||||||
.future,
|
.future,
|
||||||
'5',
|
'6',
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
@@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
|
|
||||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||||
withPublishedBase(
|
withPublishedBase(
|
||||||
{ rows: [...reservationRows, ['retired-layer', '5']] },
|
{ rows: [...reservationRows, ['retired-layer', '6']] },
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '6');
|
assert.equal(nextLayerStateToken(published), '7');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
newcomer: '6',
|
newcomer: '7',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
|||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
competing: '5',
|
competing: '6',
|
||||||
}),
|
}),
|
||||||
/next free token 6/,
|
/next free token 7/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
newcomer: '5',
|
newcomer: '6',
|
||||||
}),
|
}),
|
||||||
/changed or removed: retired-layer/,
|
/changed or removed: retired-layer/,
|
||||||
);
|
);
|
||||||
@@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
|
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
|
||||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
|
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
|
||||||
mkdirSync(path.join(cwd, 'scripts'));
|
mkdirSync(path.join(cwd, 'scripts'));
|
||||||
const fixtureChecker = path.join(
|
const fixtureChecker = path.join(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-b', '5']],
|
[...reservationRows, ['pr-b', '6']],
|
||||||
[{ id: 'pr-b', token: '5' }],
|
[{ id: 'pr-b', token: '6' }],
|
||||||
);
|
);
|
||||||
const beforeRebase = runCheck();
|
const beforeRebase = runCheck();
|
||||||
assert.equal(beforeRebase.status, 1);
|
assert.equal(beforeRebase.status, 1);
|
||||||
@@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '5' },
|
{ id: 'pr-a', token: '6' },
|
||||||
{ id: 'pr-b', token: '5' },
|
{ id: 'pr-b', token: '6' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const staleRebase = runCheck();
|
const staleRebase = runCheck();
|
||||||
@@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
|||||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||||
|
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '6');
|
assert.equal(nextLayerStateToken(published), '7');
|
||||||
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
|
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
|
||||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
|
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
|
||||||
const helper = spawnSync(
|
const helper = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[realpathSync(fixtureNext), 'pr-b'],
|
[realpathSync(fixtureNext), 'pr-b'],
|
||||||
{ cwd, encoding: 'utf8' },
|
{ cwd, encoding: 'utf8' },
|
||||||
);
|
);
|
||||||
assert.equal(helper.status, 0, helper.stderr);
|
assert.equal(helper.status, 0, helper.stderr);
|
||||||
assert.match(helper.stdout, /pr-b: 6/);
|
assert.match(helper.stdout, /pr-b: 7/);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||||
validBaselineRows,
|
validBaselineRows,
|
||||||
@@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
|||||||
);
|
);
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '5' },
|
{ id: 'pr-a', token: '6' },
|
||||||
{ id: 'pr-b', token: '6' },
|
{ id: 'pr-b', token: '7' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const corrected = runCheck();
|
const corrected = runCheck();
|
||||||
assert.equal(corrected.status, 0, corrected.stderr);
|
assert.equal(corrected.status, 0, corrected.stderr);
|
||||||
assert.match(corrected.stdout, /32 published, 1 new/);
|
assert.match(corrected.stdout, /33 published, 1 new/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||||
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
|
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
|
||||||
const allDigits = parseLayerStateTokenReservations([
|
const allDigits = parseLayerStateTokenReservations([
|
||||||
...reservationRows,
|
...reservationRows,
|
||||||
...digitRows,
|
...digitRows,
|
||||||
@@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
|||||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||||
try {
|
try {
|
||||||
const priorDigits = [...'56789'].map((digit) => [
|
const priorDigits = [...'6789'].map((digit) => [
|
||||||
`qa-prior-digit-${digit}`,
|
`qa-prior-digit-${digit}`,
|
||||||
digit,
|
digit,
|
||||||
]);
|
]);
|
||||||
@@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
|||||||
const params = new URLSearchParams([['v', '2']]);
|
const params = new URLSearchParams([['v', '2']]);
|
||||||
codec.encodeLayerStateParams(params, state);
|
codec.encodeLayerStateParams(params, state);
|
||||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||||
assert.equal(params.get('l').length, 73);
|
assert.equal(params.get('l').length, 75);
|
||||||
const restored = codec.decodeLayerStateParams(params);
|
const restored = codec.decodeLayerStateParams(params);
|
||||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||||
assert.deepEqual(restored?.options, state.options);
|
assert.deepEqual(restored?.options, state.options);
|
||||||
@@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
|||||||
},
|
},
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(Object.keys(published).length, 32);
|
assert.equal(Object.keys(published).length, 33);
|
||||||
assert.equal(published['retired-layer'], '00');
|
assert.equal(published['retired-layer'], '00');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
["radio", "r"],
|
["radio", "r"],
|
||||||
["recent-imagery", "1"],
|
["recent-imagery", "1"],
|
||||||
["rocket-launches", "x"],
|
["rocket-launches", "x"],
|
||||||
|
["s2-ghostmaps", "5"],
|
||||||
["satellites", "s"],
|
["satellites", "s"],
|
||||||
["street-level", "0"],
|
["street-level", "0"],
|
||||||
["telegeography-submarine-cables", "u"],
|
["telegeography-submarine-cables", "u"],
|
||||||
|
|||||||
@@ -1650,6 +1650,15 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clear All owns the whole selection, not only the layers it switched off
|
||||||
|
// itself. A layer already off for a passive reason (isolated by a context
|
||||||
|
// mode, released by a dependency owner, or superseded mid-batch) gets no
|
||||||
|
// OFF of its own here, so publish the set that is still on afterwards.
|
||||||
|
this._notifyListeners({
|
||||||
|
type: 'selection-cleared',
|
||||||
|
origin,
|
||||||
|
enabledLayerIds: [...this.getEnabledLayerIds()],
|
||||||
|
});
|
||||||
const items = targetIds.map((id) => {
|
const items = targetIds.map((id) => {
|
||||||
const state = this.getLayerLifecycleState(id) || {
|
const state = this.getLayerLifecycleState(id) || {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -1052,11 +1052,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
// Globe rotation adds one camera motion and its descriptions; Globe View
|
// Globe rotation adds one camera motion and its descriptions; Globe View
|
||||||
// framing/default guidance updates only move_camera and mode descriptions.
|
// framing/default guidance updates only move_camera and mode descriptions.
|
||||||
// Radio markup and the complete production-request comparison pin locality.
|
// Radio markup and the complete production-request comparison pin locality.
|
||||||
// Restore the base camera/Globe View wording with the globe-rotation append.
|
// Panel adds timeout and named voice/provider targets; consolidated guidance
|
||||||
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
|
// preserves the mission mapping and globe-rotation arguments.
|
||||||
|
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
||||||
|
assert.equal(block.length, 31206, 'serialized tool schema length drifted');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
|
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(
|
const instructions = fs.readFileSync(
|
||||||
|
|||||||
+21
-21
@@ -282,28 +282,28 @@ export class IntelHUD {
|
|||||||
<div class="hud-sonar" aria-hidden="true"></div>
|
<div class="hud-sonar" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="hud-top-bar">
|
<div class="hud-top-bar">
|
||||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
|
||||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
|
||||||
<span class="hud-top-bar-right">PAGE 1/1</span>
|
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-corner hud-top-left">
|
<div class="hud-corner hud-top-left">
|
||||||
<div class="hud-bracket">┌</div>
|
<div class="hud-bracket">┌</div>
|
||||||
<div class="hud-content">
|
<div class="hud-content">
|
||||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
|
||||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
|
||||||
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
|
||||||
<div class="hud-summary-wrap">
|
<div class="hud-summary-wrap">
|
||||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
|
||||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-corner hud-top-right">
|
<div class="hud-corner hud-top-right">
|
||||||
<div class="hud-content" style="text-align:right">
|
<div class="hud-content" style="text-align:right">
|
||||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
|
||||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-bracket">┐</div>
|
<div class="hud-bracket">┐</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,33 +311,33 @@ export class IntelHUD {
|
|||||||
<div class="hud-corner hud-bottom-left">
|
<div class="hud-corner hud-bottom-left">
|
||||||
<div class="hud-bracket">└</div>
|
<div class="hud-bracket">└</div>
|
||||||
<div class="hud-content">
|
<div class="hud-content">
|
||||||
<div id="hud-mgrs">MGRS: ---</div>
|
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
|
||||||
<div id="hud-latlon">--°--'--"N ---°--'--"W</div>
|
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-corner hud-bottom-right">
|
<div class="hud-corner hud-bottom-right">
|
||||||
<div class="hud-content" style="text-align:right">
|
<div class="hud-content" style="text-align:right">
|
||||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
|
||||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
|
||||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-bracket">┘</div>
|
<div class="hud-bracket">┘</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-edge hud-left-edge">
|
<div class="hud-edge hud-left-edge">
|
||||||
<div id="hud-coll">COLL: --:--:--Z</div>
|
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
|
||||||
<div id="hud-ona">ONA: --°</div>
|
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-edge hud-right-edge">
|
<div class="hud-edge hud-right-edge">
|
||||||
<div>BAND: PAN</div>
|
<div class="hud-readout"><div>BAND: PAN</div></div>
|
||||||
<div>BITS: 11</div>
|
<div class="hud-readout"><div>BITS: 11</div></div>
|
||||||
<div>LVL: 1A</div>
|
<div class="hud-readout"><div>LVL: 1A</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-bottom-bar">
|
<div class="hud-bottom-bar">
|
||||||
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span>
|
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this._el.dataset.variant = this._variant;
|
this._el.dataset.variant = this._variant;
|
||||||
|
|||||||
@@ -302,9 +302,16 @@
|
|||||||
"CLOUD UNKNOWN": "NUBES DESCONOCIDAS",
|
"CLOUD UNKNOWN": "NUBES DESCONOCIDAS",
|
||||||
"Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados",
|
"Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados",
|
||||||
"Link copied!": "¡Enlace copiado!",
|
"Link copied!": "¡Enlace copiado!",
|
||||||
|
"Link shared": "Enlace compartido",
|
||||||
|
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
||||||
|
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||||
|
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||||
|
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||||
"Copy failed": "No se pudo copiar",
|
"Copy failed": "No se pudo copiar",
|
||||||
"Location not found": "Ubicación no encontrada",
|
"Location not found": "Ubicación no encontrada",
|
||||||
"Search failed": "No se pudo completar la búsqueda",
|
"Search failed": "No se pudo completar la búsqueda",
|
||||||
|
"Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.",
|
||||||
|
"Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.",
|
||||||
"Fly to a POI first": "Ve primero a un punto de interés",
|
"Fly to a POI first": "Ve primero a un punto de interés",
|
||||||
"Clearing selected data layers": "Limpiando las capas de datos seleccionadas",
|
"Clearing selected data layers": "Limpiando las capas de datos seleccionadas",
|
||||||
"Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas",
|
"Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas",
|
||||||
@@ -2942,6 +2949,34 @@
|
|||||||
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||||
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
|
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
|
||||||
|
"S2 GhostMaps": "S2 GhostMaps",
|
||||||
|
"Intel Picture": "Panorama de inteligencia",
|
||||||
|
"Kinetic 30d": "Cinético 30 d",
|
||||||
|
"Border Crisis": "Crisis fronteriza",
|
||||||
|
"Iran Picture": "Panorama de Irán",
|
||||||
|
"Iran Kinetic": "Irán cinético",
|
||||||
|
"Yemen FLOT": "FLOT de Yemen",
|
||||||
|
"Latest Drop": "Última publicación",
|
||||||
|
"GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps",
|
||||||
|
"GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible",
|
||||||
|
"Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado",
|
||||||
|
"{count} source links ↗ · click card to open the first": {
|
||||||
|
"one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo",
|
||||||
|
"many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero",
|
||||||
|
"other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero"
|
||||||
|
},
|
||||||
|
"S2 · {name}": "S2 · {name}",
|
||||||
|
"Report": "Informe",
|
||||||
|
"Open the source for {title}": "Abrir la fuente de {title}",
|
||||||
|
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s",
|
||||||
|
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo",
|
||||||
|
"Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…",
|
||||||
|
"{count} placemarks": {
|
||||||
|
"one": "{count} marcador",
|
||||||
|
"many": "{count} de marcadores",
|
||||||
|
"other": "{count} marcadores"
|
||||||
|
},
|
||||||
|
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe.",
|
||||||
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
|
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
|
||||||
"hemisphere|N": "N",
|
"hemisphere|N": "N",
|
||||||
"hemisphere|S": "S",
|
"hemisphere|S": "S",
|
||||||
@@ -2952,5 +2987,19 @@
|
|||||||
"Layers": "Capas",
|
"Layers": "Capas",
|
||||||
"Search": "Buscar",
|
"Search": "Buscar",
|
||||||
"More": "Más",
|
"More": "Más",
|
||||||
"Panels": "Paneles"
|
"Panels": "Paneles",
|
||||||
|
"Recent speech": "Habla reciente",
|
||||||
|
"Voice Settings": "Ajustes de voz",
|
||||||
|
"Preview": "Vista previa",
|
||||||
|
"Settings": "Ajustes",
|
||||||
|
"Close voice panel": "Cerrar panel de voz",
|
||||||
|
"Show Voice Preview": "Mostrar vista previa de voz",
|
||||||
|
"Voice inactivity timeout": "Tiempo de inactividad de voz",
|
||||||
|
"Custom": "Personalizado",
|
||||||
|
"Minutes": "Minutos",
|
||||||
|
"These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.",
|
||||||
|
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
|
||||||
|
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
|
||||||
|
"Preview is off.": "La vista previa está desactivada.",
|
||||||
|
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
|
||||||
}
|
}
|
||||||
@@ -294,6 +294,11 @@
|
|||||||
"Scene run complete": "Воспроизведение сцены завершено",
|
"Scene run complete": "Воспроизведение сцены завершено",
|
||||||
"CCTV layer unavailable": "Слой CCTV недоступен",
|
"CCTV layer unavailable": "Слой CCTV недоступен",
|
||||||
"Link copied!": "Ссылка скопирована!",
|
"Link copied!": "Ссылка скопирована!",
|
||||||
|
"Link shared": "Ссылка отправлена",
|
||||||
|
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
||||||
|
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
|
||||||
|
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
|
||||||
|
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||||
"Copy failed": "Не удалось скопировать",
|
"Copy failed": "Не удалось скопировать",
|
||||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||||
"LOCATION": "МЕСТО",
|
"LOCATION": "МЕСТО",
|
||||||
@@ -525,6 +530,8 @@
|
|||||||
"Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса",
|
"Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса",
|
||||||
"Location not found": "Место не найдено",
|
"Location not found": "Место не найдено",
|
||||||
"Search failed": "Не удалось выполнить поиск",
|
"Search failed": "Не удалось выполнить поиск",
|
||||||
|
"Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.",
|
||||||
|
"Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.",
|
||||||
"MAP": "КАРТА",
|
"MAP": "КАРТА",
|
||||||
"panel": "панель",
|
"panel": "панель",
|
||||||
"Expand {name}": "Развернуть {name}",
|
"Expand {name}": "Развернуть {name}",
|
||||||
@@ -3009,6 +3016,36 @@
|
|||||||
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||||
"Token request is too large": "Запрос токена слишком велик",
|
"Token request is too large": "Запрос токена слишком велик",
|
||||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
|
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
|
||||||
|
"S2 GhostMaps": "S2 GhostMaps",
|
||||||
|
"Intel Picture": "Разведкартина",
|
||||||
|
"Kinetic 30d": "Боевые 30 дн.",
|
||||||
|
"Border Crisis": "Кризис на границе",
|
||||||
|
"Iran Picture": "Иран: картина",
|
||||||
|
"Iran Kinetic": "Иран: боевые",
|
||||||
|
"Yemen FLOT": "Йемен: линия фронта",
|
||||||
|
"Latest Drop": "Новый выпуск",
|
||||||
|
"GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps",
|
||||||
|
"GhostMaps catalog unavailable": "Каталог GhostMaps недоступен",
|
||||||
|
"Malformed GhostMaps catalog": "Повреждённый каталог GhostMaps",
|
||||||
|
"{count} source links ↗ · click card to open the first": {
|
||||||
|
"one": "{count} ссылка на источник ↗ · нажмите на карточку, чтобы открыть",
|
||||||
|
"few": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую",
|
||||||
|
"many": "{count} ссылок на источники ↗ · нажмите на карточку, чтобы открыть первую",
|
||||||
|
"other": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую"
|
||||||
|
},
|
||||||
|
"S2 · {name}": "S2 · {name}",
|
||||||
|
"Report": "Сводка",
|
||||||
|
"Open the source for {title}": "Открыть источник: {title}",
|
||||||
|
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с",
|
||||||
|
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени",
|
||||||
|
"Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…",
|
||||||
|
"{count} placemarks": {
|
||||||
|
"one": "{count} метка",
|
||||||
|
"few": "{count} метки",
|
||||||
|
"many": "{count} меток",
|
||||||
|
"other": "{count} метки"
|
||||||
|
},
|
||||||
|
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку.",
|
||||||
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
|
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
|
||||||
"hemisphere|N": "С",
|
"hemisphere|N": "С",
|
||||||
"hemisphere|S": "Ю",
|
"hemisphere|S": "Ю",
|
||||||
@@ -3019,5 +3056,19 @@
|
|||||||
"Layers": "Слои",
|
"Layers": "Слои",
|
||||||
"Search": "Поиск",
|
"Search": "Поиск",
|
||||||
"More": "Ещё",
|
"More": "Ещё",
|
||||||
"Panels": "Панели"
|
"Panels": "Панели",
|
||||||
|
"Recent speech": "Недавняя речь",
|
||||||
|
"Voice Settings": "Настройки голоса",
|
||||||
|
"Preview": "Предпросмотр",
|
||||||
|
"Settings": "Настройки",
|
||||||
|
"Close voice panel": "Закрыть голосовую панель",
|
||||||
|
"Show Voice Preview": "Показывать предпросмотр голоса",
|
||||||
|
"Voice inactivity timeout": "Тайм-аут бездействия голоса",
|
||||||
|
"Custom": "Другое",
|
||||||
|
"Minutes": "Минуты",
|
||||||
|
"These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.",
|
||||||
|
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
|
||||||
|
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
||||||
|
"Preview is off.": "Предпросмотр отключён.",
|
||||||
|
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
|
||||||
}
|
}
|
||||||
@@ -1477,6 +1477,11 @@
|
|||||||
"LIVE SIGNALS": "实时信号",
|
"LIVE SIGNALS": "实时信号",
|
||||||
"CCTV layer unavailable": "CCTV 图层不可用",
|
"CCTV layer unavailable": "CCTV 图层不可用",
|
||||||
"Link copied!": "链接已复制!",
|
"Link copied!": "链接已复制!",
|
||||||
|
"Link shared": "链接已分享",
|
||||||
|
"This share link can't be opened": "无法打开此分享链接",
|
||||||
|
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
|
||||||
|
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
|
||||||
|
"Shared vessel is unavailable": "分享的船只不可用",
|
||||||
"Copy failed": "复制失败",
|
"Copy failed": "复制失败",
|
||||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||||
"Tilt map to oblique view": "将地图倾斜至斜视角",
|
"Tilt map to oblique view": "将地图倾斜至斜视角",
|
||||||
@@ -1814,6 +1819,8 @@
|
|||||||
"ORBIT": "环绕",
|
"ORBIT": "环绕",
|
||||||
"Location not found": "未找到位置",
|
"Location not found": "未找到位置",
|
||||||
"Search failed": "搜索失败",
|
"Search failed": "搜索失败",
|
||||||
|
"Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。",
|
||||||
|
"Press Enter to try again.": "按 Enter 重试。",
|
||||||
"Detailed outline unavailable": "无法获取详细轮廓",
|
"Detailed outline unavailable": "无法获取详细轮廓",
|
||||||
"Fly to a POI first": "请先飞往一个 POI",
|
"Fly to a POI first": "请先飞往一个 POI",
|
||||||
"Reset to full globe view": "重置为完整地球视图",
|
"Reset to full globe view": "重置为完整地球视图",
|
||||||
@@ -2818,5 +2825,43 @@
|
|||||||
"Layers": "图层",
|
"Layers": "图层",
|
||||||
"Search": "搜索",
|
"Search": "搜索",
|
||||||
"More": "更多",
|
"More": "更多",
|
||||||
"Panels": "面板"
|
"Panels": "面板",
|
||||||
|
"Recent speech": "最近语音",
|
||||||
|
"Voice Settings": "语音设置",
|
||||||
|
"Preview": "预览",
|
||||||
|
"Settings": "设置",
|
||||||
|
"Close voice panel": "关闭语音面板",
|
||||||
|
"Show Voice Preview": "显示语音预览",
|
||||||
|
"Voice inactivity timeout": "语音空闲超时",
|
||||||
|
"Custom": "自定义",
|
||||||
|
"Minutes": "分钟",
|
||||||
|
"These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。",
|
||||||
|
"Your choice applies now, but could not be saved.": "选择已生效,但无法保存。",
|
||||||
|
"Voice replies appear here.": "语音回复显示在此处。",
|
||||||
|
"Preview is off.": "预览已关闭。",
|
||||||
|
"Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。",
|
||||||
|
"S2 GhostMaps": "S2 GhostMaps",
|
||||||
|
"Intel Picture": "情报态势图",
|
||||||
|
"Kinetic 30d": "近 30 天军事行动",
|
||||||
|
"Border Crisis": "边境危机",
|
||||||
|
"Iran Picture": "伊朗态势图",
|
||||||
|
"Iran Kinetic": "伊朗军事行动",
|
||||||
|
"Yemen FLOT": "也门前线",
|
||||||
|
"Latest Drop": "最新发布",
|
||||||
|
"GhostMaps file could not be read": "无法读取 GhostMaps 文件",
|
||||||
|
"GhostMaps catalog unavailable": "GhostMaps 目录不可用",
|
||||||
|
"Malformed GhostMaps catalog": "GhostMaps 目录格式无效",
|
||||||
|
"{count} source links ↗ · click card to open the first": {
|
||||||
|
"other": "{count} 个来源链接 ↗ · 点击卡片打开第一个"
|
||||||
|
},
|
||||||
|
"S2 · {name}": "S2 · {name}",
|
||||||
|
"Report": "报告",
|
||||||
|
"Open the source for {title}": "打开 {title} 的来源",
|
||||||
|
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "实时 · GitHub {commit} · {message} · 每 {seconds} 秒检查一次",
|
||||||
|
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新连接以获取实时更新",
|
||||||
|
"Connecting to the S2 GhostMaps repository…": "正在连接 S2 GhostMaps 仓库…",
|
||||||
|
"{count} placemarks": {
|
||||||
|
"other": "{count} 个地标"
|
||||||
|
},
|
||||||
|
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,8 @@
|
|||||||
"Radio",
|
"Radio",
|
||||||
"Reggae",
|
"Reggae",
|
||||||
"Rock",
|
"Rock",
|
||||||
|
"S2 GhostMaps",
|
||||||
|
"S2 · {name}",
|
||||||
"SAR",
|
"SAR",
|
||||||
"SE",
|
"SE",
|
||||||
"SECTOR {lat} {lon}",
|
"SECTOR {lat} {lon}",
|
||||||
@@ -135,6 +137,8 @@
|
|||||||
"NVG",
|
"NVG",
|
||||||
"R&B",
|
"R&B",
|
||||||
"REC",
|
"REC",
|
||||||
|
"S2 GhostMaps",
|
||||||
|
"S2 · {name}",
|
||||||
"VS",
|
"VS",
|
||||||
"WHOT/BHOT"
|
"WHOT/BHOT"
|
||||||
],
|
],
|
||||||
@@ -165,6 +169,8 @@
|
|||||||
"NVG",
|
"NVG",
|
||||||
"REC",
|
"REC",
|
||||||
"SRC {value}",
|
"SRC {value}",
|
||||||
|
"S2 GhostMaps",
|
||||||
|
"S2 · {name}",
|
||||||
"Sentinel-2",
|
"Sentinel-2",
|
||||||
"Techno",
|
"Techno",
|
||||||
"Trance",
|
"Trance",
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export async function initKeySetup({
|
|||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
delete root.dataset.ready;
|
||||||
unregisterUnsavedWork();
|
unregisterUnsavedWork();
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
signal?.removeEventListener('abort', destroy);
|
signal?.removeEventListener('abort', destroy);
|
||||||
@@ -591,6 +592,7 @@ export async function initKeySetup({
|
|||||||
chip.addEventListener('click', openDialog);
|
chip.addEventListener('click', openDialog);
|
||||||
closeButton?.addEventListener('click', close);
|
closeButton?.addEventListener('click', close);
|
||||||
applyButton?.addEventListener('click', onApply);
|
applyButton?.addEventListener('click', onApply);
|
||||||
|
root.dataset.ready = 'true';
|
||||||
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
||||||
rowsHost?.addEventListener('click', (event) => {
|
rowsHost?.addEventListener('click', (event) => {
|
||||||
const authButton = event.target?.closest?.(
|
const authButton = event.target?.closest?.(
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// S2 Underground GhostMaps catalog: which KMZ in the public repository is the
|
||||||
|
// current edition of each product. Pure and portable (the server builds the
|
||||||
|
// catalog, tests pin the rules); no i18n, no DOM, no Cesium.
|
||||||
|
|
||||||
|
export const GHOSTMAPS_REPO = Object.freeze({
|
||||||
|
owner: 's2underground',
|
||||||
|
name: 'GhostMaps',
|
||||||
|
branch: 'main',
|
||||||
|
url: 'https://github.com/s2underground/GhostMaps',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */
|
||||||
|
export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
|
||||||
|
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated products, in panel order. Each names a series of dated uploads; the
|
||||||
|
* newest edition wins. Patterns match whole repository paths, so a renamed
|
||||||
|
* folder fails closed (the product drops out) instead of matching elsewhere.
|
||||||
|
*/
|
||||||
|
export const GHOSTMAPS_PRODUCTS = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
id: 'cip',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Common Intelligence Picture',
|
||||||
|
defaultOn: true,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'kinetic-30d',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Kinetic Activity (30 days)',
|
||||||
|
defaultOn: true,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'border-crisis',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Border Crisis Map',
|
||||||
|
defaultOn: false,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'iran-cip',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Iran Common Intelligence Picture',
|
||||||
|
defaultOn: false,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'iran-kinetic',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Iran Kinetic Events',
|
||||||
|
defaultOn: false,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'yemen-flot',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Yemen FLOT',
|
||||||
|
defaultOn: false,
|
||||||
|
pattern: new RegExp(
|
||||||
|
`^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`,
|
||||||
|
'i',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** The newest incident product outside the curated series and the archive. */
|
||||||
|
export const GHOSTMAPS_LATEST_DROP = Object.freeze({
|
||||||
|
id: 'latest-drop',
|
||||||
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
||||||
|
title: 'Latest incident drop',
|
||||||
|
defaultOn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const LATEST_DROP_EXCLUDED =
|
||||||
|
/\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i;
|
||||||
|
|
||||||
|
function escape(text) {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
'jan',
|
||||||
|
'feb',
|
||||||
|
'mar',
|
||||||
|
'apr',
|
||||||
|
'may',
|
||||||
|
'jun',
|
||||||
|
'jul',
|
||||||
|
'aug',
|
||||||
|
'sep',
|
||||||
|
'oct',
|
||||||
|
'nov',
|
||||||
|
'dec',
|
||||||
|
];
|
||||||
|
const DATE_IN_NAME =
|
||||||
|
/(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i;
|
||||||
|
const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date and edition written into an S2 file name, e.g.
|
||||||
|
* `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names
|
||||||
|
* report `dateMs: null` and sort below every dated edition.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @returns {{dateMs: ?number, edition: number}}
|
||||||
|
*/
|
||||||
|
export function parseGhostMapsEdition(fileName) {
|
||||||
|
const stem = String(fileName || '').replace(/\.km[lz]$/i, '');
|
||||||
|
const match = DATE_IN_NAME.exec(stem);
|
||||||
|
let dateMs = null;
|
||||||
|
if (match) {
|
||||||
|
const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase());
|
||||||
|
const day = Number(match[2]);
|
||||||
|
const year = Number(match[3]);
|
||||||
|
const at = Date.UTC(year, month, day);
|
||||||
|
// Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts.
|
||||||
|
if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at;
|
||||||
|
}
|
||||||
|
const tail = match ? stem.slice(match.index + match[0].length) : stem;
|
||||||
|
const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0);
|
||||||
|
return { dateMs, edition };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newer(a, b) {
|
||||||
|
if (!b) return true;
|
||||||
|
const ad = a.dateMs ?? -Infinity;
|
||||||
|
const bd = b.dateMs ?? -Infinity;
|
||||||
|
if (ad !== bd) return ad > bd;
|
||||||
|
if (a.edition !== b.edition) return a.edition > b.edition;
|
||||||
|
return a.path > b.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */
|
||||||
|
export function ghostMapsFileTitle(fileName) {
|
||||||
|
return String(fileName || '')
|
||||||
|
.replace(/\.km[lz]$/i, '')
|
||||||
|
.replace(/^S2Underground_/i, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the product catalog from a recursive git tree listing.
|
||||||
|
* @param {Array<{path: string, type: string, sha: string, size?: number}>} tree
|
||||||
|
* @returns {Array<{id: string, title: string, defaultOn: boolean,
|
||||||
|
* path: string, fileName: string, fileTitle: string, version: string,
|
||||||
|
* size: number, dateMs: ?number, edition: number}>}
|
||||||
|
*/
|
||||||
|
export function buildGhostMapsCatalog(tree) {
|
||||||
|
const best = new Map();
|
||||||
|
let drop = null;
|
||||||
|
for (const entry of Array.isArray(tree) ? tree : []) {
|
||||||
|
if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue;
|
||||||
|
if (!/\.kmz$/i.test(entry.path)) continue;
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue;
|
||||||
|
const size = Number(entry.size);
|
||||||
|
if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES)
|
||||||
|
continue;
|
||||||
|
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
||||||
|
const candidate = {
|
||||||
|
path: entry.path,
|
||||||
|
fileName,
|
||||||
|
version: entry.sha,
|
||||||
|
size,
|
||||||
|
...parseGhostMapsEdition(fileName),
|
||||||
|
};
|
||||||
|
const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) =>
|
||||||
|
pattern.test(entry.path),
|
||||||
|
);
|
||||||
|
if (product) {
|
||||||
|
if (newer(candidate, best.get(product.id)))
|
||||||
|
best.set(product.id, candidate);
|
||||||
|
} else if (
|
||||||
|
entry.path.startsWith(INCIDENTS) &&
|
||||||
|
!LATEST_DROP_EXCLUDED.test(`/${entry.path}`) &&
|
||||||
|
candidate.dateMs != null &&
|
||||||
|
newer(candidate, drop)
|
||||||
|
) {
|
||||||
|
drop = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
||||||
|
const file =
|
||||||
|
product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id);
|
||||||
|
if (!file) continue;
|
||||||
|
rows.push({
|
||||||
|
id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
defaultOn: product.defaultOn,
|
||||||
|
...file,
|
||||||
|
fileTitle: ghostMapsFileTitle(file.fileName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw download URL pinned to a commit, so a CDN can never hand back an older
|
||||||
|
* edition for a newer catalog.
|
||||||
|
* @param {string} commit 40-hex commit sha.
|
||||||
|
* @param {string} path Repository path.
|
||||||
|
*/
|
||||||
|
export function ghostMapsRawUrl(commit, path) {
|
||||||
|
const encoded = path.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch head from a git smart-HTTP ref advertisement
|
||||||
|
* (`/info/refs?service=git-upload-pack`), or null.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} [branch]
|
||||||
|
* @returns {?string}
|
||||||
|
*/
|
||||||
|
export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) {
|
||||||
|
const ref = `refs/heads/${branch}`;
|
||||||
|
for (const line of String(text || '').split('\n')) {
|
||||||
|
// pkt-line: 4 hex length digits, then "<sha> <ref>" (the first ref line
|
||||||
|
// also carries a NUL and the capability list).
|
||||||
|
const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line);
|
||||||
|
if (match && match[2] === ref) return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildGhostMapsCatalog,
|
||||||
|
ghostMapsFileTitle,
|
||||||
|
ghostMapsRawUrl,
|
||||||
|
parseGhostMapsEdition,
|
||||||
|
parseGitRefAdvertisement,
|
||||||
|
} from './catalog.js';
|
||||||
|
|
||||||
|
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
|
||||||
|
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
|
||||||
|
let n = 0;
|
||||||
|
const blob = (path, size = 1000) => ({
|
||||||
|
path,
|
||||||
|
type: 'blob',
|
||||||
|
size,
|
||||||
|
sha: (++n).toString(16).padStart(40, '0'),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editions read the date and export/version number written in the name', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseGhostMapsEdition(
|
||||||
|
'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz',
|
||||||
|
),
|
||||||
|
{ dateMs: Date.UTC(2026, 9, 16), edition: 1 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseGhostMapsEdition(
|
||||||
|
'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz',
|
||||||
|
),
|
||||||
|
{ dateMs: Date.UTC(2026, 8, 21), edition: 2 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'),
|
||||||
|
{
|
||||||
|
dateMs: Date.UTC(2026, 7, 24),
|
||||||
|
edition: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'),
|
||||||
|
{
|
||||||
|
dateMs: Date.UTC(2025, 8, 5),
|
||||||
|
edition: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'),
|
||||||
|
{
|
||||||
|
dateMs: null,
|
||||||
|
edition: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// An impossible date is not a date.
|
||||||
|
assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each product keeps its newest edition by date, then edition number', () => {
|
||||||
|
const master = `${ARCGIS}Common Intelligence Picture/Master Database/`;
|
||||||
|
const tree = [
|
||||||
|
blob(
|
||||||
|
`${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`,
|
||||||
|
),
|
||||||
|
// A European-only subset in a subfolder is not the border map.
|
||||||
|
blob(
|
||||||
|
`${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`,
|
||||||
|
),
|
||||||
|
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`),
|
||||||
|
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`),
|
||||||
|
{ path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) },
|
||||||
|
];
|
||||||
|
const rows = buildGhostMapsCatalog(tree);
|
||||||
|
const byId = Object.fromEntries(rows.map((row) => [row.id, row]));
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map(({ id }) => id),
|
||||||
|
['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'],
|
||||||
|
);
|
||||||
|
assert.match(byId.cip.fileName, /October_16_2026/);
|
||||||
|
assert.equal(byId.cip.defaultOn, true);
|
||||||
|
assert.equal(byId['kinetic-30d'].defaultOn, true);
|
||||||
|
assert.match(byId['border-crisis'].fileName, /Export_v2/);
|
||||||
|
assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz');
|
||||||
|
assert.equal(byId.cip.version, tree[1].sha);
|
||||||
|
assert.equal(
|
||||||
|
byId.cip.fileTitle,
|
||||||
|
'Common Intelligence Picture October 16 2026 Export 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('latest drop is the newest dated incident file outside curated, archived and base data', () => {
|
||||||
|
const rows = buildGhostMapsCatalog([
|
||||||
|
blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`),
|
||||||
|
blob(
|
||||||
|
`${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`,
|
||||||
|
),
|
||||||
|
blob(
|
||||||
|
`${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`,
|
||||||
|
),
|
||||||
|
blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`),
|
||||||
|
blob(
|
||||||
|
`${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`,
|
||||||
|
),
|
||||||
|
blob(`${INCIDENTS}Undated Product.kmz`),
|
||||||
|
]);
|
||||||
|
const drop = rows.find(({ id }) => id === 'latest-drop');
|
||||||
|
assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz');
|
||||||
|
assert.ok(rows.some(({ id }) => id === 'iran-kinetic'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized, empty, non-KMZ and malformed entries are skipped', () => {
|
||||||
|
const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`;
|
||||||
|
assert.deepEqual(
|
||||||
|
buildGhostMapsCatalog([
|
||||||
|
{ ...blob(path), size: 51 * 1024 * 1024 },
|
||||||
|
{ ...blob(path), size: 0 },
|
||||||
|
{ ...blob(path), sha: 'not-a-sha' },
|
||||||
|
blob(path.replace(/\.kmz$/, '.zip')),
|
||||||
|
null,
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(buildGhostMapsCatalog(undefined), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw URLs are pinned to the commit and encode every path segment', () => {
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'),
|
||||||
|
`https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`,
|
||||||
|
);
|
||||||
|
assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the branch head comes from the smart-HTTP ref advertisement', () => {
|
||||||
|
const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d';
|
||||||
|
const other = 'b'.repeat(40);
|
||||||
|
const text = [
|
||||||
|
'001e# service=git-upload-pack',
|
||||||
|
`0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`,
|
||||||
|
`003f${other} refs/heads/main-old`,
|
||||||
|
`003d${head} refs/heads/main`,
|
||||||
|
'0000',
|
||||||
|
].join('\n');
|
||||||
|
assert.equal(parseGitRefAdvertisement(text), head);
|
||||||
|
assert.equal(parseGitRefAdvertisement(text, 'dev'), null);
|
||||||
|
assert.equal(parseGitRefAdvertisement(''), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
decodeXml,
|
||||||
|
kmzImageResolver,
|
||||||
|
kmzMainDocument,
|
||||||
|
parseXml,
|
||||||
|
readZipEntries,
|
||||||
|
resolveKmzPath,
|
||||||
|
} from '../../data/geoFileImport.js';
|
||||||
|
import {
|
||||||
|
formatNumber,
|
||||||
|
formatUtcDayMonth,
|
||||||
|
msg,
|
||||||
|
t,
|
||||||
|
tn,
|
||||||
|
} from '../../i18n/index.js';
|
||||||
|
import { createHorizonCull } from '../streetLevel/view.js';
|
||||||
|
import { readGhostMapsKml } from './kml.js';
|
||||||
|
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
||||||
|
export * from './catalog.js';
|
||||||
|
export * from './records.js';
|
||||||
|
export { readGhostMapsKml } from './kml.js';
|
||||||
|
export { createGhostMapsSource } from './source.js';
|
||||||
|
|
||||||
|
export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
||||||
|
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||||
|
const PICK_PREFIX = 'ghostmaps:';
|
||||||
|
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||||
|
const ACCENT = '#c8ff00';
|
||||||
|
const MAX_ICON_BYTES = 512 * 1024;
|
||||||
|
const CARD_HOST_OPTIONS = Object.freeze({
|
||||||
|
cohortLimit: 1,
|
||||||
|
collisionCapacity: 1,
|
||||||
|
moving: false,
|
||||||
|
});
|
||||||
|
const MAX_CARD_LINES = 12;
|
||||||
|
|
||||||
|
/** Panel names for the catalog's product ids (the catalog sends English). */
|
||||||
|
const PRODUCT_LABELS = Object.freeze({
|
||||||
|
cip: msg('Intel Picture'),
|
||||||
|
'kinetic-30d': msg('Kinetic 30d'),
|
||||||
|
'border-crisis': msg('Border Crisis'),
|
||||||
|
'iran-cip': msg('Iran Picture'),
|
||||||
|
'iran-kinetic': msg('Iran Kinetic'),
|
||||||
|
'yemen-flot': msg('Yemen FLOT'),
|
||||||
|
'latest-drop': msg('Latest Drop'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One distinct fallback marker color per product. */
|
||||||
|
const PRODUCT_COLORS = Object.freeze({
|
||||||
|
cip: '#c8ff00',
|
||||||
|
'kinetic-30d': '#ff5a36',
|
||||||
|
'border-crisis': '#ffb547',
|
||||||
|
'iran-cip': '#39d0ff',
|
||||||
|
'iran-kinetic': '#ff3b8d',
|
||||||
|
'yemen-flot': '#5dff9f',
|
||||||
|
'latest-drop': '#b48cff',
|
||||||
|
});
|
||||||
|
|
||||||
|
function defaultStorage() {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSelection(storage) {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null');
|
||||||
|
return Array.isArray(value) ? new Set(value.map(String)) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredSelection(storage, selected) {
|
||||||
|
try {
|
||||||
|
storage?.setItem(STORAGE_KEY, JSON.stringify([...selected]));
|
||||||
|
} catch {
|
||||||
|
/* private window or blocked storage: the selection lasts this session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round marker for placemarks whose icon is missing or remote. */
|
||||||
|
const markerCache = new Map();
|
||||||
|
function markerImage(color) {
|
||||||
|
if (markerCache.has(color)) return markerCache.get(color);
|
||||||
|
let url = null;
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = 20;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(10, 10, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||||||
|
ctx.stroke();
|
||||||
|
url = canvas.toDataURL();
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
markerCache.set(color, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cesiumColor = (rgba, fallback, alpha) =>
|
||||||
|
rgba
|
||||||
|
? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3])
|
||||||
|
: Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1);
|
||||||
|
|
||||||
|
function anchorOf(geometry) {
|
||||||
|
if (geometry.type === 'point') return geometry.position;
|
||||||
|
const ring =
|
||||||
|
geometry.type === 'polygon' ? geometry.outer : geometry.positions;
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
lon += x;
|
||||||
|
lat += y;
|
||||||
|
}
|
||||||
|
return [lon / ring.length, lat / ring.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S2 Underground GhostMaps: the current editions of S2's public KMZ intel
|
||||||
|
* products, kept in step with the GitHub repository. The proxy watches the
|
||||||
|
* branch head and pushes each new commit; only changed products reload.
|
||||||
|
*/
|
||||||
|
export function createGhostMapsLayer({
|
||||||
|
source,
|
||||||
|
overlayHost = null,
|
||||||
|
screenSpaceEventHandlerFactory = null,
|
||||||
|
picking = null,
|
||||||
|
pointer = null,
|
||||||
|
openExternal = null,
|
||||||
|
storage = defaultStorage(),
|
||||||
|
} = {}) {
|
||||||
|
if (typeof source?.getCatalog !== 'function')
|
||||||
|
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||||
|
let _viewer = null;
|
||||||
|
let _enabled = false;
|
||||||
|
let _catalog = null;
|
||||||
|
let _lastError = null;
|
||||||
|
let _unsubscribe = null;
|
||||||
|
let _stream = 'closed';
|
||||||
|
let _queue = Promise.resolve();
|
||||||
|
let _clickHandler = null;
|
||||||
|
let _rowListener = null;
|
||||||
|
let _selectedKey = null;
|
||||||
|
let _selectedCardId = null;
|
||||||
|
let _selectedLink = null;
|
||||||
|
let _lifetime = new AbortController();
|
||||||
|
let _selection = readStoredSelection(storage);
|
||||||
|
/** productId → {version, dataSource, urls, count, features} */
|
||||||
|
const _loaded = new Map();
|
||||||
|
/** productId → in-flight version */
|
||||||
|
const _loading = new Map();
|
||||||
|
/** productId → error text */
|
||||||
|
const _failed = new Map();
|
||||||
|
|
||||||
|
const notifyRow = () => _rowListener?.();
|
||||||
|
const render = () => _viewer?.scene?.requestRender?.();
|
||||||
|
// Pins skip the depth test, and with Google 3D tiles the globe is hidden,
|
||||||
|
// so nothing else stops far-side pins drawing through the planet.
|
||||||
|
const _horizon = createHorizonCull({
|
||||||
|
getViewer: () => _viewer,
|
||||||
|
items: function* () {
|
||||||
|
for (const item of _loaded.values()) yield* item.pins;
|
||||||
|
},
|
||||||
|
onChange: render,
|
||||||
|
});
|
||||||
|
const canSelect = () =>
|
||||||
|
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||||
|
|
||||||
|
function wanted(product) {
|
||||||
|
return _selection ? _selection.has(product.id) : product.defaultOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIds() {
|
||||||
|
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unload(id) {
|
||||||
|
const item = _loaded.get(id);
|
||||||
|
if (!item) return;
|
||||||
|
_loaded.delete(id);
|
||||||
|
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||||
|
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one product's data source off-scene, then swap it in. */
|
||||||
|
async function load(product, signal) {
|
||||||
|
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const entries = await readZipEntries(bytes);
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const main = kmzMainDocument(entries);
|
||||||
|
if (!main) throw new Error(msg('That KMZ holds no KML document.'));
|
||||||
|
const urls = [];
|
||||||
|
const resolvePacked = kmzImageResolver(entries, main, (url) =>
|
||||||
|
urls.push(url),
|
||||||
|
);
|
||||||
|
// Icons only: ArcGIS exports also pack multi-megabyte photo attachments,
|
||||||
|
// which fall back to the product marker instead of being decoded.
|
||||||
|
const resolveImage = (href) => {
|
||||||
|
const path = resolveKmzPath(href, main, entries);
|
||||||
|
return path && entries.get(path).length <= MAX_ICON_BYTES
|
||||||
|
? resolvePacked(href)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const parsed = readGhostMapsKml(
|
||||||
|
parseXml(decodeXml(entries.get(main)), 'KML'),
|
||||||
|
resolveImage,
|
||||||
|
);
|
||||||
|
entries.clear();
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const color = PRODUCT_COLORS[product.id] || ACCENT;
|
||||||
|
const dataSource = new Cesium.CustomDataSource(
|
||||||
|
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||||
|
);
|
||||||
|
const features = [];
|
||||||
|
const pins = [];
|
||||||
|
dataSource.entities.suspendEvents();
|
||||||
|
parsed.features.forEach((feature, index) => {
|
||||||
|
const key = `${product.id}:${index}`;
|
||||||
|
const { style } = feature;
|
||||||
|
feature.geometries.forEach((geometry, part) => {
|
||||||
|
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||||
|
if (geometry.type === 'point') {
|
||||||
|
const position = Cesium.Cartesian3.fromDegrees(
|
||||||
|
...geometry.position,
|
||||||
|
);
|
||||||
|
const entity = dataSource.entities.add({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
billboard: {
|
||||||
|
image: style.icon || markerImage(color),
|
||||||
|
scale: Math.min(
|
||||||
|
1.5,
|
||||||
|
Math.max(0.35, (style.iconScale ?? 1) * 0.7),
|
||||||
|
),
|
||||||
|
color:
|
||||||
|
style.icon && style.iconColor
|
||||||
|
? cesiumColor(style.iconColor)
|
||||||
|
: undefined,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
|
// Ground-clamped icons would sink into terrain and 3D tiles; the
|
||||||
|
// horizon cull hides the far side instead (see `_horizon`).
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pins.push({
|
||||||
|
position,
|
||||||
|
get show() {
|
||||||
|
return entity.show;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
entity.show = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (geometry.type === 'line') {
|
||||||
|
dataSource.entities.add({
|
||||||
|
id,
|
||||||
|
polyline: {
|
||||||
|
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||||
|
geometry.positions.flat(),
|
||||||
|
),
|
||||||
|
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||||
|
material: cesiumColor(style.lineColor, color),
|
||||||
|
clampToGround: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const ring = (points) =>
|
||||||
|
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||||
|
dataSource.entities.add({
|
||||||
|
id,
|
||||||
|
polygon: {
|
||||||
|
hierarchy: new Cesium.PolygonHierarchy(
|
||||||
|
ring(geometry.outer),
|
||||||
|
geometry.holes.map(
|
||||||
|
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
material:
|
||||||
|
style.fill === false
|
||||||
|
? Cesium.Color.TRANSPARENT
|
||||||
|
: cesiumColor(
|
||||||
|
style.polyColor,
|
||||||
|
color,
|
||||||
|
style.polyColor ? undefined : 0.25,
|
||||||
|
),
|
||||||
|
classificationType: Cesium.ClassificationType.BOTH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (style.outline !== false)
|
||||||
|
dataSource.entities.add({
|
||||||
|
id: `${id}:outline`,
|
||||||
|
polyline: {
|
||||||
|
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||||
|
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||||
|
material: cesiumColor(style.lineColor, color),
|
||||||
|
clampToGround: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
features.push({
|
||||||
|
name: feature.name,
|
||||||
|
folder: feature.folder,
|
||||||
|
description: feature.description,
|
||||||
|
anchor: anchorOf(feature.geometries[0]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dataSource.entities.resumeEvents();
|
||||||
|
return { version: product.version, dataSource, urls, features, pins };
|
||||||
|
} catch (error) {
|
||||||
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bring the globe in line with the catalog and the chip selection. */
|
||||||
|
function sync() {
|
||||||
|
const run = _queue.then(async () => {
|
||||||
|
if (!_enabled || !_viewer || !_catalog) return;
|
||||||
|
const signal = _lifetime.signal;
|
||||||
|
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||||
|
for (const id of [..._loaded.keys()]) {
|
||||||
|
const product = products.get(id);
|
||||||
|
if (!product || !wanted(product)) unload(id);
|
||||||
|
}
|
||||||
|
for (const product of _catalog.products) {
|
||||||
|
if (!wanted(product) || signal.aborted) continue;
|
||||||
|
if (_loaded.get(product.id)?.version === product.version) continue;
|
||||||
|
_loading.set(product.id, product.version);
|
||||||
|
_failed.delete(product.id);
|
||||||
|
notifyRow();
|
||||||
|
try {
|
||||||
|
const next = await load(product, signal);
|
||||||
|
if (signal.aborted || !_enabled) {
|
||||||
|
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cull before the first frame so far-side pins never flash.
|
||||||
|
_horizon.update(next.pins);
|
||||||
|
await _viewer.dataSources.add(next.dataSource);
|
||||||
|
// The new edition is on the globe before the old one leaves.
|
||||||
|
unload(product.id);
|
||||||
|
_loaded.set(product.id, next);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
console.warn(`[Data:GhostMaps] ${product.id}:`, error);
|
||||||
|
_failed.set(
|
||||||
|
product.id,
|
||||||
|
error?.template ||
|
||||||
|
error?.message ||
|
||||||
|
msg('GhostMaps file could not be read'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_loading.delete(product.id);
|
||||||
|
notifyRow();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_queue = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCatalog(catalog) {
|
||||||
|
_catalog = catalog;
|
||||||
|
_lastError = catalog.error || null;
|
||||||
|
notifyRow();
|
||||||
|
return sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProduct(id) {
|
||||||
|
// The first change pins the whole current selection, defaults included.
|
||||||
|
if (!_selection) _selection = new Set(selectedIds());
|
||||||
|
if (_selection.has(id)) _selection.delete(id);
|
||||||
|
else _selection.add(id);
|
||||||
|
writeStoredSelection(storage, _selection);
|
||||||
|
notifyRow();
|
||||||
|
void sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- selection card ----------------------------------------------------
|
||||||
|
function featureFor(key) {
|
||||||
|
const [productId, index] = key.split(':');
|
||||||
|
return {
|
||||||
|
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||||
|
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishCard() {
|
||||||
|
if (!canSelect()) return;
|
||||||
|
const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {};
|
||||||
|
if (!feature) {
|
||||||
|
_selectedKey = null;
|
||||||
|
_selectedCardId = null;
|
||||||
|
_selectedLink = null;
|
||||||
|
overlayHost.setEntries(
|
||||||
|
GHOSTMAPS_OVERLAY_SOURCE_ID,
|
||||||
|
[],
|
||||||
|
CARD_HOST_OPTIONS,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { fields, links } = parseDescriptionFields(feature.description);
|
||||||
|
_selectedLink = links[0] || null;
|
||||||
|
const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')];
|
||||||
|
if (feature.folder) heading.push(fieldLabel(feature.folder));
|
||||||
|
if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs));
|
||||||
|
const details = [heading.filter(Boolean).join(' · ')];
|
||||||
|
for (const { label, value } of fields) {
|
||||||
|
if (details.length >= MAX_CARD_LINES) break;
|
||||||
|
// ArcGIS repeats the feature type as the first row; the title has it.
|
||||||
|
if (value === feature.name) continue;
|
||||||
|
const line = label ? `${fieldLabel(label)}: ${value}` : value;
|
||||||
|
details.push(
|
||||||
|
...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_selectedLink && openExternal)
|
||||||
|
details.push(
|
||||||
|
tn(
|
||||||
|
links.length,
|
||||||
|
'{count} source link ↗ · click card to open',
|
||||||
|
'{count} source links ↗ · click card to open the first',
|
||||||
|
{ count: formatNumber(links.length) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const title = t('S2 · {name}', {
|
||||||
|
name: fieldLabel(feature.name || feature.folder) || t('Report'),
|
||||||
|
});
|
||||||
|
const card = {
|
||||||
|
id: `ghostmaps-card:${_selectedKey}`,
|
||||||
|
selected: true,
|
||||||
|
interactive: Boolean(_selectedLink && openExternal),
|
||||||
|
title,
|
||||||
|
details,
|
||||||
|
accent: PRODUCT_COLORS[product?.id] || ACCENT,
|
||||||
|
priority: Number.MAX_SAFE_INTEGER,
|
||||||
|
gapPx: 15,
|
||||||
|
verticalOnly: true,
|
||||||
|
placement: 'above',
|
||||||
|
// A click card comes and goes with the click, as CCTV cards do, rather
|
||||||
|
// than holding the label arbiter's 2.5 s minimum lifetime; and it hides
|
||||||
|
// when its marker turns behind the planet.
|
||||||
|
stateless: true,
|
||||||
|
horizonCull: true,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(...feature.anchor),
|
||||||
|
};
|
||||||
|
if (card.interactive) {
|
||||||
|
const link = _selectedLink;
|
||||||
|
card.accessibilityLabel = t('Open the source for {title}', { title });
|
||||||
|
card.activate = () => {
|
||||||
|
openExternal(link);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_selectedCardId = card.id;
|
||||||
|
overlayHost.setEntries(
|
||||||
|
GHOSTMAPS_OVERLAY_SOURCE_ID,
|
||||||
|
[card],
|
||||||
|
CARD_HOST_OPTIONS,
|
||||||
|
);
|
||||||
|
overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
_selectedKey = null;
|
||||||
|
_selectedCardId = null;
|
||||||
|
_selectedLink = null;
|
||||||
|
// An empty publish, as the perimeter cards deselect: it releases the
|
||||||
|
// selected card's paint lane, which clearing the source alone left drawn.
|
||||||
|
overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickedKey(picked) {
|
||||||
|
const pickId = picking.resolvePickId(picked);
|
||||||
|
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||||
|
return null;
|
||||||
|
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||||
|
return _loaded.get(productId)?.features[Number(index)]
|
||||||
|
? `${productId}:${index}`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installClickHandler() {
|
||||||
|
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||||
|
picking.registerPickOwner?.(
|
||||||
|
GHOSTMAPS_LAYER_ID,
|
||||||
|
(pickedId) =>
|
||||||
|
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
|
||||||
|
);
|
||||||
|
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||||
|
_clickHandler.setInputAction((click) => {
|
||||||
|
if (pointer && !pointer.isPointerFree()) return;
|
||||||
|
const cardHit = overlayHost.hitTest?.(
|
||||||
|
click.position?.x,
|
||||||
|
click.position?.y,
|
||||||
|
{ sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID },
|
||||||
|
);
|
||||||
|
if (cardHit && cardHit.entryId === _selectedCardId) {
|
||||||
|
if (_selectedLink && openExternal) openExternal(_selectedLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = _viewer.scene.pick(click.position);
|
||||||
|
const key = picked ? pickedKey(picked) : null;
|
||||||
|
if (key) {
|
||||||
|
_selectedKey = key;
|
||||||
|
publishCard();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (picked) {
|
||||||
|
const pickId = picking.resolvePickId(picked);
|
||||||
|
if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_selectedKey) {
|
||||||
|
clearSelection();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClickHandler() {
|
||||||
|
_clickHandler?.destroy();
|
||||||
|
_clickHandler = null;
|
||||||
|
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureCount() {
|
||||||
|
let count = 0;
|
||||||
|
for (const item of _loaded.values()) count += item.features.length;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = {
|
||||||
|
id: GHOSTMAPS_LAYER_ID,
|
||||||
|
name: 'S2 GhostMaps',
|
||||||
|
icon: '👻',
|
||||||
|
source: 'S2 Underground',
|
||||||
|
// Pushes arrive within seconds over the event stream; this interval is
|
||||||
|
// the backstop if the stream is down.
|
||||||
|
updateInterval: 300000,
|
||||||
|
|
||||||
|
init(viewer) {
|
||||||
|
if (_viewer) throw new Error('GhostMaps layer is already initialized');
|
||||||
|
_viewer = viewer;
|
||||||
|
},
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
_enabled = true;
|
||||||
|
_lifetime = new AbortController();
|
||||||
|
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||||
|
installClickHandler();
|
||||||
|
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||||
|
_unsubscribe = source.subscribe(
|
||||||
|
(catalog) => {
|
||||||
|
if (_enabled) void applyCatalog(catalog);
|
||||||
|
},
|
||||||
|
(state) => {
|
||||||
|
if (_stream === state) return;
|
||||||
|
_stream = state;
|
||||||
|
notifyRow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
_enabled = false;
|
||||||
|
_lifetime.abort();
|
||||||
|
_unsubscribe?.();
|
||||||
|
_unsubscribe = null;
|
||||||
|
_stream = 'closed';
|
||||||
|
removeClickHandler();
|
||||||
|
clearSelection();
|
||||||
|
_horizon.stop();
|
||||||
|
for (const id of [..._loaded.keys()]) unload(id);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(_viewerArg, { signal } = {}) {
|
||||||
|
if (!_enabled || signal?.aborted) return false;
|
||||||
|
try {
|
||||||
|
const catalog = await source.getCatalog({ signal });
|
||||||
|
if (!_enabled) return false;
|
||||||
|
await applyCatalog(catalog);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted || !_enabled) return false;
|
||||||
|
console.warn('[Data:GhostMaps] catalog error:', error);
|
||||||
|
_lastError = error?.message || msg('GhostMaps catalog unavailable');
|
||||||
|
notifyRow();
|
||||||
|
// Keep the layer on; the event stream or the next interval recovers.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
layer.disable();
|
||||||
|
_viewer = null;
|
||||||
|
_catalog = null;
|
||||||
|
_rowListener = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setRowControlsListener(listener) {
|
||||||
|
_rowListener = listener;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRowControls() {
|
||||||
|
const products = _catalog?.products || [];
|
||||||
|
const commit = _catalog?.commit;
|
||||||
|
const info = commit
|
||||||
|
? _stream === 'open'
|
||||||
|
? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', {
|
||||||
|
commit: commit.slice(0, 7),
|
||||||
|
message: _catalog.message || '—',
|
||||||
|
seconds: formatNumber(
|
||||||
|
Math.round((_catalog.pollMs || 15000) / 1000),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: t('GitHub {commit} · {message} · reconnecting for live updates', {
|
||||||
|
commit: commit.slice(0, 7),
|
||||||
|
message: _catalog.message || '—',
|
||||||
|
})
|
||||||
|
: t('Connecting to the S2 GhostMaps repository…');
|
||||||
|
return {
|
||||||
|
chips: products.map((product) => {
|
||||||
|
const loaded = _loaded.get(product.id);
|
||||||
|
const failed = _failed.get(product.id);
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
label: t(PRODUCT_LABELS[product.id] || product.title),
|
||||||
|
active: wanted(product),
|
||||||
|
busy: _loading.has(product.id),
|
||||||
|
state: failed ? 'error' : undefined,
|
||||||
|
color: PRODUCT_COLORS[product.id],
|
||||||
|
title: [
|
||||||
|
product.fileTitle,
|
||||||
|
loaded
|
||||||
|
? tn(
|
||||||
|
loaded.features.length,
|
||||||
|
'{count} placemark',
|
||||||
|
'{count} placemarks',
|
||||||
|
{
|
||||||
|
count: formatNumber(loaded.features.length),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
failed ? t(failed) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
onClick: () => toggleProduct(product.id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
legend: [],
|
||||||
|
info,
|
||||||
|
infoTitle: t(
|
||||||
|
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
count: featureCount(),
|
||||||
|
products: [..._loaded.keys()],
|
||||||
|
commit: _catalog?.commit || null,
|
||||||
|
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||||
|
checkedAt: _catalog?.checkedAt || null,
|
||||||
|
stream: _stream,
|
||||||
|
loading: _loading.size > 0,
|
||||||
|
error: _lastError || [..._failed.values()][0] || null,
|
||||||
|
fallback: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Loaded placemarks as plain records, for tools and QA. */
|
||||||
|
getAnalystRecords(maxCount = Infinity) {
|
||||||
|
if (!_enabled) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const [productId, item] of _loaded) {
|
||||||
|
for (const feature of item.features) {
|
||||||
|
if (out.length >= maxCount) return out;
|
||||||
|
out.push({
|
||||||
|
product: productId,
|
||||||
|
name: feature.name,
|
||||||
|
folder: feature.folder,
|
||||||
|
lon: feature.anchor[0],
|
||||||
|
lat: feature.anchor[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Read the drawable content of a parsed GhostMaps KML document: placemarks
|
||||||
|
// with their folder, geometry, resolved style and raw description. Network
|
||||||
|
// links, overlays, models and remote icons are never followed; an icon is
|
||||||
|
// either an image packed in the same KMZ or the layer's own marker.
|
||||||
|
import { kmlColor } from './records.js';
|
||||||
|
|
||||||
|
export const MAX_GHOSTMAPS_FEATURES = 20_000;
|
||||||
|
export const MAX_GHOSTMAPS_POSITIONS = 250_000;
|
||||||
|
|
||||||
|
const childrenNamed = (el, name) =>
|
||||||
|
[...(el?.children || [])].filter((child) => child.localName === name);
|
||||||
|
const firstChild = (el, name) =>
|
||||||
|
[...(el?.children || [])].find((child) => child.localName === name) || null;
|
||||||
|
const descendants = (el, name) =>
|
||||||
|
[...(el?.getElementsByTagName?.('*') || [])].filter(
|
||||||
|
(child) => child.localName === name,
|
||||||
|
);
|
||||||
|
const text = (el) => (el?.textContent ?? '').trim();
|
||||||
|
|
||||||
|
function number(el, fallback) {
|
||||||
|
const value = Number(text(el));
|
||||||
|
return el && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */
|
||||||
|
export function parseKmlCoordinates(value) {
|
||||||
|
const out = [];
|
||||||
|
for (const tuple of String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)) {
|
||||||
|
const [lon, lat] = tuple.split(',').map(Number);
|
||||||
|
if (
|
||||||
|
Number.isFinite(lon) &&
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Math.abs(lat) <= 90 &&
|
||||||
|
Math.abs(lon) <= 180
|
||||||
|
)
|
||||||
|
out.push([lon, lat]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStyle(styleEl, resolveImage) {
|
||||||
|
const style = {};
|
||||||
|
const icon = firstChild(styleEl, 'IconStyle');
|
||||||
|
if (icon) {
|
||||||
|
const href = text(firstChild(firstChild(icon, 'Icon'), 'href'));
|
||||||
|
style.icon = href ? resolveImage(href) : null;
|
||||||
|
style.iconScale = number(firstChild(icon, 'scale'), 1);
|
||||||
|
style.iconColor = kmlColor(text(firstChild(icon, 'color')));
|
||||||
|
}
|
||||||
|
const line = firstChild(styleEl, 'LineStyle');
|
||||||
|
if (line) {
|
||||||
|
style.lineColor = kmlColor(text(firstChild(line, 'color')));
|
||||||
|
style.lineWidth = number(firstChild(line, 'width'), 1);
|
||||||
|
}
|
||||||
|
const poly = firstChild(styleEl, 'PolyStyle');
|
||||||
|
if (poly) {
|
||||||
|
style.polyColor = kmlColor(text(firstChild(poly, 'color')));
|
||||||
|
style.fill = text(firstChild(poly, 'fill')) !== '0';
|
||||||
|
style.outline = text(firstChild(poly, 'outline')) !== '0';
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared styles by id; a StyleMap resolves to its `normal` pair. */
|
||||||
|
function collectStyles(doc, resolveImage) {
|
||||||
|
const styles = new Map();
|
||||||
|
for (const el of descendants(doc, 'Style')) {
|
||||||
|
const id = el.getAttribute('id');
|
||||||
|
if (id) styles.set(id, readStyle(el, resolveImage));
|
||||||
|
}
|
||||||
|
const maps = new Map();
|
||||||
|
for (const el of descendants(doc, 'StyleMap')) {
|
||||||
|
const id = el.getAttribute('id');
|
||||||
|
if (!id) continue;
|
||||||
|
const normal = childrenNamed(el, 'Pair').find(
|
||||||
|
(pair) => text(firstChild(pair, 'key')) === 'normal',
|
||||||
|
);
|
||||||
|
if (!normal) continue;
|
||||||
|
const inline = firstChild(normal, 'Style');
|
||||||
|
maps.set(
|
||||||
|
id,
|
||||||
|
inline
|
||||||
|
? readStyle(inline, resolveImage)
|
||||||
|
: text(firstChild(normal, 'styleUrl')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resolve = (url, depth = 0) => {
|
||||||
|
const id = String(url || '').replace(/^#/, '');
|
||||||
|
if (!id || url.includes('/') || depth > 2) return {};
|
||||||
|
if (styles.has(id)) return styles.get(id);
|
||||||
|
const mapped = maps.get(id);
|
||||||
|
if (typeof mapped === 'string') return resolve(mapped, depth + 1);
|
||||||
|
return mapped || {};
|
||||||
|
};
|
||||||
|
return resolve;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readGeometry(el, out, budget) {
|
||||||
|
switch (el.localName) {
|
||||||
|
case 'Point': {
|
||||||
|
const [position] = parseKmlCoordinates(
|
||||||
|
text(firstChild(el, 'coordinates')),
|
||||||
|
);
|
||||||
|
if (position) {
|
||||||
|
budget.positions += 1;
|
||||||
|
out.push({ type: 'point', position });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'LineString':
|
||||||
|
case 'LinearRing': {
|
||||||
|
const positions = parseKmlCoordinates(
|
||||||
|
text(firstChild(el, 'coordinates')),
|
||||||
|
);
|
||||||
|
if (positions.length >= 2) {
|
||||||
|
budget.positions += positions.length;
|
||||||
|
out.push({ type: 'line', positions });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Polygon': {
|
||||||
|
const ring = (boundary) =>
|
||||||
|
parseKmlCoordinates(
|
||||||
|
text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')),
|
||||||
|
);
|
||||||
|
const outer = ring(firstChild(el, 'outerBoundaryIs'));
|
||||||
|
if (outer.length >= 3) {
|
||||||
|
const holes = childrenNamed(el, 'innerBoundaryIs')
|
||||||
|
.map(ring)
|
||||||
|
.filter((hole) => hole.length >= 3);
|
||||||
|
budget.positions +=
|
||||||
|
outer.length + holes.reduce((n, hole) => n + hole.length, 0);
|
||||||
|
out.push({ type: 'polygon', outer, holes });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'MultiGeometry':
|
||||||
|
for (const child of el.children) readGeometry(child, out, budget);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Models, tracks and anything newer are not drawn.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name of the nearest enclosing Folder (or Document), for grouping. */
|
||||||
|
function folderName(el) {
|
||||||
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||||
|
if (node.localName === 'Folder' || node.localName === 'Document') {
|
||||||
|
const name = text(firstChild(node, 'name'));
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Document} doc Parsed KML.
|
||||||
|
* @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image.
|
||||||
|
* @returns {{features: Array<{name: string, folder: string,
|
||||||
|
* description: string, style: object, geometries: Array<object>}>,
|
||||||
|
* positions: number, over: boolean}}
|
||||||
|
*/
|
||||||
|
export function readGhostMapsKml(doc, resolveImage = () => null) {
|
||||||
|
const styleFor = collectStyles(doc, resolveImage);
|
||||||
|
const features = [];
|
||||||
|
const budget = { positions: 0 };
|
||||||
|
let over = false;
|
||||||
|
for (const placemark of descendants(doc, 'Placemark')) {
|
||||||
|
// A hidden placemark (visibility 0) is drawn hidden by Google Earth too.
|
||||||
|
if (text(firstChild(placemark, 'visibility')) === '0') continue;
|
||||||
|
const geometries = [];
|
||||||
|
for (const child of placemark.children)
|
||||||
|
readGeometry(child, geometries, budget);
|
||||||
|
if (!geometries.length) continue;
|
||||||
|
if (
|
||||||
|
features.length >= MAX_GHOSTMAPS_FEATURES ||
|
||||||
|
budget.positions > MAX_GHOSTMAPS_POSITIONS
|
||||||
|
) {
|
||||||
|
over = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const inline = firstChild(placemark, 'Style');
|
||||||
|
features.push({
|
||||||
|
name: text(firstChild(placemark, 'name')).slice(0, 200),
|
||||||
|
folder: folderName(placemark).slice(0, 120),
|
||||||
|
description: text(firstChild(placemark, 'description')),
|
||||||
|
style: {
|
||||||
|
...styleFor(text(firstChild(placemark, 'styleUrl'))),
|
||||||
|
...(inline ? readStyle(inline, resolveImage) : {}),
|
||||||
|
},
|
||||||
|
geometries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { features, positions: budget.positions, over };
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createGhostMapsLayer } from './index.js';
|
||||||
|
|
||||||
|
function element(localName, children = [], value = '', attributes = {}) {
|
||||||
|
const node = {
|
||||||
|
localName,
|
||||||
|
children,
|
||||||
|
get textContent() {
|
||||||
|
return value + children.map((child) => child.textContent).join('');
|
||||||
|
},
|
||||||
|
getAttribute: (key) => attributes[key] ?? null,
|
||||||
|
getElementsByTagName(name) {
|
||||||
|
return children.flatMap((child) => [
|
||||||
|
...(name === '*' || name === child.localName ? [child] : []),
|
||||||
|
...child.getElementsByTagName(name),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const child of children) child.parentElement = node;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentFixture() {
|
||||||
|
return element('document', [
|
||||||
|
element('kml', [
|
||||||
|
element('Document', [
|
||||||
|
element(
|
||||||
|
'Style',
|
||||||
|
[
|
||||||
|
element('IconStyle', [
|
||||||
|
element('Icon', [element('href', [], 'icon.png')]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
{ id: 'pin' },
|
||||||
|
),
|
||||||
|
element('Placemark', [
|
||||||
|
element('name', [], 'Test report'),
|
||||||
|
element('styleUrl', [], '#pin'),
|
||||||
|
element('Point', [element('coordinates', [], '12,34')]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored KMZ with a local icon; the XML DOM double supplies its one point.
|
||||||
|
function fixtureKmz() {
|
||||||
|
const files = [
|
||||||
|
['doc.kml', '<kml/>'],
|
||||||
|
['icon.png', 'packed icon'],
|
||||||
|
];
|
||||||
|
const local = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const [name, value] of files) {
|
||||||
|
const key = Buffer.from(name);
|
||||||
|
const bytes = Buffer.from(value);
|
||||||
|
const header = Buffer.alloc(30);
|
||||||
|
header.writeUInt32LE(0x04034b50);
|
||||||
|
header.writeUInt32LE(bytes.length, 18);
|
||||||
|
header.writeUInt32LE(bytes.length, 22);
|
||||||
|
header.writeUInt16LE(key.length, 26);
|
||||||
|
const directory = Buffer.alloc(46);
|
||||||
|
directory.writeUInt32LE(0x02014b50);
|
||||||
|
directory.writeUInt32LE(bytes.length, 20);
|
||||||
|
directory.writeUInt32LE(bytes.length, 24);
|
||||||
|
directory.writeUInt16LE(key.length, 28);
|
||||||
|
directory.writeUInt32LE(offset, 42);
|
||||||
|
local.push(header, key, bytes);
|
||||||
|
central.push(directory, key);
|
||||||
|
offset += header.length + key.length + bytes.length;
|
||||||
|
}
|
||||||
|
const directory = Buffer.concat(central);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50);
|
||||||
|
end.writeUInt16LE(files.length, 8);
|
||||||
|
end.writeUInt16LE(files.length, 10);
|
||||||
|
end.writeUInt32LE(directory.length, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||||
|
globalThis.DOMParser = class {
|
||||||
|
parseFromString() {
|
||||||
|
return documentFixture();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'DOMParser', previous);
|
||||||
|
else delete globalThis.DOMParser;
|
||||||
|
});
|
||||||
|
const created = [];
|
||||||
|
const revoked = [];
|
||||||
|
t.mock.method(URL, 'createObjectURL', () => {
|
||||||
|
const url = `blob:local-${created.length}`;
|
||||||
|
created.push(url);
|
||||||
|
onCreateUrl?.(layer);
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const mounted = new Set();
|
||||||
|
const viewer = {
|
||||||
|
scene: { requestRender() {} },
|
||||||
|
dataSources: {
|
||||||
|
async add(dataSource) {
|
||||||
|
mounted.add(dataSource);
|
||||||
|
return dataSource;
|
||||||
|
},
|
||||||
|
remove(dataSource) {
|
||||||
|
return mounted.delete(dataSource);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const layer = createGhostMapsLayer({
|
||||||
|
storage: null,
|
||||||
|
source: {
|
||||||
|
getCatalog: async () => ({
|
||||||
|
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
||||||
|
}),
|
||||||
|
getFile: async () => {
|
||||||
|
beforeFileReturns?.(layer);
|
||||||
|
return fixtureKmz();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable();
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
return { layer, viewer, mounted, created, revoked };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
|
||||||
|
const { layer, viewer, mounted, created, revoked } = fixture(t, {
|
||||||
|
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(created.length, 0);
|
||||||
|
assert.equal(revoked.length, 0);
|
||||||
|
assert.equal(mounted.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellation during parsing releases every packed-image URL', async (t) => {
|
||||||
|
const { layer, viewer, mounted, created, revoked } = fixture(t, {
|
||||||
|
onCreateUrl: (layer) => layer.disable(),
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.deepEqual(revoked, created);
|
||||||
|
assert.equal(mounted.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entity construction failure releases parsed image URLs', async (t) => {
|
||||||
|
const { layer, viewer, mounted, created, revoked } = fixture(t);
|
||||||
|
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
|
||||||
|
throw new Error('fixture entity failure');
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.deepEqual(revoked, created);
|
||||||
|
assert.equal(mounted.size, 0);
|
||||||
|
assert.equal(layer.getStats().error, 'fixture entity failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful load keeps icons until the product is disabled', async (t) => {
|
||||||
|
const { layer, viewer, mounted, created, revoked } = fixture(t);
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(created.length, 1);
|
||||||
|
assert.equal(revoked.length, 0);
|
||||||
|
assert.equal(mounted.size, 1);
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
layer.disable();
|
||||||
|
assert.deepEqual(revoked, created);
|
||||||
|
assert.equal(mounted.size, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
|
||||||
|
// KML colors, and card text wrapping. Strings in, data out — description
|
||||||
|
// markup is only ever read as text, never inserted into the page.
|
||||||
|
|
||||||
|
const ENTITIES = Object.freeze({
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
nbsp: ' ',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Decode the HTML character references ArcGIS and Google Earth emit. */
|
||||||
|
export function decodeHtmlEntities(text) {
|
||||||
|
return String(text ?? '').replace(
|
||||||
|
/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi,
|
||||||
|
(whole, ref) => {
|
||||||
|
if (ref[0] === '#') {
|
||||||
|
const code =
|
||||||
|
ref[1] === 'x' || ref[1] === 'X'
|
||||||
|
? parseInt(ref.slice(2), 16)
|
||||||
|
: parseInt(ref.slice(1), 10);
|
||||||
|
return code > 0 &&
|
||||||
|
code <= 0x10ffff &&
|
||||||
|
!(code >= 0xd800 && code <= 0xdfff)
|
||||||
|
? String.fromCodePoint(code)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
return ENTITIES[ref.toLowerCase()] ?? whole;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markup to one line of plain text. */
|
||||||
|
export function htmlToText(html) {
|
||||||
|
return decodeHtmlEntities(
|
||||||
|
String(html ?? '')
|
||||||
|
.replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ')
|
||||||
|
.replace(/<br\s*\/?>/gi, ' ')
|
||||||
|
.replace(/<[^>]*>/g, ' '),
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUES = new Set(['', '<null>', 'null', 'n/a', '-', '<blank>']);
|
||||||
|
// ArcGIS writes geometry type and internal ids into every popup table.
|
||||||
|
const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']);
|
||||||
|
const MAX_FIELDS = 24;
|
||||||
|
const MAX_VALUE = 600;
|
||||||
|
const MAX_LINKS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||||
|
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||||
|
* @param {string} html Description as written in the KML (CDATA text).
|
||||||
|
* @returns {{fields: Array<{label: string, value: string}>, links: string[]}}
|
||||||
|
*/
|
||||||
|
export function parseDescriptionFields(html) {
|
||||||
|
const source = String(html ?? '').replace(
|
||||||
|
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
const fields = [];
|
||||||
|
const links = [];
|
||||||
|
const addLinks = (markup) => {
|
||||||
|
for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
||||||
|
const url = decodeHtmlEntities(match[1]).trim();
|
||||||
|
if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url))
|
||||||
|
links.push(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||||
|
// and only leaf rows with exactly two cells are label/value pairs.
|
||||||
|
for (const row of source.matchAll(
|
||||||
|
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||||
|
)) {
|
||||||
|
const cells = [
|
||||||
|
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||||
|
].map((cell) => cell[1]);
|
||||||
|
if (cells.length !== 2) continue;
|
||||||
|
const label = htmlToText(cells[0]);
|
||||||
|
const value = htmlToText(cells[1]);
|
||||||
|
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||||
|
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||||
|
addLinks(cells[1]);
|
||||||
|
// A bare research link is carried by `links`; the card does not need
|
||||||
|
// the URL twice.
|
||||||
|
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||||
|
if (fields.length < MAX_FIELDS)
|
||||||
|
fields.push({ label, value: value.slice(0, MAX_VALUE) });
|
||||||
|
}
|
||||||
|
if (!fields.length && !links.length) {
|
||||||
|
const text = htmlToText(source);
|
||||||
|
if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) });
|
||||||
|
addLinks(source);
|
||||||
|
}
|
||||||
|
return { fields, links: links.slice(0, MAX_LINKS) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null.
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {?number[]}
|
||||||
|
*/
|
||||||
|
export function kmlColor(text) {
|
||||||
|
const hex = String(text ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^#/, '');
|
||||||
|
if (!/^[0-9a-f]{8}$/i.test(hex)) return null;
|
||||||
|
const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255;
|
||||||
|
return [byte(6), byte(4), byte(2), byte(0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy word wrap for canvas card lines; overlong words are cut.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} width Characters per line.
|
||||||
|
* @param {number} maxLines Lines kept; the last ends with an ellipsis when cut.
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function wrapCardText(text, width = 56, maxLines = 4) {
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
let cut = false;
|
||||||
|
for (let word of String(text ?? '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)) {
|
||||||
|
while (word.length > width) {
|
||||||
|
if (line) {
|
||||||
|
lines.push(line);
|
||||||
|
line = '';
|
||||||
|
}
|
||||||
|
lines.push(word.slice(0, width));
|
||||||
|
word = word.slice(width);
|
||||||
|
}
|
||||||
|
if (!line) line = word;
|
||||||
|
else if (line.length + 1 + word.length <= width) line += ` ${word}`;
|
||||||
|
else {
|
||||||
|
lines.push(line);
|
||||||
|
line = word;
|
||||||
|
}
|
||||||
|
if (lines.length >= maxLines) {
|
||||||
|
cut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line && lines.length < maxLines) lines.push(line);
|
||||||
|
else if (line) cut = true;
|
||||||
|
const kept = lines.slice(0, maxLines);
|
||||||
|
if (cut && kept.length) {
|
||||||
|
const last = kept[kept.length - 1];
|
||||||
|
kept[kept.length - 1] = `${last.slice(0, width - 1)}…`;
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field labels ArcGIS writes CamelCase; show them as words. */
|
||||||
|
export function fieldLabel(label) {
|
||||||
|
return String(label ?? '')
|
||||||
|
.replace(/([a-z])([A-Z0-9])/g, '$1 $2')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
decodeHtmlEntities,
|
||||||
|
fieldLabel,
|
||||||
|
htmlToText,
|
||||||
|
kmlColor,
|
||||||
|
parseDescriptionFields,
|
||||||
|
wrapCardText,
|
||||||
|
} from './records.js';
|
||||||
|
|
||||||
|
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||||
|
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||||
|
<META http-equiv="Content-Type" content="text/html"></head>
|
||||||
|
<body style="margin:0px"><table style="font-family:Arial">
|
||||||
|
<tr style="text-align:center;font-weight:bold;background:#9CBCE2"><td>HistoricProtestSite</td></tr>
|
||||||
|
<tr><td><table style="font-family:Arial">
|
||||||
|
<tr><td>SHAPE</td><td>Point</td></tr>
|
||||||
|
<tr bgcolor="#D4E4F3"><td>InstallationType</td><td>HistoricProtestSite</td></tr>
|
||||||
|
<tr><td>OperationalStatus</td><td>ACTIVE</td></tr>
|
||||||
|
<tr bgcolor="#D4E4F3"><td>Affiliation</td><td><Null></td></tr>
|
||||||
|
<tr><td>Description</td><td>Locals gather here & protest — weekly.</td></tr>
|
||||||
|
<tr><td>Research1</td><td><a target="_blank" href="https://x.com/a/status/1">https://x.com/a/status/1</a></td></tr>
|
||||||
|
<tr><td>Research2</td><td><a href="javascript:alert(1)">bad</a></td></tr>
|
||||||
|
<tr><td>Research3</td><td><Null></td></tr>
|
||||||
|
</table></td></tr></table></body><script type="text/javascript">
|
||||||
|
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||||
|
</script></html>`;
|
||||||
|
|
||||||
|
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||||
|
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||||
|
assert.deepEqual(fields, [
|
||||||
|
{ label: 'InstallationType', value: 'HistoricProtestSite' },
|
||||||
|
{ label: 'OperationalStatus', value: 'ACTIVE' },
|
||||||
|
{ label: 'Description', value: 'Locals gather here & protest — weekly.' },
|
||||||
|
{ label: 'Research2', value: 'bad' },
|
||||||
|
]);
|
||||||
|
// javascript: and other schemes never become links.
|
||||||
|
assert.deepEqual(links, ['https://x.com/a/status/1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('free-form descriptions become one unlabelled field; scripts are dropped', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseDescriptionFields(
|
||||||
|
'<p>Front line<br>as of 10 Oct</p><script>steal()</script><a href="https://s2.example/x">more</a>',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
fields: [{ label: '', value: 'Front line as of 10 Oct more' }],
|
||||||
|
links: ['https://s2.example/x'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] });
|
||||||
|
assert.deepEqual(parseDescriptionFields(undefined), {
|
||||||
|
fields: [],
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field values and counts are bounded', () => {
|
||||||
|
const rows = Array.from(
|
||||||
|
{ length: 40 },
|
||||||
|
(_, i) => `<tr><td>K${i}</td><td>${'x'.repeat(900)}</td></tr>`,
|
||||||
|
).join('');
|
||||||
|
const { fields } = parseDescriptionFields(`<table>${rows}</table>`);
|
||||||
|
assert.equal(fields.length, 24);
|
||||||
|
assert.equal(fields[0].value.length, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entities and tags decode to text', () => {
|
||||||
|
assert.equal(
|
||||||
|
decodeHtmlEntities('<b> AB &bogus;'),
|
||||||
|
'<b> AB &bogus;',
|
||||||
|
);
|
||||||
|
assert.equal(decodeHtmlEntities('��'), '');
|
||||||
|
assert.equal(htmlToText('<style>a{}</style><b>One</b>\n two'), 'One two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KML aabbggrr colors convert to rgba', () => {
|
||||||
|
assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]);
|
||||||
|
assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]);
|
||||||
|
assert.equal(kmlColor('red'), null);
|
||||||
|
assert.equal(kmlColor(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('card text wraps on words, cuts long words and marks truncation', () => {
|
||||||
|
assert.deepEqual(wrapCardText('one two three four', 9, 4), [
|
||||||
|
'one two',
|
||||||
|
'three',
|
||||||
|
'four',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [
|
||||||
|
'abcde',
|
||||||
|
'fghij',
|
||||||
|
'kl',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']);
|
||||||
|
assert.deepEqual(wrapCardText('', 5, 2), []);
|
||||||
|
for (const line of wrapCardText('word '.repeat(200), 20, 3))
|
||||||
|
assert.ok(line.length <= 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CamelCase field labels read as words', () => {
|
||||||
|
assert.equal(fieldLabel('OperationalStatus'), 'Operational Status');
|
||||||
|
assert.equal(fieldLabel('Research1'), 'Research 1');
|
||||||
|
assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity');
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
readResponseBytesCapped,
|
||||||
|
readResponseJsonCapped,
|
||||||
|
} from '../../sources/httpBody.js';
|
||||||
|
import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js';
|
||||||
|
|
||||||
|
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||||
|
// the Layers panel shows them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a
|
||||||
|
* push subscription that fires whenever the watcher sees a new commit.
|
||||||
|
*/
|
||||||
|
export function createGhostMapsSource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
EventSourceImpl = globalThis.EventSource,
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
async getCatalog({ signal } = {}) {
|
||||||
|
const response = await fetchImpl('/api/ghostmaps/catalog', { signal });
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`GhostMaps catalog unavailable (HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
const catalog = await readResponseJsonCapped(
|
||||||
|
response,
|
||||||
|
1024 * 1024,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(catalog?.products))
|
||||||
|
throw new Error(msg('Malformed GhostMaps catalog'));
|
||||||
|
return catalog;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFile(id, version, { signal } = {}) {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`);
|
||||||
|
return readResponseBytesCapped(
|
||||||
|
response,
|
||||||
|
GHOSTMAPS_MAX_FILE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call `onCatalog(catalog)` for every catalog the server pushes, and
|
||||||
|
* `onState('open' | 'connecting' | 'closed')` as the stream changes. The
|
||||||
|
* browser's EventSource reconnects by itself. Returns an unsubscribe.
|
||||||
|
*/
|
||||||
|
subscribe(onCatalog, onState = () => {}) {
|
||||||
|
if (typeof EventSourceImpl !== 'function') {
|
||||||
|
onState('closed');
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const events = new EventSourceImpl('/api/ghostmaps/events');
|
||||||
|
const states = ['connecting', 'open', 'closed'];
|
||||||
|
const report = () => onState(states[events.readyState] ?? 'connecting');
|
||||||
|
events.onopen = report;
|
||||||
|
events.onerror = report;
|
||||||
|
const listener = (event) => {
|
||||||
|
try {
|
||||||
|
const catalog = JSON.parse(event.data);
|
||||||
|
if (Array.isArray(catalog?.products)) onCatalog(catalog);
|
||||||
|
} catch {
|
||||||
|
/* ignore a malformed frame; the next one replaces it */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
events.addEventListener('catalog', listener);
|
||||||
|
report();
|
||||||
|
return () => {
|
||||||
|
events.removeEventListener('catalog', listener);
|
||||||
|
events.close();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d
|
|||||||
|
|
||||||
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
||||||
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
|
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
|
||||||
assert.match(handler, /this\.showStatus\(message\)/);
|
const present = shellMethod('_presentAfterStartupCover').toString();
|
||||||
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
|
assert.match(
|
||||||
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
|
handler,
|
||||||
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
/this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/,
|
||||||
assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
|
);
|
||||||
assert.doesNotMatch(handler, /this\._showToast\(message\)/);
|
assert.match(present, /this\.showStatus\(message\)/);
|
||||||
assert.doesNotMatch(handler, /pushCockpitSignal/);
|
assert.match(present, /this\._lifetime\.frame\(\(\) => \{/);
|
||||||
|
assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
||||||
|
assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
|
||||||
|
assert.match(present, /canPresentDeferredStatusNotice\(/);
|
||||||
|
for (const source of [handler, present]) {
|
||||||
|
assert.doesNotMatch(source, /this\._showToast\(message\)/);
|
||||||
|
assert.doesNotMatch(source, /pushCockpitSignal/);
|
||||||
|
}
|
||||||
assert.match(handler, /result\.classification === 'pending'/);
|
assert.match(handler, /result\.classification === 'pending'/);
|
||||||
assert.match(handler, /state: 'acquiring'/);
|
assert.match(handler, /state: 'acquiring'/);
|
||||||
assert.match(handler, /persistent: true/);
|
assert.match(handler, /persistent: true/);
|
||||||
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
|
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
|
||||||
assert.match(handler, /canPresentDeferredStatusNotice\(/);
|
|
||||||
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
|
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
|
||||||
assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/);
|
assert.match(handler, /result\.classification === 'followed'/);
|
||||||
|
assert.match(handler, /result\.classification === 'cancelled'\) return/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('universal notice masks active loading only for its own fixed dwell', () => {
|
test('universal notice masks active loading only for its own fixed dwell', () => {
|
||||||
|
|||||||
@@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
|
|||||||
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
||||||
);
|
);
|
||||||
|
|
||||||
const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {');
|
const reassert = ui.indexOf(
|
||||||
|
" _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {",
|
||||||
|
);
|
||||||
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
|
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
ui.slice(reassert, reassert + 900),
|
ui.slice(reassert, reassert + 900),
|
||||||
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
|
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/,
|
||||||
);
|
);
|
||||||
|
|
||||||
// …and the policy only reaches `release` after its authority checks pass.
|
// …and the policy only reaches `release` after its authority checks pass.
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
|||||||
|
|
||||||
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
||||||
const ui = readShellSource();
|
const ui = readShellSource();
|
||||||
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
// The long form spells the share format's panel table (one source of truth).
|
||||||
|
const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
@@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
|||||||
'pin and unpin must update the share hash even when collapse state is unchanged',
|
'pin and unpin must update the share hash even when collapse state is unchanged',
|
||||||
);
|
);
|
||||||
assert.match(ui, /\{ id: 'param-slider-panel' \}/);
|
assert.match(ui, /\{ id: 'param-slider-panel' \}/);
|
||||||
assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/);
|
assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parameterized Display presets keep one stable scroll owner', () => {
|
test('parameterized Display presets keep one stable scroll owner', () => {
|
||||||
|
|||||||
+18
-17
@@ -1,5 +1,6 @@
|
|||||||
import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
|
import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
|
||||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||||
|
import { realtimeInstructions } from '../server/providers/openai/instructions.js';
|
||||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
@@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
|
|||||||
|
|
||||||
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
||||||
|
|
||||||
test('Realtime schema exposes the authoritative 30-tool inventory', () => {
|
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
|
||||||
const tools = realtimeTools();
|
const tools = realtimeTools();
|
||||||
assert.equal(tools.length, 30);
|
assert.equal(tools.length, 31);
|
||||||
const names = tools.map((tool) => tool.name);
|
const names = tools.map((tool) => tool.name);
|
||||||
assert.equal(new Set(names).size, 30, 'tool names are unique');
|
assert.equal(new Set(names).size, 31, 'tool names are unique');
|
||||||
assert.ok(names.includes('set_context_mode'));
|
assert.ok(names.includes('set_context_mode'));
|
||||||
assert.ok(names.includes('control_cockpit'));
|
assert.ok(names.includes('control_cockpit'));
|
||||||
|
assert.ok(names.includes('set_voice_inactivity_timeout'));
|
||||||
assert.ok(names.includes('select_nearest_aircraft'));
|
assert.ok(names.includes('select_nearest_aircraft'));
|
||||||
assert.ok(names.includes('control_radio'));
|
assert.ok(names.includes('control_radio'));
|
||||||
assert.ok(names.includes('next_satellite_pass'));
|
assert.ok(names.includes('next_satellite_pass'));
|
||||||
@@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Context panel opening stays distinct from Contacts activation', () => {
|
test('Context panel opening stays distinct from Contacts activation', () => {
|
||||||
const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel");
|
const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
|
||||||
assert.ok(start >= 0, 'panel-routing instruction is missing');
|
assert.ok(text, 'panel-routing instruction is missing');
|
||||||
const text = voice.slice(start, voice.indexOf('\n', start));
|
assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/);
|
||||||
assert.match(text, /"Open Context" means only set_panel_open/);
|
assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/);
|
||||||
assert.match(text, /does not activate a Context sub-mode/);
|
assert.match(text, /"Open Contacts" is set_context_mode contacts/);
|
||||||
assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/);
|
assert.match(text, /expands Context itself/);
|
||||||
assert.match(text, /expands the parent Context panel before activating Contacts/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
||||||
@@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
|||||||
assert.match(text, /nearest airborne result/);
|
assert.match(text, /nearest airborne result/);
|
||||||
assert.match(text, /healthy fallback feed is valid data/i);
|
assert.match(text, /healthy fallback feed is valid data/i);
|
||||||
assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/);
|
assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/);
|
||||||
assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/);
|
const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
|
||||||
|
assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/);
|
||||||
assert.match(text, /set_context_mode, or control_cockpit/);
|
assert.match(text, /set_context_mode, or control_cockpit/);
|
||||||
|
|
||||||
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
|
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
|
||||||
assert.match(byName.get('set_context_mode').description, /explicitly requests/);
|
assert.match(byName.get('set_context_mode').description, /only when explicitly requested/);
|
||||||
assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i);
|
assert.match(routing, /selecting\/viewing an aircraft never enters them/);
|
||||||
assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/);
|
assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/);
|
||||||
assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
byName.get('fly_to_location').parameters.properties.waitForArrival.type,
|
byName.get('fly_to_location').parameters.properties.waitForArrival.type,
|
||||||
'boolean',
|
'boolean',
|
||||||
@@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
|||||||
assert.match(nearest.description, /Atomically/);
|
assert.match(nearest.description, /Atomically/);
|
||||||
assert.match(nearest.description, /exclude on-ground records/);
|
assert.match(nearest.description, /exclude on-ground records/);
|
||||||
assert.match(nearest.description, /fallback feeds remain usable/);
|
assert.match(nearest.description, /fallback feeds remain usable/);
|
||||||
assert.match(nearest.description, /does not open Contacts or Cockpit/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
|
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
|
||||||
@@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => {
|
|||||||
const panel = byName.get('set_panel_open');
|
const panel = byName.get('set_panel_open');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
panel.parameters.properties.panelId.enum,
|
panel.parameters.properties.panelId.enum,
|
||||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'],
|
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
||||||
|
|
||||||
@@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
|||||||
'set_context_mode',
|
'set_context_mode',
|
||||||
'control_cockpit',
|
'control_cockpit',
|
||||||
'set_panel_open',
|
'set_panel_open',
|
||||||
|
'set_voice_inactivity_timeout',
|
||||||
'get_current_view_state',
|
'get_current_view_state',
|
||||||
'fly_to_location',
|
'fly_to_location',
|
||||||
'select_nearest_aircraft',
|
'select_nearest_aircraft',
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js';
|
|||||||
// actually decides fresh-boot layer state — including the early return that
|
// actually decides fresh-boot layer state — including the early return that
|
||||||
// makes each layer's own initializer the operative default.
|
// makes each layer's own initializer the operative default.
|
||||||
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
|
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
|
||||||
// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11%
|
// 2026-08-22; later revisions were 8%, then 11%, then 100% on
|
||||||
// on 2026-08-24, a soft
|
// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
|
||||||
// edge. The hard crop is still one drag away, and that is pinned too.
|
|
||||||
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
|
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
|
||||||
// Owner: "detect should also be on by default."
|
// Owner: "detect should also be on by default."
|
||||||
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
|
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
|
||||||
@@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js';
|
|||||||
import {
|
import {
|
||||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||||
getScopeMaskFeather,
|
getScopeMaskFeather,
|
||||||
|
isScopeMaskEnabled,
|
||||||
scopeMaskGeometry,
|
scopeMaskGeometry,
|
||||||
setScopeMaskFeather,
|
setScopeMaskFeather,
|
||||||
} from './scopeMask.js';
|
} from './scopeMask.js';
|
||||||
@@ -75,32 +75,40 @@ function managerForHash(hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Scope feather — a subtle soft edge on a first run
|
// 2. Scope ON with 100% feather on a first run
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test('first run opens with a subtle scope feather, at every surface that decides it', () => {
|
test('first run opens with Scope ON and 100% feather at every default surface', () => {
|
||||||
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11,
|
assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
|
||||||
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings');
|
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
|
||||||
assert.equal(getScopeMaskFeather(), 0.11,
|
assert.equal(getScopeMaskFeather(), 1,
|
||||||
'and the live module starts there, not merely documents it');
|
'and the live module starts there, not merely documents it');
|
||||||
|
|
||||||
// The slider and its readout are the same default rendered as markup — a
|
// The slider and its readout are the same default rendered as markup — a
|
||||||
// fresh boot applies no restore, so a stale value here would show one number
|
// fresh boot applies no restore, so a stale value here would show one number
|
||||||
// over a mask drawn at another.
|
// over a mask drawn at another.
|
||||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/,
|
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
|
||||||
'index.html: the feather slider ships at 11');
|
'index.html: the feather slider ships at 100');
|
||||||
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</,
|
assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
|
||||||
'index.html: and its readout agrees with the handle');
|
'index.html: and its readout agrees with the handle');
|
||||||
|
|
||||||
|
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
|
||||||
|
'the native Scope checkbox starts checked');
|
||||||
|
|
||||||
// The link this session generates must describe the mask this session draws,
|
// The link this session generates must describe the mask this session draws,
|
||||||
// for the window before the first _syncShareState.
|
// for the window before the first _syncShareState.
|
||||||
assert.match(shareSource, /this\._scopeFeatherPct = 11;/,
|
assert.match(shareSource, /this\._scopeFeatherPct = 100;/,
|
||||||
'sharelink.js: the generator starts from the same value the mask starts at');
|
'sharelink.js: the generator starts from the same value the mask starts at');
|
||||||
|
const params = managerForHash('')._buildHashParams();
|
||||||
|
assert.equal(params.get('sc'), '1');
|
||||||
|
assert.equal(params.get('scf'), '100');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an explicit feather still wins over the subtle default', () => {
|
test('an explicit feather still wins over the 100% default', () => {
|
||||||
// A link is authored state. The new default governs a session that said
|
// A link is authored state. The new default governs a session that said
|
||||||
// nothing; it must never overwrite one that said something.
|
// nothing; it must never overwrite one that said something.
|
||||||
|
assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11,
|
||||||
|
'links carrying the previous default preserve their authored feather');
|
||||||
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
|
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
|
||||||
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
|
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
|
||||||
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
|
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
|
||||||
@@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => {
|
|||||||
'a pre-scf link restores the author\'s view, not the new default');
|
'a pre-scf link restores the author\'s view, not the new default');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the subtle default did not weaken the feather control, and 0 is still reachable', () => {
|
test('share links preserve explicit Scope OFF and feather choices over the defaults', () => {
|
||||||
|
const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash();
|
||||||
|
assert.equal(shared.scopeEnabled, false);
|
||||||
|
assert.equal(shared.scopeFeatherPct, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 100% default retains the expanded feather range and hard edge', () => {
|
||||||
// The cheap way to move a default would be to nerf the control. Prove the
|
// The cheap way to move a default would be to nerf the control. Prove the
|
||||||
// slider still spans its full range and the geometry is still DERIVED from
|
// slider still spans its full range and the geometry is still DERIVED from
|
||||||
// the ratio — a check that would pass vacuously if it only ever saw one value.
|
// the ratio — a check that would pass vacuously if it only ever saw one value.
|
||||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/,
|
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
|
||||||
'the slider still offers the whole range');
|
'the slider still offers the whole range');
|
||||||
const previous = getScopeMaskFeather();
|
const previous = getScopeMaskFeather();
|
||||||
try {
|
try {
|
||||||
for (const ratio of [0.35, 0.7, 1]) {
|
for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
|
||||||
setScopeMaskFeather(ratio);
|
setScopeMaskFeather(ratio);
|
||||||
const geo = scopeMaskGeometry(1200, 900);
|
const geo = scopeMaskGeometry(1200, 900);
|
||||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||||
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
|
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
|
||||||
`feather ${ratio} must still widen the band to that fraction of the keyhole`);
|
`feather ${ratio} must still widen the band to that fraction of the keyhole`);
|
||||||
}
|
}
|
||||||
// The new default is a real, narrow band — not the hard crop, and nowhere
|
// The first-run value draws a band derived from the same keyhole radius.
|
||||||
// near the retired 35 % halo.
|
|
||||||
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
||||||
const soft = scopeMaskGeometry(1200, 900);
|
const soft = scopeMaskGeometry(1200, 900);
|
||||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||||
|
|||||||
+25
-21
@@ -1,4 +1,5 @@
|
|||||||
import { getKeyholeGeometry } from './celestialRing.js';
|
import { getKeyholeGeometry } from './celestialRing.js';
|
||||||
|
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope mask — the app's signature circular viewport treatment, made real.
|
* Scope mask — the app's signature circular viewport treatment, made real.
|
||||||
@@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js';
|
|||||||
/** Matches the page background the emergent scope faded into. */
|
/** Matches the page background the emergent scope faded into. */
|
||||||
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
||||||
/**
|
/**
|
||||||
* Default edge feather as a fraction of the keyhole radius.
|
* First-run feather ratio; the Display slider and share defaults use 100%.
|
||||||
*
|
* The historical missing-scf parse fallback remains 35%.
|
||||||
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
|
|
||||||
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
|
|
||||||
* set it to zero: a subtle soft edge rather than either the hard crop or the
|
|
||||||
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
|
|
||||||
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
|
|
||||||
* share link carries, or the operator dials in, overrides this immediately, and
|
|
||||||
* the hard-crop path at 0 is still reachable from the handle.
|
|
||||||
*
|
|
||||||
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
|
|
||||||
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
|
|
||||||
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
|
|
||||||
* fallback, which stays at 35 on purpose: a link predating that field was
|
|
||||||
* authored when 35 was what its author saw, and a link from the feather-0 era
|
|
||||||
* carries `scf=0` explicitly because the generator always writes the field.
|
|
||||||
* Pinned in reasonableDefaults.test.mjs.
|
|
||||||
*/
|
*/
|
||||||
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11;
|
export const SCOPE_FEATHER_RATIO_DEFAULT = 1;
|
||||||
/**
|
/**
|
||||||
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
|
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
|
||||||
* faint stars survive in the corners at globe scale.
|
* faint stars survive in the corners at globe scale.
|
||||||
@@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() {
|
|||||||
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
||||||
const keyhole = getKeyholeGeometry(width, height);
|
const keyhole = getKeyholeGeometry(width, height);
|
||||||
if (!(keyhole.radius > 0)) return null;
|
if (!(keyhole.radius > 0)) return null;
|
||||||
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
|
const feather = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
|
||||||
|
);
|
||||||
// Feather straddles the keyhole edge so the visible radius stays anchored
|
// Feather straddles the keyhole edge so the visible radius stays anchored
|
||||||
// to the shared geometry every keyhole consumer (labels, cards) fades on.
|
// to the shared geometry every keyhole consumer (labels, cards) fades on.
|
||||||
const half = keyhole.radius * feather * 0.5;
|
const half = keyhole.radius * feather * 0.5;
|
||||||
@@ -423,6 +412,14 @@ function draw() {
|
|||||||
_painted = true;
|
_painted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expose only the OFF state; an absent attribute preserves the original HUD. */
|
||||||
|
function syncScopeMaskAppearance(enabled = _enabled) {
|
||||||
|
const dataset = globalThis.document?.documentElement?.dataset;
|
||||||
|
if (!dataset) return;
|
||||||
|
if (enabled) delete dataset.scopeMask;
|
||||||
|
else dataset.scopeMask = 'off';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the scope mask into the viewer container. Idempotent.
|
* Install the scope mask into the viewer container. Idempotent.
|
||||||
* @param {Cesium.Viewer} viewer
|
* @param {Cesium.Viewer} viewer
|
||||||
@@ -430,6 +427,7 @@ function draw() {
|
|||||||
*/
|
*/
|
||||||
export function installScopeMask(viewer) {
|
export function installScopeMask(viewer) {
|
||||||
if (_canvas || !viewer?.container) return;
|
if (_canvas || !viewer?.container) return;
|
||||||
|
syncScopeMaskAppearance();
|
||||||
_container = viewer.container;
|
_container = viewer.container;
|
||||||
_viewer = viewer;
|
_viewer = viewer;
|
||||||
_canvas = document.createElement('canvas');
|
_canvas = document.createElement('canvas');
|
||||||
@@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) {
|
|||||||
const next = Boolean(enabled);
|
const next = Boolean(enabled);
|
||||||
const reEnabled = next && !_enabled;
|
const reEnabled = next && !_enabled;
|
||||||
_enabled = next;
|
_enabled = next;
|
||||||
|
syncScopeMaskAppearance();
|
||||||
if (reEnabled) {
|
if (reEnabled) {
|
||||||
// The camera moved freely while the scope was off and nothing sampled it,
|
// The camera moved freely while the scope was off and nothing sampled it,
|
||||||
// so the painted terminus can be a whole altitude band stale. Re-sync once
|
// so the painted terminus can be a whole altitude band stale. Re-sync once
|
||||||
@@ -522,11 +521,14 @@ export function isScopeMaskEnabled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
|
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function setScopeMaskFeather(ratio) {
|
export function setScopeMaskFeather(ratio) {
|
||||||
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
|
_featherRatio = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
|
||||||
|
);
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +542,7 @@ export function getScopeMaskFeather() {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function destroyScopeMask() {
|
export function destroyScopeMask() {
|
||||||
|
syncScopeMaskAppearance(true);
|
||||||
_resizeObserver?.disconnect();
|
_resizeObserver?.disconnect();
|
||||||
_resizeObserver = null;
|
_resizeObserver = null;
|
||||||
teardownDevicePixelRatioWatch();
|
teardownDevicePixelRatioWatch();
|
||||||
@@ -553,6 +556,7 @@ export function destroyScopeMask() {
|
|||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
export function _resetScopeMaskForTest() {
|
export function _resetScopeMaskForTest() {
|
||||||
|
syncScopeMaskAppearance(true);
|
||||||
_resizeObserver?.disconnect();
|
_resizeObserver?.disconnect();
|
||||||
_resizeObserver = null;
|
_resizeObserver = null;
|
||||||
teardownDevicePixelRatioWatch();
|
teardownDevicePixelRatioWatch();
|
||||||
|
|||||||
+50
-5
@@ -18,6 +18,7 @@ import {
|
|||||||
SCOPE_TERMINUS_MIN_PCT,
|
SCOPE_TERMINUS_MIN_PCT,
|
||||||
SCOPE_TERMINUS_MAX_PCT,
|
SCOPE_TERMINUS_MAX_PCT,
|
||||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||||
|
getScopeMaskFeather,
|
||||||
SCOPE_OUTSIDE_ALPHA,
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
SCOPE_TERMINUS_ALPHA_NEAR,
|
SCOPE_TERMINUS_ALPHA_NEAR,
|
||||||
SCOPE_TERMINUS_FAR_M,
|
SCOPE_TERMINUS_FAR_M,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
_resetScopeMaskForTest,
|
_resetScopeMaskForTest,
|
||||||
} from './scopeMask.js';
|
} from './scopeMask.js';
|
||||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
||||||
|
import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||||
|
|
||||||
beforeEach(() => _resetScopeMaskForTest());
|
beforeEach(() => _resetScopeMaskForTest());
|
||||||
|
|
||||||
@@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
|
|||||||
assert.equal(geo.outerR, keyholeR);
|
assert.equal(geo.outerR, keyholeR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => {
|
test('feather ratio reaches the center at 200% and clamps larger input', () => {
|
||||||
const wide = scopeMaskGeometry(1000, 800, 7);
|
const wide = scopeMaskGeometry(1000, 800, 7);
|
||||||
const one = scopeMaskGeometry(1000, 800, 1);
|
const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
|
||||||
assert.equal(wide.innerR, one.innerR);
|
const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||||
assert.equal(wide.outerR, one.outerR);
|
assert.equal(maximum.innerR, 0);
|
||||||
|
assert.equal(maximum.outerR, keyholeR * 2);
|
||||||
|
assert.equal(wide.innerR, maximum.innerR);
|
||||||
|
assert.equal(wide.outerR, maximum.outerR);
|
||||||
|
for (const ratio of [1, 1.5, 2]) {
|
||||||
|
setScopeMaskFeather(ratio);
|
||||||
|
assert.equal(getScopeMaskFeather(), ratio);
|
||||||
|
const geometry = scopeMaskGeometry(1000, 800);
|
||||||
|
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
|
||||||
|
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
|
||||||
|
}
|
||||||
|
setScopeMaskFeather(7);
|
||||||
|
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
|
||||||
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
|
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
|
||||||
const zero = scopeMaskGeometry(1000, 800, 0);
|
const zero = scopeMaskGeometry(1000, 800, 0);
|
||||||
assert.equal(nan.innerR, zero.innerR);
|
assert.equal(nan.innerR, zero.innerR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('UI and share feather percentages share one bounded integer range', () => {
|
||||||
|
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
|
||||||
|
assert.equal(clampScopeFeatherPct(value), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('degenerate viewport yields null instead of a broken gradient', () => {
|
test('degenerate viewport yields null instead of a broken gradient', () => {
|
||||||
assert.equal(scopeMaskGeometry(0, 0), null);
|
assert.equal(scopeMaskGeometry(0, 0), null);
|
||||||
assert.equal(scopeMaskGeometry(-5, 100), null);
|
assert.equal(scopeMaskGeometry(-5, 100), null);
|
||||||
@@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
|||||||
removeEventListener: (type, fn) => listeners.delete(fn),
|
removeEventListener: (type, fn) => listeners.delete(fn),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
globalThis.document = { createElement: () => canvas };
|
const documentElement = { dataset: {} };
|
||||||
|
globalThis.document = { documentElement, createElement: () => canvas };
|
||||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||||
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
|
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
|
||||||
return {
|
return {
|
||||||
canvas,
|
canvas,
|
||||||
container,
|
container,
|
||||||
|
documentElement,
|
||||||
/** Canvas work counters: backing-store resizes, clears, fills. */
|
/** Canvas work counters: backing-store resizes, clears, fills. */
|
||||||
ops,
|
ops,
|
||||||
/** Every fillStyle string assigned since install (hard-crop path). */
|
/** Every fillStyle string assigned since install (hard-crop path). */
|
||||||
@@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
|
||||||
|
const dom = stubScopeMaskDom();
|
||||||
|
try {
|
||||||
|
setScopeMaskEnabled(false);
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
|
||||||
|
delete dom.documentElement.dataset.scopeMask;
|
||||||
|
installScopeMask({ container: dom.container });
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
|
||||||
|
destroyScopeMask();
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
|
||||||
|
installScopeMask({ container: dom.container });
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
|
||||||
|
setScopeMaskEnabled(true);
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
|
||||||
|
setScopeMaskEnabled(false);
|
||||||
|
_resetScopeMaskForTest();
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
|
||||||
|
} finally {
|
||||||
|
destroyScopeMask();
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a DPR change with no resize still repaints the backing store', () => {
|
test('a DPR change with no resize still repaints the backing store', () => {
|
||||||
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
|
||||||
|
export const SCOPE_FEATHER_RATIO_MAX = 2;
|
||||||
|
const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
|
||||||
|
|
||||||
|
/** Normalize a UI/share-link feather percentage to the supported integer range. */
|
||||||
|
export function clampScopeFeatherPct(value) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* What a share link's thing view follows, and where its camera is: the
|
||||||
|
* entity kinds and the follow-offset geometry of the share format
|
||||||
|
* (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code,
|
||||||
|
* default and formula here is permanent.
|
||||||
|
*
|
||||||
|
* Pure and dependency-free, so the app's follow camera and the codec
|
||||||
|
* (viewCodec.js) share one definition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const freezeAll = (rows) =>
|
||||||
|
Object.freeze(rows.map((row) => Object.freeze(row)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Things a view can follow. `code` is permanent and is also the number of the
|
||||||
|
* thing-message field that carries this kind's id, so the kind costs nothing
|
||||||
|
* on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO
|
||||||
|
* address or a decimal number); any other identifier travels as text.
|
||||||
|
* `tracking` names the layer-state slot that holds the same selection in the
|
||||||
|
* long form.
|
||||||
|
*/
|
||||||
|
export const SHARE_VIEW_ENTITY_KINDS = freezeAll([
|
||||||
|
{
|
||||||
|
code: 1,
|
||||||
|
kind: 'aircraft',
|
||||||
|
layerId: 'flights',
|
||||||
|
idForm: 'hex24',
|
||||||
|
tracking: 'flights',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 2,
|
||||||
|
kind: 'military_aircraft',
|
||||||
|
layerId: 'military',
|
||||||
|
idForm: 'hex24',
|
||||||
|
tracking: 'military',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 3,
|
||||||
|
kind: 'vessel',
|
||||||
|
layerId: 'ais-live-vessels',
|
||||||
|
idForm: 'decimal',
|
||||||
|
tracking: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 4,
|
||||||
|
kind: 'satellite',
|
||||||
|
layerId: 'satellites',
|
||||||
|
idForm: 'decimal',
|
||||||
|
tracking: 'satellites',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
/**
|
||||||
|
* The frozen meaning of an omitted follow-offset field. An offset whose
|
||||||
|
* fields all equal these is written as nothing, so it decodes as these.
|
||||||
|
*/
|
||||||
|
export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
|
||||||
|
range: 2000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -35,
|
||||||
|
});
|
||||||
|
|
||||||
|
const EARTH_RADIUS_M = 6_371_000;
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The camera's offset from a followed entity in the entity's local east,
|
||||||
|
* north and up metres: the camera looks along (heading, pitch) at the entity
|
||||||
|
* from `range` metres away. Heading is clockwise from north; a negative pitch
|
||||||
|
* looks down, so the camera sits above the entity.
|
||||||
|
*/
|
||||||
|
export function followOffsetToEnu({ range, heading, pitch }) {
|
||||||
|
const horizontal = range * Math.cos(pitch * RAD);
|
||||||
|
return {
|
||||||
|
east: -horizontal * Math.sin(heading * RAD),
|
||||||
|
north: -horizontal * Math.cos(heading * RAD),
|
||||||
|
up: -range * Math.sin(pitch * RAD),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */
|
||||||
|
export function followOffsetFromEnu({ east, north, up }) {
|
||||||
|
const range = Math.hypot(east, north, up);
|
||||||
|
if (!(range > 0)) return null;
|
||||||
|
return {
|
||||||
|
range: Math.round(range),
|
||||||
|
heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360,
|
||||||
|
pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Earth-centred position of (lat, lon, alt) on the format's sphere. */
|
||||||
|
function toEcef(lat, lon, alt) {
|
||||||
|
const r = EARTH_RADIUS_M + alt;
|
||||||
|
const cosLat = Math.cos(lat * RAD);
|
||||||
|
return [
|
||||||
|
r * cosLat * Math.cos(lon * RAD),
|
||||||
|
r * cosLat * Math.sin(lon * RAD),
|
||||||
|
r * Math.sin(lat * RAD),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The local east, north and up unit vectors at (lat, lon). */
|
||||||
|
function enuBasis(lat, lon) {
|
||||||
|
const sinLat = Math.sin(lat * RAD);
|
||||||
|
const cosLat = Math.cos(lat * RAD);
|
||||||
|
const sinLon = Math.sin(lon * RAD);
|
||||||
|
const cosLon = Math.cos(lon * RAD);
|
||||||
|
return {
|
||||||
|
east: [-sinLon, cosLon, 0],
|
||||||
|
north: [-sinLat * cosLon, -sinLat * sinLon, cosLat],
|
||||||
|
up: [cosLat * cosLon, cosLat * sinLon, sinLat],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the camera sits while following an entity at `fix` from `offset`,
|
||||||
|
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
|
||||||
|
* thing view with a fix stores no camera of its own: this is its camera, and
|
||||||
|
* the place a decoder falls back to. Pure, and computed only from values at
|
||||||
|
* the format's precision, so encoder and decoder agree.
|
||||||
|
*/
|
||||||
|
export function followCameraFor(fix, offset) {
|
||||||
|
const local = followOffsetToEnu(offset);
|
||||||
|
const at = toEcef(fix.lat, fix.lon, fix.alt);
|
||||||
|
const basis = enuBasis(fix.lat, fix.lon);
|
||||||
|
const camera = [0, 1, 2].map(
|
||||||
|
(i) =>
|
||||||
|
at[i] +
|
||||||
|
local.east * basis.east[i] +
|
||||||
|
local.north * basis.north[i] +
|
||||||
|
local.up * basis.up[i],
|
||||||
|
);
|
||||||
|
const horizontal = Math.hypot(camera[0], camera[1]);
|
||||||
|
const lat = Math.atan2(camera[2], horizontal) / RAD;
|
||||||
|
const lon = Math.atan2(camera[1], camera[0]) / RAD;
|
||||||
|
const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M;
|
||||||
|
// The view direction, camera to entity, in the camera's own local frame.
|
||||||
|
const view = [0, 1, 2].map((i) => at[i] - camera[i]);
|
||||||
|
const own = enuBasis(lat, lon);
|
||||||
|
const length = Math.hypot(...view);
|
||||||
|
if (!(length > 0))
|
||||||
|
return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 };
|
||||||
|
return {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
alt,
|
||||||
|
heading:
|
||||||
|
(Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360,
|
||||||
|
pitch:
|
||||||
|
Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD,
|
||||||
|
roll: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Conversion between the long share-link form's parsed state (what
|
||||||
|
* `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the
|
||||||
|
* compact share-view state of viewCodec.js.
|
||||||
|
*
|
||||||
|
* The two use the same field names, so the conversion is small: the compact
|
||||||
|
* form adds `view`, and the long form keeps a followed entity as a tracking
|
||||||
|
* selection inside the layer options instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES,
|
||||||
|
createDefaultLayerState,
|
||||||
|
normalizeLayerState,
|
||||||
|
} from '../data/layerState.js';
|
||||||
|
import {
|
||||||
|
SHARE_VIEW_ENTITY_KINDS,
|
||||||
|
normalizeShareViewState,
|
||||||
|
} from './viewCodec.js';
|
||||||
|
|
||||||
|
/** Fields of the long form's parsed state, in its own order. */
|
||||||
|
export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
|
||||||
|
'lat',
|
||||||
|
'lon',
|
||||||
|
'alt',
|
||||||
|
'heading',
|
||||||
|
'pitch',
|
||||||
|
'roll',
|
||||||
|
'style',
|
||||||
|
'styleParams',
|
||||||
|
'bloom',
|
||||||
|
'sharpen',
|
||||||
|
'bloomIntensity',
|
||||||
|
'bloomVersion',
|
||||||
|
'sharpenIntensity',
|
||||||
|
'hudVariant',
|
||||||
|
'hudVisible',
|
||||||
|
'detectionMode',
|
||||||
|
'detectionDensity',
|
||||||
|
'detectionAllocation',
|
||||||
|
'detectionFadePct',
|
||||||
|
'detectionOutsideOpacityPct',
|
||||||
|
'celestialRing',
|
||||||
|
'scopeEnabled',
|
||||||
|
'scopeFeatherPct',
|
||||||
|
'scopeTerminusPct',
|
||||||
|
'mapStack',
|
||||||
|
'layerState',
|
||||||
|
'layerStateInvalid',
|
||||||
|
'panelState',
|
||||||
|
'sharedAtMs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact share-view state from a parsed long-form state. A tracking
|
||||||
|
* selection in the layer options becomes a thing view with no offset or
|
||||||
|
* last-known fix, since the long form records neither. Restore bookkeeping
|
||||||
|
* (`restoreAuthority`, `layerStateInvalid`) is not part of a view.
|
||||||
|
*/
|
||||||
|
export function fromLegacyShareState(parsed) {
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const picked = {};
|
||||||
|
for (const key of LEGACY_SHARE_STATE_KEYS) {
|
||||||
|
if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key))
|
||||||
|
picked[key] = parsed[key];
|
||||||
|
}
|
||||||
|
return normalizeShareViewState(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed long-form state for a compact share-view state, ready for
|
||||||
|
* `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer
|
||||||
|
* set when the view has none), and a followed aircraft, military aircraft or
|
||||||
|
* satellite goes back into its layer's tracking selection. The long form
|
||||||
|
* cannot express the rest of a thing view (a vessel, the follow offset, the
|
||||||
|
* last-known fix); callers that need them read `state.view` directly.
|
||||||
|
*/
|
||||||
|
export function toLegacyShareState(input) {
|
||||||
|
const state = normalizeShareViewState(input);
|
||||||
|
const entry =
|
||||||
|
state.view.kind === 'thing'
|
||||||
|
? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind)
|
||||||
|
: null;
|
||||||
|
let layerState =
|
||||||
|
entry && !state.layerState
|
||||||
|
? normalizeLayerState({
|
||||||
|
...createDefaultLayerState(),
|
||||||
|
enabledLayerIds: [entry.layerId],
|
||||||
|
})
|
||||||
|
: state.layerState;
|
||||||
|
const policy = entry?.tracking
|
||||||
|
? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking]
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
layerState &&
|
||||||
|
policy &&
|
||||||
|
layerState.enabledLayerIds.includes(entry.layerId)
|
||||||
|
) {
|
||||||
|
const id =
|
||||||
|
entry.kind === 'satellite'
|
||||||
|
? Number(state.view.entity.id)
|
||||||
|
: state.view.entity.id;
|
||||||
|
layerState = normalizeLayerState({
|
||||||
|
...layerState,
|
||||||
|
options: {
|
||||||
|
...layerState.options,
|
||||||
|
[policy.optionOwner]: {
|
||||||
|
...layerState.options[policy.optionOwner],
|
||||||
|
[policy.optionKey]: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const legacy = {};
|
||||||
|
for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key];
|
||||||
|
legacy.layerState = layerState;
|
||||||
|
legacy.layerStateInvalid = false;
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ShareLinkManager } from '../sharelink.js';
|
||||||
|
import { createDefaultLayerState } from '../data/layerState.js';
|
||||||
|
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
|
||||||
|
import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js';
|
||||||
|
import {
|
||||||
|
HAL_EXAMPLE_HASH,
|
||||||
|
pick,
|
||||||
|
randomLayerState,
|
||||||
|
rng,
|
||||||
|
} from '../testSupport/shareViewFixtures.mjs';
|
||||||
|
import {
|
||||||
|
SHARE_VIEW_PANELS,
|
||||||
|
SHARE_VIEW_STYLE_PARAMS,
|
||||||
|
decodeShareView,
|
||||||
|
encodeShareView,
|
||||||
|
} from './viewCodec.js';
|
||||||
|
import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js';
|
||||||
|
import {
|
||||||
|
LEGACY_SHARE_STATE_KEYS,
|
||||||
|
fromLegacyShareState,
|
||||||
|
toLegacyShareState,
|
||||||
|
} from './legacyShareState.js';
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
function stubViewer(camera = {}) {
|
||||||
|
return {
|
||||||
|
camera: {
|
||||||
|
changed: { addEventListener: () => () => {} },
|
||||||
|
positionCartographic: camera.position ?? {
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
heading: camera.heading ?? 0,
|
||||||
|
pitch: camera.pitch ?? 0,
|
||||||
|
roll: camera.roll ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The long form's own parser, run on `hash`. */
|
||||||
|
function parseLegacy(hash) {
|
||||||
|
const body = hash.startsWith('#') ? hash : `#${hash}`;
|
||||||
|
globalThis.window = {
|
||||||
|
location: { hash: body, href: `http://localhost/${body}` },
|
||||||
|
};
|
||||||
|
const manager = new ShareLinkManager(stubViewer());
|
||||||
|
const parsed = manager.parseInitialHash();
|
||||||
|
manager.destroy();
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed state as a view: restore bookkeeping removed, and headings and
|
||||||
|
* rolls wrapped into [0, 360) — the codec's one normalization of values the
|
||||||
|
* long-form generator writes (Cesium can round 359.6° up to `roll=360`).
|
||||||
|
*/
|
||||||
|
function comparable(parsed) {
|
||||||
|
const out = {};
|
||||||
|
for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key];
|
||||||
|
// A long link without `sp` leaves the style's parameters as they are; the
|
||||||
|
// compact form says what they are, the style's own unless changed.
|
||||||
|
const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams;
|
||||||
|
out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null;
|
||||||
|
for (const key of ['heading', 'roll'])
|
||||||
|
out[key] = ((out[key] % 360) + 360) % 360;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy hash → parsed state → codec → parsed state, asserting equality. */
|
||||||
|
function assertLegacyRoundTrip(hash) {
|
||||||
|
const parsed = parseLegacy(hash);
|
||||||
|
assert.ok(parsed, `long form parses: ${hash}`);
|
||||||
|
const state = fromLegacyShareState(parsed);
|
||||||
|
const encoded = encodeShareView(state);
|
||||||
|
const decoded = decodeShareView(encoded, { nowMs: Date.now() });
|
||||||
|
assert.equal(decoded.ok, true, decoded.reason);
|
||||||
|
assert.deepEqual(decoded.state, state, 'codec state survives');
|
||||||
|
assert.deepEqual(
|
||||||
|
toLegacyShareState(decoded.state),
|
||||||
|
comparable(parsed),
|
||||||
|
'parsed long-form state survives',
|
||||||
|
);
|
||||||
|
return { parsed, state, encoded };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the HAL example survives the round trip and shrinks to 39 characters', () => {
|
||||||
|
const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH);
|
||||||
|
assert.equal(HAL_EXAMPLE_HASH.length, 302);
|
||||||
|
assert.equal(encoded.length, 39);
|
||||||
|
assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o');
|
||||||
|
assert.equal(parsed.roll, 360);
|
||||||
|
assert.equal(state.roll, 0);
|
||||||
|
assert.deepEqual(state.layerState.enabledLayerIds, [
|
||||||
|
'ais-live-vessels',
|
||||||
|
'flights',
|
||||||
|
'satellites',
|
||||||
|
]);
|
||||||
|
assert.equal(state.layerState.options.flights.models3d, true);
|
||||||
|
assert.equal(state.layerState.options.wind.overlay, 'none');
|
||||||
|
assert.deepEqual(
|
||||||
|
state.panelState.specs.find((spec) => spec.id === 'cctv-panel'),
|
||||||
|
{ id: 'cctv-panel', collapsed: false, pinned: null },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links from earlier long-form versions keep their historical meaning', () => {
|
||||||
|
for (const hash of [
|
||||||
|
'lat=10&lon=20',
|
||||||
|
'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal',
|
||||||
|
'v=2&lat=1&lon=2&l=f&lo=f.m.a',
|
||||||
|
'v=2&lat=1&lon=2&l=',
|
||||||
|
'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35',
|
||||||
|
'v=2&lat=1&lon=2&ui=v.c.0',
|
||||||
|
'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100',
|
||||||
|
'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600',
|
||||||
|
]) {
|
||||||
|
assertLegacyRoundTrip(hash);
|
||||||
|
}
|
||||||
|
// A pre-v2 link's omitted settings are written explicitly, so the compact
|
||||||
|
// form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35).
|
||||||
|
const old = fromLegacyShareState(parseLegacy('lat=10&lon=20'));
|
||||||
|
assert.deepEqual(
|
||||||
|
[old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct],
|
||||||
|
[5, 16, 35],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[old.bloomIntensity, old.bloomVersion],
|
||||||
|
[50, 1],
|
||||||
|
'legacy bloom scale',
|
||||||
|
);
|
||||||
|
// `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that.
|
||||||
|
const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f'));
|
||||||
|
assert.equal(off.layerState.options.flights.models3d, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tracking selection becomes a thing view and back', () => {
|
||||||
|
const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3');
|
||||||
|
assert.deepEqual(flights.state.view.entity, {
|
||||||
|
kind: 'aircraft',
|
||||||
|
id: 'a1b2c3',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
flights.state.layerState.options.flights.selectedFlightsTrackingId,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234');
|
||||||
|
assert.deepEqual(military.state.view.entity, {
|
||||||
|
kind: 'military_aircraft',
|
||||||
|
id: '~ae1234',
|
||||||
|
});
|
||||||
|
const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544');
|
||||||
|
assert.deepEqual(satellite.state.view.entity, {
|
||||||
|
kind: 'satellite',
|
||||||
|
id: '25544',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
toLegacyShareState(satellite.state).layerState.options.satellites
|
||||||
|
.selectedSatTrackingId,
|
||||||
|
25544,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thing views the long form cannot express convert without one', () => {
|
||||||
|
const layerState = createDefaultLayerState();
|
||||||
|
layerState.enabledLayerIds = ['ais-live-vessels'];
|
||||||
|
const vessel = toLegacyShareState({
|
||||||
|
lat: 1,
|
||||||
|
lon: 2,
|
||||||
|
layerState,
|
||||||
|
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(vessel.layerState, layerState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a thing view turns its layer on, with or without a layer set', () => {
|
||||||
|
for (const layerState of [createDefaultLayerState(), null]) {
|
||||||
|
const legacy = toLegacyShareState({
|
||||||
|
layerState,
|
||||||
|
view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']);
|
||||||
|
assert.equal(
|
||||||
|
legacy.layerState.options.flights.selectedFlightsTrackingId,
|
||||||
|
'a1b2c3',
|
||||||
|
);
|
||||||
|
assert.equal(legacy.layerStateInvalid, false);
|
||||||
|
}
|
||||||
|
const vessel = toLegacyShareState({
|
||||||
|
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']);
|
||||||
|
// A place view without a layer set leaves the recipient's layers alone.
|
||||||
|
assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A long-form link written by the real generator from random live state. */
|
||||||
|
function generatedLegacyHash(random) {
|
||||||
|
const int = (min, max) => min + Math.floor(random() * (max - min + 1));
|
||||||
|
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||||
|
const manager = new ShareLinkManager(
|
||||||
|
stubViewer({
|
||||||
|
position: {
|
||||||
|
latitude: (int(-899999, 899999) / 1e4) * DEG,
|
||||||
|
longitude: (int(-1799999, 1799999) / 1e4) * DEG,
|
||||||
|
height: pick(random, [int(-50, 3000), int(0, 30_000_000)]),
|
||||||
|
},
|
||||||
|
heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
|
||||||
|
pitch: int(-90, 0) * DEG,
|
||||||
|
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const style = pick(random, VIEW_STYLES);
|
||||||
|
manager.onStyleChange(style);
|
||||||
|
const density = pick(random, [0, 25, 50, 75, 100]);
|
||||||
|
manager.onToggleChange(random() < 0.5, random() < 0.5, {
|
||||||
|
bloomIntensity: int(0, 100),
|
||||||
|
bloomVersion: pick(random, [1, 2]),
|
||||||
|
sharpenIntensity: int(0, 100),
|
||||||
|
hudVariant: pick(random, HUD_LAYOUTS),
|
||||||
|
hudVisible: random() < 0.5,
|
||||||
|
detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']),
|
||||||
|
detectionDensity: density,
|
||||||
|
detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']),
|
||||||
|
detectionFadePct: int(0, 40),
|
||||||
|
detectionOutsideOpacityPct: int(0, 100),
|
||||||
|
celestialRingEnabled: random() < 0.5,
|
||||||
|
scopeEnabled: random() < 0.5,
|
||||||
|
scopeFeatherPct: int(0, 200),
|
||||||
|
scopeTerminusPct: random() < 0.3 ? int(94, 100) : null,
|
||||||
|
mapStack: pick(random, VIEW_MAPS),
|
||||||
|
});
|
||||||
|
if (random() < 0.8) {
|
||||||
|
const layerState = randomLayerState(random);
|
||||||
|
manager.setLayerStateProvider(() => layerState);
|
||||||
|
}
|
||||||
|
if (random() < 0.8) {
|
||||||
|
const panelState = {
|
||||||
|
specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({
|
||||||
|
id: panel.id,
|
||||||
|
collapsed: random() < 0.7,
|
||||||
|
...(panel.pinnable ? { pinned: random() < 0.3 } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
manager.setPanelStateProvider(() => panelState);
|
||||||
|
}
|
||||||
|
const specs = SHARE_VIEW_STYLE_PARAMS[style];
|
||||||
|
if (specs && random() < 0.7) {
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
specs.map((spec) => [
|
||||||
|
spec.key,
|
||||||
|
spec.min + (spec.max - spec.min) * random(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
manager.setStyleParamStateProvider(() => values);
|
||||||
|
}
|
||||||
|
const params = manager._buildHashParams();
|
||||||
|
if (random() < 0.7)
|
||||||
|
params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000))));
|
||||||
|
manager.destroy();
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('links written by the long-form generator survive the compact form', () => {
|
||||||
|
const random = rng(0xc0dec);
|
||||||
|
let totalLegacy = 0;
|
||||||
|
let totalCompact = 0;
|
||||||
|
for (let i = 0; i < 400; i += 1) {
|
||||||
|
const hash = generatedLegacyHash(random);
|
||||||
|
const { encoded } = assertLegacyRoundTrip(hash);
|
||||||
|
totalLegacy += hash.length;
|
||||||
|
totalCompact += encoded.length;
|
||||||
|
}
|
||||||
|
// Even random states, far from defaults, are under half the length.
|
||||||
|
assert.ok(
|
||||||
|
totalCompact * 2 < totalLegacy,
|
||||||
|
`${totalCompact} vs ${totalLegacy}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the converter ignores non-objects and restore bookkeeping', () => {
|
||||||
|
// A malformed v2 layer list is reported by the long-form parser; the view
|
||||||
|
// itself simply has no layer set.
|
||||||
|
const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown');
|
||||||
|
assert.equal(invalid.layerStateInvalid, true);
|
||||||
|
assert.equal(fromLegacyShareState(invalid).layerState, null);
|
||||||
|
assert.equal(fromLegacyShareState(null), null);
|
||||||
|
const parsed = parseLegacy(HAL_EXAMPLE_HASH);
|
||||||
|
assert.ok(parsed.restoreAuthority);
|
||||||
|
const state = fromLegacyShareState(parsed);
|
||||||
|
assert.equal(Object.hasOwn(state, 'restoreAuthority'), false);
|
||||||
|
assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the converter knows every key of the long form’s parsed state', () => {
|
||||||
|
// A key the converter does not list would silently drop out of share codes.
|
||||||
|
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||||
|
const manager = new ShareLinkManager(stubViewer({}));
|
||||||
|
const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(parsed).sort(),
|
||||||
|
[...LEGACY_SHARE_STATE_KEYS].sort(),
|
||||||
|
);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Share-link addresses: a share code travels in the path, `<app>/share/<code>`,
|
||||||
|
* where `<app>` is wherever the app is served (the origin's root, or a base
|
||||||
|
* path). The same code opens on any copy of the app.
|
||||||
|
*
|
||||||
|
* Pure: no DOM. Callers pass the addresses they have.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The path segment that introduces a share code. */
|
||||||
|
export const SHARE_PATH_SEGMENT = 'share';
|
||||||
|
|
||||||
|
// Any last segment counts as a code here, so a mangled one is refused by the
|
||||||
|
// decoder (and said so) rather than opening the app at a wrong address.
|
||||||
|
const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share code and the app's base path for a share-link pathname, or null
|
||||||
|
* when the pathname is not a share link. `/share/AQc` gives
|
||||||
|
* `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`.
|
||||||
|
*/
|
||||||
|
export function parseSharePath(pathname) {
|
||||||
|
if (typeof pathname !== 'string') return null;
|
||||||
|
const match = SHARE_PATH.exec(pathname);
|
||||||
|
if (!match) return null;
|
||||||
|
let code = match[2];
|
||||||
|
try {
|
||||||
|
code = decodeURIComponent(code);
|
||||||
|
} catch {
|
||||||
|
// Left as written; the decoder refuses it.
|
||||||
|
}
|
||||||
|
return { code, basePath: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's root address for `href`: the base path of a share link, or the
|
||||||
|
* directory of any other page, with no hash. The query is kept.
|
||||||
|
*/
|
||||||
|
export function appRootUrl(href) {
|
||||||
|
const url = new URL(href);
|
||||||
|
url.hash = '';
|
||||||
|
const share = parseSharePath(url.pathname);
|
||||||
|
url.pathname = share ? share.basePath : new URL('./', url).pathname;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share link for `code` on the app at `href`. The sender's `?lang` stays
|
||||||
|
* behind: the recipient keeps their own language.
|
||||||
|
*/
|
||||||
|
export function shareLinkUrl(code, href) {
|
||||||
|
const root = appRootUrl(href);
|
||||||
|
const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root);
|
||||||
|
url.search = root.search;
|
||||||
|
url.searchParams.delete('lang');
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js';
|
||||||
|
|
||||||
|
test('a share path names its code and the app base path', () => {
|
||||||
|
assert.deepEqual(parseSharePath('/share/AQc'), {
|
||||||
|
code: 'AQc',
|
||||||
|
basePath: '/',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseSharePath('/gev/share/AQc/'), {
|
||||||
|
code: 'AQc',
|
||||||
|
basePath: '/gev/',
|
||||||
|
});
|
||||||
|
// A mangled code is still a share link, so the app refuses it out loud.
|
||||||
|
assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c');
|
||||||
|
for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc'])
|
||||||
|
assert.equal(parseSharePath(path), null, path);
|
||||||
|
assert.equal(parseSharePath(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app root drops a share path and the hash, and keeps the query', () => {
|
||||||
|
assert.equal(
|
||||||
|
appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href,
|
||||||
|
'https://gev.example/?qa=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
appRootUrl('http://localhost:4173/gev/share/AQc').href,
|
||||||
|
'http://localhost:4173/gev/',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
appRootUrl('https://gev.example/index.html#v=2&lat=1').href,
|
||||||
|
'https://gev.example/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a share link sits under the app root without the sender ?lang', () => {
|
||||||
|
assert.equal(
|
||||||
|
shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'),
|
||||||
|
'https://gev.example/share/AQc',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'),
|
||||||
|
'https://gev.example/gev/share/AQc?qa=1',
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Byte-level primitives for the compact share-view format: base64url text,
|
||||||
|
* protobuf-style varints, and a tag-length-value reader and writer.
|
||||||
|
*
|
||||||
|
* Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a
|
||||||
|
* {@link ShareWireError} with a stable `reason`, so the view codec can turn it
|
||||||
|
* into a rejected link instead of an exception at app start.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wire types. Field numbers and their wire types are permanent. */
|
||||||
|
export const WIRE_VARINT = 0;
|
||||||
|
export const WIRE_FIXED64 = 1;
|
||||||
|
export const WIRE_LEN = 2;
|
||||||
|
export const WIRE_FIXED32 = 5;
|
||||||
|
|
||||||
|
/** Field numbers stay inside protobuf's range so tags fit in five bytes. */
|
||||||
|
export const MAX_FIELD_NUMBER = 2 ** 29 - 1;
|
||||||
|
const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer.
|
||||||
|
|
||||||
|
/** A malformed or out-of-bounds payload. `reason` is a stable code. */
|
||||||
|
export class ShareWireError extends Error {
|
||||||
|
constructor(reason, detail = '') {
|
||||||
|
super(detail ? `${reason}: ${detail}` : reason);
|
||||||
|
this.name = 'ShareWireError';
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64URL =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||||
|
const BASE64URL_INDEX = new Map(
|
||||||
|
[...BASE64URL].map((char, index) => [char, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Unpadded base64url text for `bytes`. */
|
||||||
|
export function encodeBase64Url(bytes) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const a = bytes[i];
|
||||||
|
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||||
|
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||||
|
const triple = (a << 16) | (b << 8) | c;
|
||||||
|
out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63];
|
||||||
|
if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63];
|
||||||
|
if (i + 2 < bytes.length) out += BASE64URL[triple & 63];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes for unpadded base64url text. Rejects padding, foreign characters, an
|
||||||
|
* impossible length and non-zero trailing bits, so every payload has exactly
|
||||||
|
* one spelling.
|
||||||
|
*/
|
||||||
|
export function decodeBase64Url(text) {
|
||||||
|
if (typeof text !== 'string' || text.length === 0)
|
||||||
|
throw new ShareWireError('empty');
|
||||||
|
if (text.length % 4 === 1) throw new ShareWireError('bad-base64');
|
||||||
|
const bytes = new Uint8Array(Math.floor((text.length * 3) / 4));
|
||||||
|
let buffer = 0;
|
||||||
|
let bits = 0;
|
||||||
|
let out = 0;
|
||||||
|
for (const char of text) {
|
||||||
|
const value = BASE64URL_INDEX.get(char);
|
||||||
|
if (value === undefined) throw new ShareWireError('bad-base64');
|
||||||
|
buffer = ((buffer << 6) | value) & 0xffffff;
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
bytes[out++] = (buffer >> bits) & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0)
|
||||||
|
throw new ShareWireError('bad-base64', 'non-zero trailing bits');
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection;
|
||||||
|
* no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to
|
||||||
|
* 8 bits, so any one mistyped base64url character (6 bits) is caught.
|
||||||
|
*/
|
||||||
|
export function crc8(bytes) {
|
||||||
|
let crc = 0;
|
||||||
|
for (const byte of bytes) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let bit = 0; bit < 8; bit += 1)
|
||||||
|
crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
|
||||||
|
}
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */
|
||||||
|
export function zigzag(value) {
|
||||||
|
return value >= 0 ? value * 2 : -value * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of {@link zigzag}. */
|
||||||
|
export function unzigzag(value) {
|
||||||
|
return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest magnitude a zigzag field can carry without losing precision. */
|
||||||
|
export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2);
|
||||||
|
|
||||||
|
/** Append-only byte sink with protobuf-style field helpers. */
|
||||||
|
export class WireWriter {
|
||||||
|
constructor() {
|
||||||
|
this.bytes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
varint(value) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0)
|
||||||
|
throw new RangeError(`varint out of range: ${value}`);
|
||||||
|
let rest = value;
|
||||||
|
while (rest >= 0x80) {
|
||||||
|
this.bytes.push((rest % 0x80) | 0x80);
|
||||||
|
rest = Math.floor(rest / 0x80);
|
||||||
|
}
|
||||||
|
this.bytes.push(rest);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag(field, wireType) {
|
||||||
|
if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER)
|
||||||
|
throw new RangeError(`field number out of range: ${field}`);
|
||||||
|
return this.varint(field * 8 + wireType);
|
||||||
|
}
|
||||||
|
|
||||||
|
uintField(field, value) {
|
||||||
|
return this.tag(field, WIRE_VARINT).varint(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sintField(field, value) {
|
||||||
|
if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE)
|
||||||
|
throw new RangeError(`signed value out of range: ${value}`);
|
||||||
|
return this.tag(field, WIRE_VARINT).varint(zigzag(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesField(field, bytes) {
|
||||||
|
this.tag(field, WIRE_LEN).varint(bytes.length);
|
||||||
|
for (const byte of bytes) this.bytes.push(byte);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ASCII-only text; every share-view string is drawn from ASCII grammars. */
|
||||||
|
textField(field, text) {
|
||||||
|
return this.bytesField(field, asciiBytes(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
return Uint8Array.from(this.bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Printable-ASCII bytes for `text`; anything else is a programming error. */
|
||||||
|
export function asciiBytes(text) {
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
const code = text.charCodeAt(i);
|
||||||
|
if (code < 0x20 || code > 0x7e)
|
||||||
|
throw new RangeError('share-view text must be printable ASCII');
|
||||||
|
bytes.push(code);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Printable-ASCII text from bytes; anything else is a malformed payload. */
|
||||||
|
export function asciiText(bytes) {
|
||||||
|
let out = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text');
|
||||||
|
out += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounds-checked cursor over one message (or one packed blob). */
|
||||||
|
export class WireReader {
|
||||||
|
constructor(bytes, start = 0, end = bytes.length) {
|
||||||
|
this.bytes = bytes;
|
||||||
|
this.pos = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
get done() {
|
||||||
|
return this.pos >= this.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */
|
||||||
|
varint() {
|
||||||
|
let value = 0;
|
||||||
|
let scale = 1;
|
||||||
|
for (let count = 0; count < MAX_VARINT_BYTES; count += 1) {
|
||||||
|
if (this.pos >= this.end) throw new ShareWireError('truncated');
|
||||||
|
const byte = this.bytes[this.pos++];
|
||||||
|
value += (byte & 0x7f) * scale;
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
// A zero final byte after a continuation is an overlong spelling.
|
||||||
|
if (count > 0 && byte === 0)
|
||||||
|
throw new ShareWireError('non-canonical-varint');
|
||||||
|
if (!Number.isSafeInteger(value))
|
||||||
|
throw new ShareWireError('varint-overflow');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
scale *= 0x80;
|
||||||
|
}
|
||||||
|
throw new ShareWireError('varint-overflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
svarint() {
|
||||||
|
return unzigzag(this.varint());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next field's number and wire type, rejecting reserved wire types. */
|
||||||
|
tag() {
|
||||||
|
const key = this.varint();
|
||||||
|
const field = Math.floor(key / 8);
|
||||||
|
const wireType = key % 8;
|
||||||
|
if (field < 1 || field > MAX_FIELD_NUMBER)
|
||||||
|
throw new ShareWireError('bad-field-number');
|
||||||
|
if (
|
||||||
|
wireType !== WIRE_VARINT &&
|
||||||
|
wireType !== WIRE_FIXED64 &&
|
||||||
|
wireType !== WIRE_LEN &&
|
||||||
|
wireType !== WIRE_FIXED32
|
||||||
|
)
|
||||||
|
throw new ShareWireError('bad-wire-type', String(wireType));
|
||||||
|
return { field, wireType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A length-delimited body as a sub-reader over the same bytes. */
|
||||||
|
lengthDelimited() {
|
||||||
|
const length = this.varint();
|
||||||
|
if (length > this.end - this.pos)
|
||||||
|
throw new ShareWireError('truncated', 'length exceeds payload');
|
||||||
|
const reader = new WireReader(this.bytes, this.pos, this.pos + length);
|
||||||
|
this.pos += length;
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining bytes of this reader as a fresh array. */
|
||||||
|
rest() {
|
||||||
|
const out = this.bytes.slice(this.pos, this.end);
|
||||||
|
this.pos = this.end;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skip one value of `wireType`, so newer fields cost older decoders nothing. */
|
||||||
|
skip(wireType) {
|
||||||
|
if (wireType === WIRE_VARINT) this.varint();
|
||||||
|
else if (wireType === WIRE_LEN) this.lengthDelimited();
|
||||||
|
else {
|
||||||
|
const size = wireType === WIRE_FIXED64 ? 8 : 4;
|
||||||
|
if (size > this.end - this.pos) throw new ShareWireError('truncated');
|
||||||
|
this.pos += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ShareWireError,
|
||||||
|
WIRE_FIXED32,
|
||||||
|
WIRE_FIXED64,
|
||||||
|
WIRE_LEN,
|
||||||
|
WIRE_VARINT,
|
||||||
|
WireReader,
|
||||||
|
WireWriter,
|
||||||
|
crc8,
|
||||||
|
decodeBase64Url,
|
||||||
|
encodeBase64Url,
|
||||||
|
unzigzag,
|
||||||
|
zigzag,
|
||||||
|
} from './wire.js';
|
||||||
|
|
||||||
|
const reasonOf = (fn) => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof ShareWireError, String(error));
|
||||||
|
return error.reason;
|
||||||
|
}
|
||||||
|
assert.fail('expected a ShareWireError');
|
||||||
|
};
|
||||||
|
|
||||||
|
test('base64url round-trips every length and byte value without padding', () => {
|
||||||
|
for (let length = 0; length < 70; length += 1) {
|
||||||
|
const bytes = Uint8Array.from(
|
||||||
|
{ length },
|
||||||
|
(_, i) => (i * 97 + length) & 0xff,
|
||||||
|
);
|
||||||
|
const text = encodeBase64Url(bytes);
|
||||||
|
assert.match(text, /^[A-Za-z0-9_-]*$/);
|
||||||
|
if (length) assert.deepEqual(decodeBase64Url(text), bytes);
|
||||||
|
}
|
||||||
|
const all = Uint8Array.from({ length: 256 }, (_, i) => i);
|
||||||
|
assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all);
|
||||||
|
assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => {
|
||||||
|
for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) {
|
||||||
|
assert.ok(
|
||||||
|
['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))),
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// "AR" carries one byte (0x01) plus four trailing bits that must be zero.
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => decodeBase64Url('AR')),
|
||||||
|
'bad-base64',
|
||||||
|
);
|
||||||
|
assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zigzag maps small magnitudes of either sign to small codes', () => {
|
||||||
|
assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]);
|
||||||
|
for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) {
|
||||||
|
assert.equal(unzigzag(zigzag(value)), value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('varints round-trip up to the largest safe integer', () => {
|
||||||
|
const values = [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
127,
|
||||||
|
128,
|
||||||
|
300,
|
||||||
|
2 ** 21,
|
||||||
|
2 ** 31,
|
||||||
|
2 ** 32 + 5,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
];
|
||||||
|
const writer = new WireWriter();
|
||||||
|
for (const value of values) writer.varint(value);
|
||||||
|
const reader = new WireReader(writer.finish());
|
||||||
|
for (const value of values) assert.equal(reader.varint(), value);
|
||||||
|
assert.ok(reader.done);
|
||||||
|
assert.deepEqual(
|
||||||
|
new WireWriter().varint(300).finish(),
|
||||||
|
Uint8Array.of(0xac, 0x02),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the writer refuses values the format cannot carry', () => {
|
||||||
|
assert.throws(() => new WireWriter().varint(-1), RangeError);
|
||||||
|
assert.throws(() => new WireWriter().varint(1.5), RangeError);
|
||||||
|
assert.throws(() => new WireWriter().varint(2 ** 53), RangeError);
|
||||||
|
assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError);
|
||||||
|
assert.throws(() => new WireWriter().textField(1, 'é'), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reader rejects truncated, overlong and overflowing varints', () => {
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()),
|
||||||
|
'truncated',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()),
|
||||||
|
'non-canonical-varint',
|
||||||
|
);
|
||||||
|
const tooLong = new Uint8Array(9).fill(0xff);
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(tooLong).varint()),
|
||||||
|
'varint-overflow',
|
||||||
|
);
|
||||||
|
const beyondSafe = Uint8Array.of(
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0xff,
|
||||||
|
0x7f,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(beyondSafe).varint()),
|
||||||
|
'varint-overflow',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reader bounds lengths and rejects group and reserved wire types', () => {
|
||||||
|
const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish();
|
||||||
|
const reader = new WireReader(absurd);
|
||||||
|
reader.tag();
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => reader.lengthDelimited()),
|
||||||
|
'truncated',
|
||||||
|
);
|
||||||
|
for (const wireType of [3, 4, 6, 7]) {
|
||||||
|
const bytes = new WireWriter().varint(8 + wireType).finish();
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(bytes).tag()),
|
||||||
|
'bad-wire-type',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()),
|
||||||
|
'bad-field-number',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip steps over each wire type and stops at a truncated value', () => {
|
||||||
|
const writer = new WireWriter()
|
||||||
|
.uintField(1, 300)
|
||||||
|
.bytesField(2, [1, 2, 3])
|
||||||
|
.tag(3, WIRE_FIXED32);
|
||||||
|
writer.bytes.push(1, 2, 3, 4);
|
||||||
|
writer.tag(4, WIRE_FIXED64);
|
||||||
|
writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8);
|
||||||
|
writer.uintField(5, 9);
|
||||||
|
const reader = new WireReader(writer.finish());
|
||||||
|
for (let field = 1; field <= 4; field += 1)
|
||||||
|
reader.skip(reader.tag().wireType);
|
||||||
|
assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT });
|
||||||
|
assert.equal(reader.varint(), 9);
|
||||||
|
const short = new WireWriter().tag(1, WIRE_FIXED64).finish();
|
||||||
|
const shortReader = new WireReader(Uint8Array.of(...short, 1, 2));
|
||||||
|
assert.equal(
|
||||||
|
reasonOf(() => shortReader.skip(shortReader.tag().wireType)),
|
||||||
|
'truncated',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => {
|
||||||
|
// The catalogued check value for the ASCII digits 1–9.
|
||||||
|
assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4);
|
||||||
|
assert.equal(crc8([]), 0);
|
||||||
|
const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33];
|
||||||
|
const sum = crc8(bytes);
|
||||||
|
for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) {
|
||||||
|
for (let pattern = 1; pattern < 256; pattern += 1) {
|
||||||
|
// Flip a burst starting at `bit` (its first bit always set).
|
||||||
|
if (!(pattern & 1)) continue;
|
||||||
|
const flipped = [...bytes];
|
||||||
|
for (let k = 0; k < 8; k += 1) {
|
||||||
|
if (!(pattern & (1 << k))) continue;
|
||||||
|
const at = bit + k;
|
||||||
|
flipped[at >> 3] ^= 0x80 >> (at & 7);
|
||||||
|
}
|
||||||
|
assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,6 +8,12 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
||||||
import { createDefaultLayerState } from './data/layerState.js';
|
import { createDefaultLayerState } from './data/layerState.js';
|
||||||
|
import { parseSharePath } from './share/shareLinkPath.js';
|
||||||
|
import { decodeShareView } from './share/viewCodec.js';
|
||||||
|
|
||||||
|
/** The decoded view of a copied `/share/<code>` link. */
|
||||||
|
const sharedState = (href) =>
|
||||||
|
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
|
||||||
|
|
||||||
const uiSource = readShellSource();
|
const uiSource = readShellSource();
|
||||||
|
|
||||||
@@ -99,6 +105,22 @@ test('Nepal locator token is valid in v2 share links', () => {
|
|||||||
assert.equal(parsed.layerStateInvalid, false);
|
assert.equal(parsed.layerStateInvalid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scope feather above 100% survives share parsing and serialization', () => {
|
||||||
|
const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200');
|
||||||
|
const parsed = manager.parseInitialHash();
|
||||||
|
assert.equal(parsed.scopeEnabled, false);
|
||||||
|
assert.equal(parsed.scopeFeatherPct, 200);
|
||||||
|
assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175);
|
||||||
|
assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200);
|
||||||
|
manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 });
|
||||||
|
const params = manager._buildHashParams();
|
||||||
|
assert.equal(params.get('sc'), '0');
|
||||||
|
assert.equal(params.get('scf'), '200');
|
||||||
|
manager.onToggleChange(false, true, { scopeFeatherPct: 999 });
|
||||||
|
assert.equal(manager._buildHashParams().get('scf'), '200');
|
||||||
|
clearTimeout(manager._debounceTimer);
|
||||||
|
});
|
||||||
|
|
||||||
test('share-link serialization emits the current celestial state', () => {
|
test('share-link serialization emits the current celestial state', () => {
|
||||||
const manager = makeManager();
|
const manager = makeManager();
|
||||||
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
||||||
@@ -294,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async (
|
|||||||
|
|
||||||
assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true);
|
assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true);
|
||||||
assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true);
|
assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true);
|
||||||
assert.equal(new URL(copied[0]).hash.includes('at=2000'), true);
|
assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000);
|
||||||
assert.equal(new URL(copied[1]).hash.includes('at=2002'), true);
|
assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000);
|
||||||
assert.equal(window.location.hash, liveHash);
|
assert.equal(window.location.hash, liveHash);
|
||||||
assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false);
|
assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false);
|
||||||
});
|
});
|
||||||
@@ -307,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress
|
|||||||
manager.parseInitialHash();
|
manager.parseInitialHash();
|
||||||
assert.equal(manager._initialRestorePending, true);
|
assert.equal(manager._initialRestorePending, true);
|
||||||
assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true);
|
assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true);
|
||||||
const params = new URL(copied).hash.slice(1);
|
assert.equal(sharedState(copied).sharedAtMs, 3_000_000);
|
||||||
assert.equal(new URLSearchParams(params).get('at'), '3000');
|
|
||||||
assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100');
|
assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -498,8 +519,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate
|
|||||||
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
||||||
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
|
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
|
||||||
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
|
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
|
||||||
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
||||||
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
|
['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
|
||||||
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
|
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
|
||||||
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
|
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
|
||||||
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
|
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
|
||||||
|
|||||||
+186
-84
@@ -5,17 +5,36 @@ import {
|
|||||||
normalizeAllocationStrategy,
|
normalizeAllocationStrategy,
|
||||||
} from './data/detectionPolicy.js';
|
} from './data/detectionPolicy.js';
|
||||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||||
|
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||||
import {
|
import {
|
||||||
decodeLayerStateParams,
|
decodeLayerStateParams,
|
||||||
encodeLayerStateParams,
|
encodeLayerStateParams,
|
||||||
} from './data/layerState.js';
|
} from './data/layerState.js';
|
||||||
import { STYLE_URL_NAMES } from './view/index.js';
|
import { STYLE_URL_NAMES } from './view/index.js';
|
||||||
|
import {
|
||||||
|
SHARE_VIEW_PANELS,
|
||||||
|
SHARE_VIEW_STYLE_PARAMS,
|
||||||
|
decodeShareView,
|
||||||
|
encodeShareView,
|
||||||
|
} from './share/viewCodec.js';
|
||||||
|
import {
|
||||||
|
fromLegacyShareState,
|
||||||
|
toLegacyShareState,
|
||||||
|
} from './share/legacyShareState.js';
|
||||||
|
import {
|
||||||
|
appRootUrl,
|
||||||
|
parseSharePath,
|
||||||
|
shareLinkUrl,
|
||||||
|
} from './share/shareLinkPath.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share Links — URL Hash State Management
|
* Share Links — URL Hash State Management
|
||||||
*
|
*
|
||||||
* Encodes camera position + style into the URL hash so links can be shared.
|
* The address bar follows the live view in the long hash form:
|
||||||
* Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
|
* #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
|
||||||
|
*
|
||||||
|
* A shared link is the short form, `<app>/share/<code>`, with the code from
|
||||||
|
* src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEBOUNCE_MS = 500;
|
const DEBOUNCE_MS = 500;
|
||||||
@@ -28,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui';
|
|||||||
const SHARE_STYLE_PARAMS_PARAM = 'sp';
|
const SHARE_STYLE_PARAMS_PARAM = 'sp';
|
||||||
const SHARE_CREATED_AT_PARAM = 'at';
|
const SHARE_CREATED_AT_PARAM = 'at';
|
||||||
|
|
||||||
const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
// The panel and visual-preset parameter tables are the share format's own
|
||||||
{ id: 'control-panel', token: 'c', pinnable: true },
|
// (src/share/viewCodec.js); the long form spells their entries by token.
|
||||||
{ id: 'location-bar', token: 'l', pinnable: true },
|
const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS;
|
||||||
{ id: 'data-panel', token: 'd', pinnable: false },
|
|
||||||
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
|
||||||
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
|
||||||
{ id: 'street-level-panel', token: 't', pinnable: false },
|
|
||||||
{ id: 'radio-panel', token: 'r', pinnable: false },
|
|
||||||
{ id: 'scene-panel', token: 's', pinnable: false },
|
|
||||||
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
|
||||||
{ id: 'pp-toggles', token: 'p', pinnable: false },
|
|
||||||
{ id: 'param-slider-panel', token: 'm', pinnable: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
|
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
|
||||||
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
|
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
|
||||||
);
|
);
|
||||||
@@ -50,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries(
|
|||||||
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
|
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS;
|
||||||
retro: Object.freeze([
|
|
||||||
{ key: 'pixelation', token: 'p', min: 1, max: 10 },
|
|
||||||
{ key: 'distortion', token: 'd', min: 0, max: 1 },
|
|
||||||
{ key: 'instability', token: 'i', min: 0, max: 1 },
|
|
||||||
]),
|
|
||||||
surveillance: Object.freeze([
|
|
||||||
{ key: 'gain', token: 'g', min: 0, max: 1 },
|
|
||||||
{ key: 'bloom', token: 'b', min: 0, max: 1 },
|
|
||||||
{ key: 'scanlineStr', token: 's', min: 0, max: 1 },
|
|
||||||
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
|
|
||||||
]),
|
|
||||||
thermal: Object.freeze([
|
|
||||||
{ key: 'sensitivity', token: 's', min: 0, max: 1 },
|
|
||||||
{ key: 'bloom', token: 'b', min: 0, max: 1 },
|
|
||||||
{ key: 'mode', token: 'm', min: 0, max: 1 },
|
|
||||||
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
|
|
||||||
{ key: 'palette', token: 'a', min: 0, max: 1 },
|
|
||||||
]),
|
|
||||||
anime: Object.freeze([
|
|
||||||
{ key: 'saturation', token: 's', min: 0, max: 2 },
|
|
||||||
{ key: 'edgeThick', token: 'e', min: 0, max: 1 },
|
|
||||||
]),
|
|
||||||
noir: Object.freeze([
|
|
||||||
{ key: 'contrastAmt', token: 'c', min: 0, max: 2 },
|
|
||||||
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
|
|
||||||
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
|
|
||||||
]),
|
|
||||||
snow: Object.freeze([
|
|
||||||
{ key: 'density', token: 'd', min: 0, max: 1 },
|
|
||||||
{ key: 'wind', token: 'w', min: 0, max: 1 },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ShareLinkManager {
|
export class ShareLinkManager {
|
||||||
constructor(
|
constructor(
|
||||||
viewer,
|
viewer,
|
||||||
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
|
{
|
||||||
|
onRestore,
|
||||||
|
isNavigationCurrent,
|
||||||
|
cancelOwnedNavigation,
|
||||||
|
initialShareCode = null,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
|
// A host may hand over the share code to open instead of the address.
|
||||||
|
this._initialShareCode =
|
||||||
|
typeof initialShareCode === 'string' ? initialShareCode : null;
|
||||||
|
// Why the share code this page opened with was refused, if it was.
|
||||||
|
this.rejectedShareCode = null;
|
||||||
|
this._followSubjectProvider = null;
|
||||||
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
||||||
this._debounceTimer = null;
|
this._debounceTimer = null;
|
||||||
this._currentStyle = 'normal';
|
this._currentStyle = 'normal';
|
||||||
@@ -112,10 +99,7 @@ export class ShareLinkManager {
|
|||||||
this._detectionOutsideOpacityPct = 1;
|
this._detectionOutsideOpacityPct = 1;
|
||||||
this._celestialRingEnabled = false;
|
this._celestialRingEnabled = false;
|
||||||
this._scopeEnabled = true;
|
this._scopeEnabled = true;
|
||||||
// Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24,
|
this._scopeFeatherPct = 100;
|
||||||
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors
|
|
||||||
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value.
|
|
||||||
this._scopeFeatherPct = 11;
|
|
||||||
// null = the altitude-adaptive terminus (the default). A number pins the
|
// null = the altitude-adaptive terminus (the default). A number pins the
|
||||||
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
|
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
|
||||||
this._scopeTerminusPct = null;
|
this._scopeTerminusPct = null;
|
||||||
@@ -124,6 +108,13 @@ export class ShareLinkManager {
|
|||||||
this._panelStateProvider = null;
|
this._panelStateProvider = null;
|
||||||
this._styleParamStateProvider = null;
|
this._styleParamStateProvider = null;
|
||||||
this._initialRestorePending = false;
|
this._initialRestorePending = false;
|
||||||
|
// Each incoming share (start-up or a later hash navigation) gets a token;
|
||||||
|
// only the newest one may release URL-write suppression.
|
||||||
|
this._restoreToken = 0;
|
||||||
|
// The hash this page last read or wrote. A location change back to it is
|
||||||
|
// the app's own state, never an incoming share.
|
||||||
|
this._knownHash = null;
|
||||||
|
this._unwatchNavigation = null;
|
||||||
this._restoreAuthority = {
|
this._restoreAuthority = {
|
||||||
visual: 0,
|
visual: 0,
|
||||||
map: 0,
|
map: 0,
|
||||||
@@ -150,10 +141,44 @@ export class ShareLinkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse URL hash on page load. Returns parsed state or null.
|
* Parse the share this page opened with: the host's `initialShareCode`, a
|
||||||
|
* `/share/<code>` address, or a long-form hash. Returns the parsed state, or
|
||||||
|
* null for none. A share code that cannot be opened is recorded in
|
||||||
|
* `rejectedShareCode` and restores nothing.
|
||||||
*/
|
*/
|
||||||
parseInitialHash() {
|
parseInitialHash() {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
|
this._knownHash = hash;
|
||||||
|
const code =
|
||||||
|
this._initialShareCode ??
|
||||||
|
parseSharePath(window.location.pathname)?.code ??
|
||||||
|
null;
|
||||||
|
this._initialShareCode = null;
|
||||||
|
const state =
|
||||||
|
code === null ? this._parseShareHash(hash) : this._parseShareCode(code);
|
||||||
|
if (!state) return null;
|
||||||
|
this._beginIncomingRestore(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The restore state for a share code, in the long form's parsed shape plus
|
||||||
|
* `shareView` (what the view follows, with its offset and last-known fix),
|
||||||
|
* or null when the code is refused.
|
||||||
|
*/
|
||||||
|
_parseShareCode(code) {
|
||||||
|
const result = decodeShareView(code);
|
||||||
|
if (!result.ok) {
|
||||||
|
this.rejectedShareCode = result.reason;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const state = toLegacyShareState(result.state);
|
||||||
|
state.shareView = result.state.view;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse one hash without side effects. Returns null unless it is a share. */
|
||||||
|
_parseShareHash(hash) {
|
||||||
if (!hash) return null;
|
if (!hash) return null;
|
||||||
|
|
||||||
const params = new URLSearchParams(hash);
|
const params = new URLSearchParams(hash);
|
||||||
@@ -227,10 +252,7 @@ export class ShareLinkManager {
|
|||||||
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
||||||
// mirrors live state for the link this session generates, so it must match
|
// mirrors live state for the link this session generates, so it must match
|
||||||
// the mask, not the archive.)
|
// the mask, not the archive.)
|
||||||
scopeFeatherPct: Math.max(
|
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
|
||||||
0,
|
|
||||||
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
|
|
||||||
),
|
|
||||||
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
||||||
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
||||||
@@ -247,15 +269,55 @@ export class ShareLinkManager {
|
|||||||
panelState: decodePanelStateParams(params),
|
panelState: decodePanelStateParams(params),
|
||||||
sharedAtMs: decodeShareCreatedAtMs(params),
|
sharedAtMs: decodeShareCreatedAtMs(params),
|
||||||
};
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve restore lanes and hold URL writes for one incoming share. */
|
||||||
|
_beginIncomingRestore(state) {
|
||||||
state.restoreAuthority = {
|
state.restoreAuthority = {
|
||||||
visual: this._restoreAuthority.visual,
|
visual: this._restoreAuthority.visual,
|
||||||
map: this._restoreAuthority.map,
|
map: this._restoreAuthority.map,
|
||||||
panels: new Map(this._restoreAuthority.panels),
|
panels: new Map(this._restoreAuthority.panels),
|
||||||
};
|
};
|
||||||
|
state.restoreToken = ++this._restoreToken;
|
||||||
// Hold URL writes until the complete incoming state has been restored.
|
// Hold URL writes until the complete incoming state has been restored. A
|
||||||
|
// debounced write of the previous view must not land over the new link.
|
||||||
this._initialRestorePending = true;
|
this._initialRestorePending = true;
|
||||||
return state;
|
clearTimeout(this._debounceTimer);
|
||||||
|
this._debounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a share link opened in this tab after start-up: a pasted address,
|
||||||
|
* a followed link, or Back/Forward. The app's own replaceState writes never
|
||||||
|
* fire these events, and a hash equal to the last one read or written is
|
||||||
|
* ignored anyway, so only a genuinely different incoming share restores.
|
||||||
|
*/
|
||||||
|
watchNavigation(onNavigate) {
|
||||||
|
this._unwatchNavigation?.();
|
||||||
|
if (this._destroyed || typeof onNavigate !== 'function') return;
|
||||||
|
const target = globalThis.window;
|
||||||
|
if (typeof target?.addEventListener !== 'function') return;
|
||||||
|
const handler = () => this._handleLocationChange(onNavigate);
|
||||||
|
target.addEventListener('hashchange', handler);
|
||||||
|
target.addEventListener('popstate', handler);
|
||||||
|
this._unwatchNavigation = () => {
|
||||||
|
target.removeEventListener('hashchange', handler);
|
||||||
|
target.removeEventListener('popstate', handler);
|
||||||
|
this._unwatchNavigation = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleLocationChange(onNavigate) {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
// Back/Forward fires both popstate and hashchange for one entry.
|
||||||
|
if (hash === this._knownHash) return;
|
||||||
|
this._knownHash = hash;
|
||||||
|
const state = this._parseShareHash(hash);
|
||||||
|
if (!state) return;
|
||||||
|
this._beginIncomingRestore(state);
|
||||||
|
onNavigate(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -379,9 +441,13 @@ export class ShareLinkManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release initial hash suppression only after every restore owner settles. */
|
/**
|
||||||
completeInitialRestore() {
|
* Release hash suppression only after every restore owner settles. A token
|
||||||
|
* from a superseded share restore leaves the newer one holding the URL.
|
||||||
|
*/
|
||||||
|
completeInitialRestore(restoreToken = this._restoreToken) {
|
||||||
if (!this._initialRestorePending) return;
|
if (!this._initialRestorePending) return;
|
||||||
|
if (restoreToken !== this._restoreToken) return;
|
||||||
this._initialRestorePending = false;
|
this._initialRestorePending = false;
|
||||||
this._scheduleUpdate();
|
this._scheduleUpdate();
|
||||||
}
|
}
|
||||||
@@ -404,6 +470,16 @@ export class ShareLinkManager {
|
|||||||
this._layerStateProvider = typeof provider === 'function' ? provider : null;
|
this._layerStateProvider = typeof provider === 'function' ? provider : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the source of the entity the camera follows, used for share links:
|
||||||
|
* `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share
|
||||||
|
* format's terms (src/share/follow.js), with the fix as of `nowMs`.
|
||||||
|
*/
|
||||||
|
setFollowSubjectProvider(provider) {
|
||||||
|
this._followSubjectProvider =
|
||||||
|
typeof provider === 'function' ? provider : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Install the finalized panel-state source used by URL generation. */
|
/** Install the finalized panel-state source used by URL generation. */
|
||||||
setPanelStateProvider(provider) {
|
setPanelStateProvider(provider) {
|
||||||
this._panelStateProvider = typeof provider === 'function' ? provider : null;
|
this._panelStateProvider = typeof provider === 'function' ? provider : null;
|
||||||
@@ -494,10 +570,7 @@ export class ShareLinkManager {
|
|||||||
if (typeof extras.scopeEnabled === 'boolean')
|
if (typeof extras.scopeEnabled === 'boolean')
|
||||||
this._scopeEnabled = extras.scopeEnabled;
|
this._scopeEnabled = extras.scopeEnabled;
|
||||||
if (typeof extras.scopeFeatherPct === 'number') {
|
if (typeof extras.scopeFeatherPct === 'number') {
|
||||||
this._scopeFeatherPct = Math.max(
|
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
|
||||||
0,
|
|
||||||
Math.min(100, Math.round(extras.scopeFeatherPct)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||||
@@ -507,17 +580,41 @@ export class ShareLinkManager {
|
|||||||
this._scheduleUpdate();
|
this._scheduleUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share link for the current state, `<app>/share/<code>`, stamped with
|
||||||
|
* its creation time, or null before the camera has a position. While the
|
||||||
|
* camera follows an entity, the link follows it too.
|
||||||
|
*/
|
||||||
|
buildLink({ nowMs = Date.now() } = {}) {
|
||||||
|
const params = this._buildHashParams();
|
||||||
|
if (!params) return null;
|
||||||
|
// The long form is the one description of the live state; the code is
|
||||||
|
// that state in the share format.
|
||||||
|
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
|
||||||
|
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
|
||||||
|
let subject = null;
|
||||||
|
try {
|
||||||
|
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
|
||||||
|
} catch {
|
||||||
|
subject = null;
|
||||||
|
}
|
||||||
|
if (subject) {
|
||||||
|
state.view = {
|
||||||
|
kind: 'thing',
|
||||||
|
entity: { kind: subject.kind, id: subject.id },
|
||||||
|
offset: subject.offset ?? null,
|
||||||
|
lastKnown: subject.lastKnown ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return shareLinkUrl(encodeShareView(state), window.location.href);
|
||||||
|
}
|
||||||
|
|
||||||
/** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */
|
/** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */
|
||||||
async copyLink({ nowMs = Date.now() } = {}) {
|
async copyLink({ nowMs = Date.now() } = {}) {
|
||||||
const params = this._buildHashParams();
|
|
||||||
if (!params) return false;
|
|
||||||
params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000)));
|
|
||||||
const copiedUrl = new URL(window.location.href);
|
|
||||||
// ?lang is the sender's language override; the recipient keeps their own.
|
|
||||||
copiedUrl.searchParams.delete('lang');
|
|
||||||
copiedUrl.hash = params.toString();
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copiedUrl.href);
|
const href = this.buildLink({ nowMs });
|
||||||
|
if (!href) return false;
|
||||||
|
await navigator.clipboard.writeText(href);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -535,12 +632,15 @@ export class ShareLinkManager {
|
|||||||
const params = this._buildHashParams();
|
const params = this._buildHashParams();
|
||||||
if (!params) return;
|
if (!params) return;
|
||||||
// The hash belongs to this page's address, not the document base the
|
// The hash belongs to this page's address, not the document base the
|
||||||
// app may load under.
|
// app may load under. A `/share/<code>` address gives way to the app's
|
||||||
history.replaceState(
|
// own address, so a reload follows the live view; replacing it adds no
|
||||||
null,
|
// history entry.
|
||||||
'',
|
const url = parseSharePath(new URL(window.location.href).pathname)
|
||||||
new URL(`#${params.toString()}`, window.location.href).href,
|
? appRootUrl(window.location.href)
|
||||||
);
|
: new URL(window.location.href);
|
||||||
|
url.hash = params.toString();
|
||||||
|
this._knownHash = url.hash.slice(1);
|
||||||
|
history.replaceState(null, '', url.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a deterministic snapshot without mutating history. */
|
/** Build a deterministic snapshot without mutating history. */
|
||||||
@@ -599,8 +699,8 @@ export class ShareLinkManager {
|
|||||||
this._styleParamStateProvider?.(this._currentStyle),
|
this._styleParamStateProvider?.(this._currentStyle),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy-time metadata is intentionally absent here. `copyLink()` adds a
|
// Creation-time metadata is intentionally absent here. `buildLink()`
|
||||||
// fresh timestamp to its ephemeral URL without aging the live address.
|
// stamps its own link without aging the live address.
|
||||||
params.delete(SHARE_CREATED_AT_PARAM);
|
params.delete(SHARE_CREATED_AT_PARAM);
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
@@ -623,9 +723,11 @@ export class ShareLinkManager {
|
|||||||
this._debounceTimer = null;
|
this._debounceTimer = null;
|
||||||
this._removeCameraChanged?.();
|
this._removeCameraChanged?.();
|
||||||
this._removeCameraChanged = null;
|
this._removeCameraChanged = null;
|
||||||
|
this._unwatchNavigation?.();
|
||||||
this._layerStateProvider = null;
|
this._layerStateProvider = null;
|
||||||
this._panelStateProvider = null;
|
this._panelStateProvider = null;
|
||||||
this._styleParamStateProvider = null;
|
this._styleParamStateProvider = null;
|
||||||
|
this._followSubjectProvider = null;
|
||||||
this._onRestore = null;
|
this._onRestore = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ShareLinkManager } from './sharelink.js';
|
||||||
|
import { ShareRestoration } from './ui/shareRestoration.js';
|
||||||
|
import { DataLayerManager } from './data/manager.js';
|
||||||
|
import {
|
||||||
|
LAYER_STATE_REGISTRY,
|
||||||
|
REGISTERED_LAYER_IDS,
|
||||||
|
createDefaultLayerState,
|
||||||
|
decodeLayerStateParams,
|
||||||
|
} from './data/layerState.js';
|
||||||
|
import { LayerStateCoordinator } from './data/layerStateCoordinator.js';
|
||||||
|
|
||||||
|
const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e';
|
||||||
|
const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window whose address behaves like a tab's: `navigate` is a hash-only
|
||||||
|
* navigation (new history entry plus hashchange), `back`/`forward` traverse
|
||||||
|
* entries (popstate plus hashchange), and `history.replaceState` rewrites the
|
||||||
|
* current entry without firing anything.
|
||||||
|
*/
|
||||||
|
function installTab(t, initialHash = '') {
|
||||||
|
const prior = {
|
||||||
|
window: globalThis.window,
|
||||||
|
history: globalThis.history,
|
||||||
|
};
|
||||||
|
const target = new EventTarget();
|
||||||
|
const location = new URL(
|
||||||
|
`http://localhost/${initialHash ? `#${initialHash}` : ''}`,
|
||||||
|
);
|
||||||
|
const entries = [location.hash];
|
||||||
|
let index = 0;
|
||||||
|
const listeners = { hashchange: 0, popstate: 0 };
|
||||||
|
const add = target.addEventListener.bind(target);
|
||||||
|
const remove = target.removeEventListener.bind(target);
|
||||||
|
target.addEventListener = (type, handler, options) => {
|
||||||
|
if (type in listeners) listeners[type] += 1;
|
||||||
|
add(type, handler, options);
|
||||||
|
};
|
||||||
|
target.removeEventListener = (type, handler, options) => {
|
||||||
|
if (type in listeners) listeners[type] -= 1;
|
||||||
|
remove(type, handler, options);
|
||||||
|
};
|
||||||
|
target.location = location;
|
||||||
|
globalThis.window = target;
|
||||||
|
globalThis.history = {
|
||||||
|
replaceState(_state, _title, next) {
|
||||||
|
location.hash = new URL(next, location.href).hash;
|
||||||
|
entries[index] = location.hash;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
t.after(() => Object.assign(globalThis, prior));
|
||||||
|
const go = (nextIndex) => {
|
||||||
|
index = nextIndex;
|
||||||
|
location.hash = entries[index];
|
||||||
|
target.dispatchEvent(new Event('popstate'));
|
||||||
|
target.dispatchEvent(new Event('hashchange'));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
navigate(hash) {
|
||||||
|
entries.splice(index + 1);
|
||||||
|
entries.push(`#${hash}`);
|
||||||
|
index = entries.length - 1;
|
||||||
|
location.hash = hash;
|
||||||
|
target.dispatchEvent(new Event('hashchange'));
|
||||||
|
},
|
||||||
|
back: () => go(index - 1),
|
||||||
|
forward: () => go(index + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue timers instead of running them, so delayed work runs only on demand. */
|
||||||
|
function installManualTimers(t) {
|
||||||
|
const prior = {
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
|
};
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
globalThis.setTimeout = (fn) => {
|
||||||
|
timers.set(++timerId, fn);
|
||||||
|
return timerId;
|
||||||
|
};
|
||||||
|
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||||
|
t.after(() => Object.assign(globalThis, prior));
|
||||||
|
return timers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeViewer() {
|
||||||
|
return {
|
||||||
|
camera: {
|
||||||
|
changed: { addEventListener: () => () => {} },
|
||||||
|
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
||||||
|
heading: 0,
|
||||||
|
pitch: -Math.PI / 2,
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a hash navigation to a different share restores it and holds URL writes', (t) => {
|
||||||
|
const tab = installTab(t, SHARE_A);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
const initial = manager.parseInitialHash();
|
||||||
|
manager.completeInitialRestore(initial.restoreToken);
|
||||||
|
const navigated = [];
|
||||||
|
manager.watchNavigation((state) => navigated.push(state));
|
||||||
|
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(navigated.length, 1);
|
||||||
|
assert.equal(navigated[0].lat, 48.8584);
|
||||||
|
assert.equal(navigated[0].style, 'normal');
|
||||||
|
assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']);
|
||||||
|
assert.equal(manager._initialRestorePending, true);
|
||||||
|
// Nothing from the previous view may overwrite the incoming link.
|
||||||
|
manager._updateHash();
|
||||||
|
assert.equal(window.location.hash, `#${SHARE_B}`);
|
||||||
|
manager.completeInitialRestore(navigated[0].restoreToken);
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the app's own hash writes never trigger a restore", (t) => {
|
||||||
|
installTab(t, SHARE_A);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||||
|
let restores = 0;
|
||||||
|
manager.watchNavigation(() => {
|
||||||
|
restores += 1;
|
||||||
|
});
|
||||||
|
manager._updateHash();
|
||||||
|
assert.notEqual(window.location.hash, `#${SHARE_A}`);
|
||||||
|
// replaceState fires nothing, but a stray event for our own hash is ignored.
|
||||||
|
window.dispatchEvent(new Event('hashchange'));
|
||||||
|
window.dispatchEvent(new Event('popstate'));
|
||||||
|
assert.equal(restores, 0);
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
|
||||||
|
const tab = installTab(t, SHARE_A);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||||
|
// The app rewrites entry 0 with its own state; Back must return to that.
|
||||||
|
manager._updateHash();
|
||||||
|
const ownHash = window.location.hash.slice(1);
|
||||||
|
const navigated = [];
|
||||||
|
manager.watchNavigation((state) => navigated.push(state));
|
||||||
|
|
||||||
|
tab.navigate('not-a-share');
|
||||||
|
assert.equal(navigated.length, 0);
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(navigated.length, 1);
|
||||||
|
manager.completeInitialRestore(navigated[0].restoreToken);
|
||||||
|
|
||||||
|
tab.back(); // not-a-share
|
||||||
|
tab.back(); // the app's own rewritten first entry
|
||||||
|
assert.equal(navigated.length, 2, 'popstate and hashchange restore once');
|
||||||
|
assert.equal(window.location.hash.slice(1), ownHash);
|
||||||
|
assert.equal(navigated[1].lat, 0);
|
||||||
|
manager.completeInitialRestore(navigated[1].restoreToken);
|
||||||
|
|
||||||
|
tab.forward();
|
||||||
|
tab.forward();
|
||||||
|
assert.equal(navigated.length, 3);
|
||||||
|
assert.equal(navigated[2].lat, 48.8584);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newer navigation supersedes an older restore and its pending write', (t) => {
|
||||||
|
const tab = installTab(t, SHARE_A);
|
||||||
|
const timers = installManualTimers(t);
|
||||||
|
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||||
|
assert.equal(timers.size, 1, 'releasing suppression schedules a write');
|
||||||
|
const navigated = [];
|
||||||
|
manager.watchNavigation((state) => navigated.push(state));
|
||||||
|
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(
|
||||||
|
timers.size,
|
||||||
|
0,
|
||||||
|
'the pending write of the old view is cancelled',
|
||||||
|
);
|
||||||
|
tab.navigate(SHARE_A);
|
||||||
|
const [older, newer] = navigated;
|
||||||
|
assert.ok(newer.restoreToken > older.restoreToken);
|
||||||
|
manager.completeInitialRestore(older.restoreToken);
|
||||||
|
assert.equal(
|
||||||
|
manager._initialRestorePending,
|
||||||
|
true,
|
||||||
|
'a superseded restore cannot release writes',
|
||||||
|
);
|
||||||
|
manager.completeInitialRestore(newer.restoreToken);
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy removes the navigation listeners', (t) => {
|
||||||
|
const tab = installTab(t, SHARE_A);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.parseInitialHash();
|
||||||
|
let restores = 0;
|
||||||
|
manager.watchNavigation(() => {
|
||||||
|
restores += 1;
|
||||||
|
});
|
||||||
|
assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 });
|
||||||
|
manager.watchNavigation(() => {
|
||||||
|
restores += 1;
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
tab.listeners,
|
||||||
|
{ hashchange: 1, popstate: 1 },
|
||||||
|
'rewatching replaces',
|
||||||
|
);
|
||||||
|
manager.destroy();
|
||||||
|
assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 });
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(restores, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */
|
||||||
|
function restorationHarness(t, initialHash, navigationOverrides = {}) {
|
||||||
|
const tab = installTab(t, initialHash);
|
||||||
|
const timers = installManualTimers(t);
|
||||||
|
|
||||||
|
const restores = [];
|
||||||
|
let navigationGeneration = 0;
|
||||||
|
const manager = new ShareLinkManager(fakeViewer(), {
|
||||||
|
onRestore: (state) => {
|
||||||
|
const gate = deferred();
|
||||||
|
restores.push({ state, gate });
|
||||||
|
return gate.promise;
|
||||||
|
},
|
||||||
|
isNavigationCurrent: () => false,
|
||||||
|
});
|
||||||
|
const layerCalls = [];
|
||||||
|
const handoffs = [];
|
||||||
|
const owner = new ShareRestoration({
|
||||||
|
viewer: { canvas: new EventTarget() },
|
||||||
|
navigation: {
|
||||||
|
_beginDeferredNavigation: () => ++navigationGeneration,
|
||||||
|
_reassertNavigationHandoff: (generation, options) => {
|
||||||
|
handoffs.push({ generation, options });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
_stampNavigation() {},
|
||||||
|
...navigationOverrides,
|
||||||
|
},
|
||||||
|
syncShareState() {},
|
||||||
|
syncModels3d() {},
|
||||||
|
showStatus() {},
|
||||||
|
feedback: {},
|
||||||
|
updateFeedback() {},
|
||||||
|
});
|
||||||
|
owner.attachLinks(manager);
|
||||||
|
owner._layerStateCoordinator = {
|
||||||
|
applyShareLayerState(options) {
|
||||||
|
layerCalls.push(options);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
getDurableState: () => null,
|
||||||
|
cancelPendingShareTracking: () => false,
|
||||||
|
destroy() {},
|
||||||
|
};
|
||||||
|
owner.start();
|
||||||
|
t.after(() => owner.destroy());
|
||||||
|
return { tab, timers, manager, owner, restores, layerCalls, handoffs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => {
|
||||||
|
const { tab, timers, manager, owner, restores, layerCalls } =
|
||||||
|
restorationHarness(t, SHARE_A);
|
||||||
|
assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight');
|
||||||
|
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(timers.size, 0, 'the delayed start-up restore never runs');
|
||||||
|
assert.equal((await owner.initialRestorePromise).status, 'superseded');
|
||||||
|
assert.equal(restores.length, 1);
|
||||||
|
assert.equal(restores[0].state.style, 'normal');
|
||||||
|
assert.equal(restores[0].state.mapStack, 'photoreal');
|
||||||
|
assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']);
|
||||||
|
|
||||||
|
restores[0].gate.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a navigated share releases the old Follow passively, never as a saved preference', (t) => {
|
||||||
|
const { tab, handoffs } = restorationHarness(t, SHARE_A);
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(handoffs.length, 1);
|
||||||
|
assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a navigated share that fails to start never freezes the address bar', async (t) => {
|
||||||
|
const settled = [];
|
||||||
|
let calls = 0;
|
||||||
|
const { tab, manager } = restorationHarness(t, SHARE_A, {
|
||||||
|
// Start-up reserves the camera; the navigated share then fails to.
|
||||||
|
_beginDeferredNavigation: () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls > 1) throw new Error('navigation unavailable');
|
||||||
|
return calls;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.addEventListener('gev:share-navigation-restore-settled', (event) =>
|
||||||
|
settled.push(event.detail),
|
||||||
|
);
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
assert.equal(settled[0]?.status, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second navigation during a restore supersedes it end to end', async (t) => {
|
||||||
|
const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A);
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
tab.navigate(SHARE_A);
|
||||||
|
assert.equal(restores.length, 2);
|
||||||
|
assert.equal(layerCalls.length, 2);
|
||||||
|
assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [
|
||||||
|
'earthquakes',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The newer restore finishes first; the older one settling late must not
|
||||||
|
// release or reopen anything.
|
||||||
|
restores[1].gate.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(manager._initialRestorePending, false);
|
||||||
|
manager._initialRestorePending = true; // observe any late release
|
||||||
|
restores[0].gate.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(manager._initialRestorePending, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the layer coordinator replaces in-flight share layers with the navigated share', async () => {
|
||||||
|
const enableGates = new Map();
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
for (const id of REGISTERED_LAYER_IDS) {
|
||||||
|
manager.register({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
icon: '',
|
||||||
|
source: 'test',
|
||||||
|
async init() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async enable() {
|
||||||
|
const gate = enableGates.get(id);
|
||||||
|
if (gate) await gate.promise;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async update() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async disable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||||
|
const quakeGate = deferred();
|
||||||
|
enableGates.set('earthquakes', quakeGate);
|
||||||
|
const writes = [];
|
||||||
|
const storage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: (...args) => writes.push(args),
|
||||||
|
};
|
||||||
|
const sink = {
|
||||||
|
provider: null,
|
||||||
|
setLayerStateProvider(provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
},
|
||||||
|
onLayerStateChange() {},
|
||||||
|
};
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||||
|
const first = coordinator.start({
|
||||||
|
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)),
|
||||||
|
allowLocalState: false,
|
||||||
|
});
|
||||||
|
const second = coordinator.applyShareLayerState({
|
||||||
|
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)),
|
||||||
|
});
|
||||||
|
quakeGate.resolve();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true;
|
||||||
|
assert.equal(enabled('earthquakes'), false);
|
||||||
|
assert.equal(enabled('flights'), true);
|
||||||
|
assert.deepEqual(sink.provider().enabledLayerIds, ['flights']);
|
||||||
|
assert.equal(coordinator.source, 'share');
|
||||||
|
assert.deepEqual(
|
||||||
|
writes,
|
||||||
|
[],
|
||||||
|
'a passive share restore never writes local preferences',
|
||||||
|
);
|
||||||
|
|
||||||
|
// A share without a layer payload restores the defaults, as a reload does.
|
||||||
|
await coordinator.applyShareLayerState({ shareLayerState: null });
|
||||||
|
assert.equal(coordinator.source, 'legacy-share');
|
||||||
|
assert.deepEqual(
|
||||||
|
sink.provider().enabledLayerIds,
|
||||||
|
createDefaultLayerState().enabledLayerIds,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
enabled('flights'),
|
||||||
|
createDefaultLayerState().enabledLayerIds.includes('flights'),
|
||||||
|
);
|
||||||
|
coordinator.destroy();
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js';
|
|||||||
import { createCycloneSource } from '../layers/cyclones/source.js';
|
import { createCycloneSource } from '../layers/cyclones/source.js';
|
||||||
import { createWindSource } from '../layers/wind/source.js';
|
import { createWindSource } from '../layers/wind/source.js';
|
||||||
import { createFirmsSource } from '../layers/firms/source.js';
|
import { createFirmsSource } from '../layers/firms/source.js';
|
||||||
|
import { createGhostMapsSource } from '../layers/ghostMaps/source.js';
|
||||||
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
|
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
|
||||||
import { createReferenceSources } from '../sources/reference.js';
|
import { createReferenceSources } from '../sources/reference.js';
|
||||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||||
@@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) {
|
|||||||
launches: createLaunchSource(),
|
launches: createLaunchSource(),
|
||||||
alpr: createAlprTileSource(),
|
alpr: createAlprTileSource(),
|
||||||
firms: createFirmsSource(),
|
firms: createFirmsSource(),
|
||||||
|
's2-ghostmaps': createGhostMapsSource(),
|
||||||
wind: createWindSource(),
|
wind: createWindSource(),
|
||||||
weather: createWeatherSource(),
|
weather: createWeatherSource(),
|
||||||
cyclones: createCycloneSource(),
|
cyclones: createCycloneSource(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function createStandaloneTools(options) {
|
|||||||
...options,
|
...options,
|
||||||
voice: {
|
voice: {
|
||||||
supportedProviders: ['openai', 'gemini'],
|
supportedProviders: ['openai', 'gemini'],
|
||||||
|
panel: true,
|
||||||
toolCatalog: loadToolCatalog,
|
toolCatalog: loadToolCatalog,
|
||||||
...options?.voice,
|
...options?.voice,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anime / Studio Ghibli Style — Cel-shading + Saturation boost
|
* Anime / Studio Ghibli Style — Cel-shading + Saturation boost
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Noir Style — Desaturation + High Contrast + Vignette
|
* Noir Style — Desaturation + High Contrast + Vignette
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CRT Terminal — Dynamic CRT Monitor Aesthetic
|
* CRT Terminal — Dynamic CRT Monitor Aesthetic
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snow Style — White overlay + Desaturation + Falling particles
|
* Snow Style — White overlay + Desaturation + Falling particles
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Night Vision / NVG — PVS-14 Image Intensifier Emulation
|
* Night Vision / NVG — PVS-14 Image Intensifier Emulation
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FLIR — Forward Looking Infrared
|
* FLIR — Forward Looking Infrared
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Shared fixtures for the share-view codec tests: a seeded generator and
|
||||||
|
* real long-form links.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
REGISTERED_LAYER_IDS,
|
||||||
|
createDefaultLayerState,
|
||||||
|
optionSpecs,
|
||||||
|
} from '../data/layerState.js';
|
||||||
|
|
||||||
|
/** A long-form link copied from a real session. */
|
||||||
|
export const HAL_EXAMPLE_HASH =
|
||||||
|
'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0';
|
||||||
|
|
||||||
|
/** Deterministic generator (mulberry32) for property-style tests. */
|
||||||
|
export function rng(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTION_CANDIDATES = [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
'light',
|
||||||
|
'strong',
|
||||||
|
'gfs',
|
||||||
|
'ifs',
|
||||||
|
'none',
|
||||||
|
'speed',
|
||||||
|
'temperature',
|
||||||
|
'pressure',
|
||||||
|
'km/h',
|
||||||
|
'm/s',
|
||||||
|
'mph',
|
||||||
|
'proximity',
|
||||||
|
'all',
|
||||||
|
'core',
|
||||||
|
'dense',
|
||||||
|
'off',
|
||||||
|
'on',
|
||||||
|
'viewshed',
|
||||||
|
'tomtom',
|
||||||
|
'osm',
|
||||||
|
'hybrid',
|
||||||
|
'clouds',
|
||||||
|
'clouds-regional',
|
||||||
|
'pano',
|
||||||
|
'flat',
|
||||||
|
'filtered',
|
||||||
|
'full',
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
37,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
365,
|
||||||
|
-12345678,
|
||||||
|
12345678,
|
||||||
|
8505110,
|
||||||
|
'S30:2026-09-18',
|
||||||
|
'L30:2024-02-29',
|
||||||
|
'VIIRS:2025-01-02',
|
||||||
|
'genre:jazz & blues',
|
||||||
|
'news',
|
||||||
|
'public-safety',
|
||||||
|
0.35,
|
||||||
|
0.8,
|
||||||
|
'abc-1*def-0',
|
||||||
|
'osm-1',
|
||||||
|
25544,
|
||||||
|
'a1b2c3',
|
||||||
|
'~abc123',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A random layer state using only values each option accepts. */
|
||||||
|
export function randomLayerState(random) {
|
||||||
|
const state = createDefaultLayerState();
|
||||||
|
state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3);
|
||||||
|
for (const ownerId of Object.keys(state.options)) {
|
||||||
|
for (const spec of optionSpecs(ownerId)) {
|
||||||
|
if (random() < 0.5) continue;
|
||||||
|
const accepted = OPTION_CANDIDATES.filter(
|
||||||
|
(value) => spec.normalize(value) !== null,
|
||||||
|
);
|
||||||
|
if (accepted.length)
|
||||||
|
state.options[ownerId][spec.key] =
|
||||||
|
accepted[Math.floor(random() * accepted.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One element of `values`, chosen by `random`. */
|
||||||
|
export function pick(random, values) {
|
||||||
|
return values[Math.floor(random() * values.length)];
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only'
|
|||||||
const plugins = localProviderPlugins();
|
const plugins = localProviderPlugins();
|
||||||
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
|
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
|
||||||
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
|
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
|
||||||
|
assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (plugin.name === 'gev-key-setup') {
|
if (plugin.name === 'gev-key-setup') {
|
||||||
assert.equal(plugin.configurePreviewServer, undefined);
|
assert.equal(plugin.configurePreviewServer, undefined);
|
||||||
@@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
|||||||
['/api/firms/status', 200],
|
['/api/firms/status', 200],
|
||||||
['/api/fire-perimeters/inciweb/publication/invalid', 400],
|
['/api/fire-perimeters/inciweb/publication/invalid', 400],
|
||||||
['/api/gnss-integrity?lat=invalid', 400],
|
['/api/gnss-integrity?lat=invalid', 400],
|
||||||
|
['/api/ghostmaps/unknown', 404],
|
||||||
['/api/terrain/heights?points=invalid', 400],
|
['/api/terrain/heights?points=invalid', 400],
|
||||||
['/api/overpass', 405],
|
['/api/overpass', 405],
|
||||||
['/api/overpass/status', 200],
|
['/api/overpass/status', 200],
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export class AircraftDisplay {
|
|||||||
// a screen reader without this. It matters more now that the button ships
|
// a screen reader without this. It matters more now that the button ships
|
||||||
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||||
// tech reported was an unpressed-looking control over an armed layer.
|
// tech reported was an unpressed-looking control over an armed layer.
|
||||||
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
|
||||||
this._models3dBtn?.setAttribute(
|
this._models3dBtn?.setAttribute(
|
||||||
'aria-pressed',
|
'aria-pressed',
|
||||||
String(this._models3dEnabled),
|
String(this._models3dEnabled),
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
|
|||||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||||
|
|
||||||
import { ShellFeedback } from './shellFeedback.js';
|
import { ShellFeedback } from './shellFeedback.js';
|
||||||
|
import {
|
||||||
|
applyShareFollowOffset,
|
||||||
|
readShareFollowSubject,
|
||||||
|
} from './shareFollow.js';
|
||||||
|
|
||||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||||
|
|
||||||
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
viewer,
|
viewer,
|
||||||
{ mapStackController = null, placeSearch, services, requestServices } = {},
|
{
|
||||||
|
mapStackController = null,
|
||||||
|
placeSearch,
|
||||||
|
services,
|
||||||
|
requestServices,
|
||||||
|
initialShareCode = null,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
const {
|
const {
|
||||||
@@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._showGlobalStatusNotice(message, options),
|
this._showGlobalStatusNotice(message, options),
|
||||||
feedback: this._feedback,
|
feedback: this._feedback,
|
||||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||||
|
applyFollowOffset: (entity, offset) =>
|
||||||
|
applyShareFollowOffset(viewer, entity, offset),
|
||||||
});
|
});
|
||||||
|
|
||||||
this._visualSettings = new VisualSettings({
|
this._visualSettings = new VisualSettings({
|
||||||
@@ -336,6 +348,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
_poiRow: this._poiRow,
|
_poiRow: this._poiRow,
|
||||||
_locationBarDivider: this._locationBarDivider,
|
_locationBarDivider: this._locationBarDivider,
|
||||||
_locationSearch: this._locationSearch,
|
_locationSearch: this._locationSearch,
|
||||||
|
_locationSearchError: this._locationSearchError,
|
||||||
_searchToggle: this._searchToggle,
|
_searchToggle: this._searchToggle,
|
||||||
_resetGlobeBtn: this._resetGlobeBtn,
|
_resetGlobeBtn: this._resetGlobeBtn,
|
||||||
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
|
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
|
||||||
@@ -430,7 +443,12 @@ export class StyleManager extends ShellFacade {
|
|||||||
isNavigationCurrent: (generation) =>
|
isNavigationCurrent: (generation) =>
|
||||||
generation === this._navigationGeneration,
|
generation === this._navigationGeneration,
|
||||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||||
|
// A host may open a share code without a `/share/<code>` address.
|
||||||
|
initialShareCode,
|
||||||
});
|
});
|
||||||
|
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||||
|
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
||||||
|
);
|
||||||
this.shareLinkManager.setPanelStateProvider(() =>
|
this.shareLinkManager.setPanelStateProvider(() =>
|
||||||
this._buildSharePanelState(),
|
this._buildSharePanelState(),
|
||||||
);
|
);
|
||||||
@@ -1652,14 +1670,35 @@ export class StyleManager extends ShellFacade {
|
|||||||
// ── Share Button ─────────────────────────────
|
// ── Share Button ─────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires the share button click to copy the current share link to the clipboard.
|
* Wires the share button. By default it copies the current share link to the
|
||||||
|
* clipboard; a host may pass `services.shareLink(url)` to share it another way
|
||||||
|
* (a system share sheet, for example). It resolves to 'shared', 'copied',
|
||||||
|
* 'cancelled' (no toast) or false (failed).
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initShareButton() {
|
_initShareButton() {
|
||||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||||
const success = await this.shareLinkManager.copyLink();
|
const share = this.services.shareLink;
|
||||||
if (!this._disposed)
|
let result;
|
||||||
this._showToast(success ? t('Link copied!') : t('Copy failed'));
|
if (typeof share === 'function') {
|
||||||
|
// A host's hook may return a plain value or throw; both are answers.
|
||||||
|
result = await Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
const href = this.shareLinkManager.buildLink();
|
||||||
|
return href ? share(href) : false;
|
||||||
|
})
|
||||||
|
.catch(() => false);
|
||||||
|
} else {
|
||||||
|
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
|
||||||
|
}
|
||||||
|
if (this._disposed || result === 'cancelled') return;
|
||||||
|
this._showToast(
|
||||||
|
result === 'shared'
|
||||||
|
? t('Link shared')
|
||||||
|
: result === 'copied'
|
||||||
|
? t('Link copied!')
|
||||||
|
: t('Copy failed'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export async function _restoreContextSession({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clear All may have run while Contacts settled. It owns the selection
|
||||||
|
// now, so the snapshot must not come back behind it.
|
||||||
|
if (restoreState.cancelled) return;
|
||||||
await this._dataManager.restoreEnabledLayerIds(
|
await this._dataManager.restoreEnabledLayerIds(
|
||||||
restoreState.enabledLayerIds,
|
restoreState.enabledLayerIds,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
|||||||
assert.equal(voice.owner.activeStyle, 'snow');
|
assert.equal(voice.owner.activeStyle, 'snow');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||||
|
const { owner } = styleOwner(t, 'tactical');
|
||||||
|
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||||
|
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||||
|
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||||
|
owner._scopeBtn = { checked: true };
|
||||||
|
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||||
|
owner._scopeFeatherValue = {};
|
||||||
|
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||||
|
assert.equal(owner._scopeBtn.checked, false);
|
||||||
|
assert.equal(owner._scopeFeatherSlider.disabled, true);
|
||||||
|
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||||
|
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||||
|
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||||
|
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||||
|
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||||
|
assert.equal(owner._scopeBtn.checked, false, layout);
|
||||||
|
}
|
||||||
|
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||||
|
assert.equal(isScopeMaskEnabled(), true);
|
||||||
|
assert.equal(getScopeMaskFeather(), 1.75);
|
||||||
|
assert.equal(owner._scopeBtn.checked, true);
|
||||||
|
assert.equal(owner._scopeFeatherSlider.disabled, false);
|
||||||
|
assert.equal(owner._scopeFeatherValue.textContent, '175%');
|
||||||
|
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||||
|
assert.equal(getScopeMaskFeather(), 2);
|
||||||
|
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||||
|
});
|
||||||
|
|
||||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||||
const { owner, hud } = styleOwner(t, 'tactical');
|
const { owner, hud } = styleOwner(t, 'tactical');
|
||||||
owner.setStyle('noir');
|
owner.setStyle('noir');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||||
import { bindApplicationShortcuts } from './visualInput.js';
|
import { bindApplicationShortcuts } from './visualInput.js';
|
||||||
import { bindDisplayControls } from './displayControls.js';
|
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
|
||||||
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||||
|
|
||||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||||
@@ -92,6 +93,11 @@ export class DisplayBindings {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
syncScopeControls(
|
||||||
|
this._scopeBtn,
|
||||||
|
this._scopeFeatherSlider,
|
||||||
|
isScopeMaskEnabled(),
|
||||||
|
);
|
||||||
this._displayControls?.destroy();
|
this._displayControls?.destroy();
|
||||||
this._displayControls = bindDisplayControls({
|
this._displayControls = bindDisplayControls({
|
||||||
elements: {
|
elements: {
|
||||||
@@ -100,7 +106,7 @@ export class DisplayBindings {
|
|||||||
bloomSlider: this._bloomSlider,
|
bloomSlider: this._bloomSlider,
|
||||||
sharpenButton: this._sharpenBtn,
|
sharpenButton: this._sharpenBtn,
|
||||||
sharpenSlider: this._sharpenSlider,
|
sharpenSlider: this._sharpenSlider,
|
||||||
scopeButton: this._scopeBtn,
|
scopeToggle: this._scopeBtn,
|
||||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||||
hudLayout: this._hudLayoutSelect,
|
hudLayout: this._hudLayoutSelect,
|
||||||
hudButton: this._hudBtn,
|
hudButton: this._hudBtn,
|
||||||
@@ -134,17 +140,15 @@ export class DisplayBindings {
|
|||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||||
},
|
},
|
||||||
toggleScope: () => {
|
setScopeEnabled: (enabled) => {
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
const next = !isScopeMaskEnabled();
|
setScopeMaskEnabled(enabled);
|
||||||
setScopeMaskEnabled(next);
|
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||||
this._scopeBtn.classList.toggle('active', next);
|
|
||||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
|
||||||
this._syncShareState();
|
this._syncShareState();
|
||||||
},
|
},
|
||||||
setScopeFeather: (value) => {
|
setScopeFeather: (value) => {
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
const pct = Math.max(0, Math.min(100, value || 0));
|
const pct = clampScopeFeatherPct(value);
|
||||||
if (this._scopeFeatherValue)
|
if (this._scopeFeatherValue)
|
||||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||||
setScopeMaskFeather(pct / 100);
|
setScopeMaskFeather(pct / 100);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
|
|||||||
for (const [name, action] of [
|
for (const [name, action] of [
|
||||||
['bloomButton', 'toggleBloom'],
|
['bloomButton', 'toggleBloom'],
|
||||||
['sharpenButton', 'toggleSharpen'],
|
['sharpenButton', 'toggleSharpen'],
|
||||||
['scopeButton', 'toggleScope'],
|
|
||||||
['cleanViewButton', 'toggleCleanView'],
|
['cleanViewButton', 'toggleCleanView'],
|
||||||
['cleanViewExitButton', 'exitCleanView'],
|
['cleanViewExitButton', 'exitCleanView'],
|
||||||
['celestialButton', 'toggleCelestial'],
|
['celestialButton', 'toggleCelestial'],
|
||||||
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
|
|||||||
['scopeFeatherSlider', 'setScopeFeather'],
|
['scopeFeatherSlider', 'setScopeFeather'],
|
||||||
])
|
])
|
||||||
listen(elements[name], 'input', action, integer);
|
listen(elements[name], 'input', action, integer);
|
||||||
|
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
|
||||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||||
for (const [name, action] of [
|
for (const [name, action] of [
|
||||||
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||||
|
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||||
|
if (toggle) toggle.checked = enabled;
|
||||||
|
if (featherSlider) featherSlider.disabled = !enabled;
|
||||||
|
}
|
||||||
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
|
|||||||
assert.deepEqual(calls, ['b']);
|
assert.deepEqual(calls, ['b']);
|
||||||
two.destroy();
|
two.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||||
|
const scopeToggle = element();
|
||||||
|
scopeToggle.checked = true;
|
||||||
|
const scopeFeatherSlider = element('200');
|
||||||
|
const calls = [];
|
||||||
|
const controls = bindDisplayControls({
|
||||||
|
elements: { scopeToggle, scopeFeatherSlider },
|
||||||
|
actions: {
|
||||||
|
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||||
|
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scopeToggle.checked = false;
|
||||||
|
scopeToggle.dispatchEvent(new Event('change'));
|
||||||
|
scopeToggle.checked = true;
|
||||||
|
scopeToggle.dispatchEvent(new Event('change'));
|
||||||
|
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||||
|
controls.destroy();
|
||||||
|
scopeToggle.dispatchEvent(new Event('change'));
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['enabled', false],
|
||||||
|
['enabled', true],
|
||||||
|
['feather', 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
|||||||
if (!host || !frameEvent) return { destroy() {} };
|
if (!host || !frameEvent) return { destroy() {} };
|
||||||
|
|
||||||
const readout = documentRef.createElement('div');
|
const readout = documentRef.createElement('div');
|
||||||
readout.className = 'frame-rate-readout';
|
readout.className = 'frame-rate-readout scene-text-holdout';
|
||||||
readout.hidden = true;
|
readout.hidden = true;
|
||||||
// i18n-ignore: FPS is a unit code, the same in every language
|
// i18n-ignore: FPS is a unit code, the same in every language
|
||||||
readout.textContent = 'FPS —';
|
readout.textContent = 'FPS —';
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
|||||||
'recent-imagery': msg('Recent Imagery'),
|
'recent-imagery': msg('Recent Imagery'),
|
||||||
'fire-perimeters': msg('Fire Perimeters'),
|
'fire-perimeters': msg('Fire Perimeters'),
|
||||||
'gnss-integrity': msg('GNSS Integrity'),
|
'gnss-integrity': msg('GNSS Integrity'),
|
||||||
|
's2-ghostmaps': msg('S2 GhostMaps'),
|
||||||
wind: msg('Wind'),
|
wind: msg('Wind'),
|
||||||
'weather-radar': msg('Rain radar'),
|
'weather-radar': msg('Rain radar'),
|
||||||
'weather-satellite': msg('Satellite clouds'),
|
'weather-satellite': msg('Satellite clouds'),
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
|
|||||||
'local-firms',
|
'local-firms',
|
||||||
'fire-perimeters',
|
'fire-perimeters',
|
||||||
'gnss-integrity',
|
'gnss-integrity',
|
||||||
|
's2-ghostmaps',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class LocationControls {
|
|||||||
onCity,
|
onCity,
|
||||||
onPoi,
|
onPoi,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onSearchEdit,
|
||||||
onReset,
|
onReset,
|
||||||
doc = document,
|
doc = document,
|
||||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||||
@@ -74,6 +75,11 @@ export class LocationControls {
|
|||||||
this.bind(elements.search, 'keydown', (event) => {
|
this.bind(elements.search, 'keydown', (event) => {
|
||||||
if (event.key === 'Enter') void onSearch(elements.search.value);
|
if (event.key === 'Enter') void onSearch(elements.search.value);
|
||||||
});
|
});
|
||||||
|
// Typing corrects a failed query; closing the field (which empties it) drops its error.
|
||||||
|
this.bind(elements.search, 'input', () => onSearchEdit?.());
|
||||||
|
this.bind(elements.search, 'blur', () => {
|
||||||
|
if (!elements.search.value) onSearchEdit?.();
|
||||||
|
});
|
||||||
for (const button of elements.resetButtons)
|
for (const button of elements.resetButtons)
|
||||||
this.bind(button, 'click', onReset);
|
this.bind(button, 'click', onReset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function fixture() {
|
|||||||
onCity: (id) => calls.push(['city', id]),
|
onCity: (id) => calls.push(['city', id]),
|
||||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||||
onSearch: (query) => calls.push(['search', query]),
|
onSearch: (query) => calls.push(['search', query]),
|
||||||
|
onSearchEdit: () => calls.push(['edit']),
|
||||||
onReset: () => calls.push(['reset']),
|
onReset: () => calls.push(['reset']),
|
||||||
doc,
|
doc,
|
||||||
requestFrame: (fn) => {
|
requestFrame: (fn) => {
|
||||||
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
|
|||||||
f.elements.resetButtons[1].fire('click');
|
f.elements.resetButtons[1].fire('click');
|
||||||
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
||||||
});
|
});
|
||||||
|
test('typing clears a failed search error and closing the emptied field drops it too', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.elements.search.value = 'Pariss';
|
||||||
|
f.elements.search.fire('blur');
|
||||||
|
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
|
||||||
|
f.elements.search.fire('input');
|
||||||
|
f.elements.search.value = '';
|
||||||
|
f.elements.search.fire('blur');
|
||||||
|
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
||||||
|
});
|
||||||
@@ -80,19 +80,27 @@ export class LocationNavigation {
|
|||||||
|
|
||||||
_handleLocationSearchState(state, change) {
|
_handleLocationSearchState(state, change) {
|
||||||
if (this._disposed || !change) return;
|
if (this._disposed || !change) return;
|
||||||
if (change.type === 'started')
|
if (change.type === 'started') {
|
||||||
this._activeLocationSearchGeneration = change.generation;
|
this._activeLocationSearchGeneration = change.generation;
|
||||||
else if (change.type === 'found') {
|
this._setLocationSearchError(null);
|
||||||
|
} else if (change.type === 'found') {
|
||||||
this._searchedLocationLabel = state.destination.label || state.query;
|
this._searchedLocationLabel = state.destination.label || state.query;
|
||||||
this._setActiveLocation(null);
|
this._setActiveLocation(null);
|
||||||
this._currentPoi = null;
|
this._currentPoi = null;
|
||||||
this._collapsePOIRow();
|
this._collapsePOIRow();
|
||||||
this._updateLocationMiniStatus();
|
this._updateLocationMiniStatus();
|
||||||
} else if (change.type === 'missing')
|
} else if (change.type === 'missing')
|
||||||
this._showToast(t('Location not found'));
|
this._setLocationSearchError(
|
||||||
else if (change.type === 'failed') this._showToast(t('Search failed'));
|
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
|
||||||
|
);
|
||||||
|
else if (change.type === 'failed')
|
||||||
|
this._setLocationSearchError(
|
||||||
|
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
|
||||||
|
);
|
||||||
else if (change.type === 'settled')
|
else if (change.type === 'settled')
|
||||||
this._settleLocationSearchUi(change.generation);
|
this._settleLocationSearchUi(change.generation, {
|
||||||
|
keepQuery: state.status === 'missing' || state.status === 'failed',
|
||||||
|
});
|
||||||
else if (
|
else if (
|
||||||
change.type === 'reset' &&
|
change.type === 'reset' &&
|
||||||
this._activeLocationSearchGeneration !== null
|
this._activeLocationSearchGeneration !== null
|
||||||
@@ -101,6 +109,21 @@ export class LocationNavigation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show, or with null clear, the announced error under the search field. */
|
||||||
|
_setLocationSearchError(message) {
|
||||||
|
const input = this._locationSearch;
|
||||||
|
const error = this._locationSearchError;
|
||||||
|
if (error) error.textContent = message || '';
|
||||||
|
if (!input) return;
|
||||||
|
if (message) {
|
||||||
|
input.setAttribute('aria-invalid', 'true');
|
||||||
|
if (error?.id) input.setAttribute('aria-describedby', error.id);
|
||||||
|
} else {
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
input.removeAttribute('aria-describedby');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_initLocationBar() {
|
_initLocationBar() {
|
||||||
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||||
this._locationControls?.destroy();
|
this._locationControls?.destroy();
|
||||||
@@ -144,6 +167,7 @@ export class LocationNavigation {
|
|||||||
onCity: (id) => this._onCityPillClick(id),
|
onCity: (id) => this._onCityPillClick(id),
|
||||||
onPoi: (id, index) => this._onPoiClick(id, index),
|
onPoi: (id, index) => this._onPoiClick(id, index),
|
||||||
onSearch: (query) => this._locationLookup.run(query),
|
onSearch: (query) => this._locationLookup.run(query),
|
||||||
|
onSearchEdit: () => this._setLocationSearchError(null),
|
||||||
onReset: () => this.resetToGlobeView(),
|
onReset: () => this.resetToGlobeView(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { NavigationController } from './navigationController.js';
|
||||||
|
import { LocationNavigation } from './locationNavigation.js';
|
||||||
|
|
||||||
|
function input() {
|
||||||
|
const classes = new Set(['expanded', 'searching']);
|
||||||
|
const attributes = new Map();
|
||||||
|
return {
|
||||||
|
id: 'location-search',
|
||||||
|
value: 'Pariss',
|
||||||
|
focused: false,
|
||||||
|
selected: false,
|
||||||
|
classList: {
|
||||||
|
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||||
|
contains: (name) => classes.has(name),
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
this.focused = true;
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
this.focused = false;
|
||||||
|
},
|
||||||
|
select() {
|
||||||
|
this.selected = true;
|
||||||
|
},
|
||||||
|
setAttribute: (name, value) => attributes.set(name, value),
|
||||||
|
removeAttribute: (name) => attributes.delete(name),
|
||||||
|
getAttribute: (name) => attributes.get(name) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle(searchInput, options) {
|
||||||
|
const owner = { _activeLocationSearchGeneration: 4, searchInput };
|
||||||
|
NavigationController.prototype._settleLocationSearchUi.call(
|
||||||
|
owner,
|
||||||
|
4,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a search that found nothing keeps its query, the open field and focus', () => {
|
||||||
|
const field = input();
|
||||||
|
const owner = settle(field, { keepQuery: true });
|
||||||
|
assert.equal(owner._activeLocationSearchGeneration, null);
|
||||||
|
assert.equal(field.value, 'Pariss');
|
||||||
|
assert.equal(field.classList.contains('expanded'), true);
|
||||||
|
assert.equal(field.classList.contains('searching'), false);
|
||||||
|
assert.equal(field.focused, true);
|
||||||
|
assert.equal(field.selected, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a search that arrived still clears and closes the field', () => {
|
||||||
|
const field = input();
|
||||||
|
field.focused = true;
|
||||||
|
settle(field);
|
||||||
|
assert.equal(field.value, '');
|
||||||
|
assert.equal(field.classList.contains('expanded'), false);
|
||||||
|
assert.equal(field.focused, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigation() {
|
||||||
|
const field = input();
|
||||||
|
const error = { id: 'location-search-error', textContent: '' };
|
||||||
|
const settled = [];
|
||||||
|
const toasts = [];
|
||||||
|
const owner = {
|
||||||
|
_disposed: false,
|
||||||
|
_locationSearch: field,
|
||||||
|
_locationSearchError: error,
|
||||||
|
_activeLocationSearchGeneration: null,
|
||||||
|
_settleLocationSearchUi: (generation, options) =>
|
||||||
|
settled.push([generation, options]),
|
||||||
|
_showToast: (message) => toasts.push(message),
|
||||||
|
_setLocationSearchError:
|
||||||
|
LocationNavigation.prototype._setLocationSearchError,
|
||||||
|
};
|
||||||
|
const handle = (status, type) =>
|
||||||
|
LocationNavigation.prototype._handleLocationSearchState.call(
|
||||||
|
owner,
|
||||||
|
{ status, query: field.value },
|
||||||
|
{ type, generation: 7 },
|
||||||
|
);
|
||||||
|
return { owner, field, error, settled, toasts, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [status, text] of [
|
||||||
|
['missing', 'Location not found'],
|
||||||
|
['failed', 'Search failed'],
|
||||||
|
]) {
|
||||||
|
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
|
||||||
|
const n = navigation();
|
||||||
|
n.handle('searching', 'started');
|
||||||
|
n.handle(status, status);
|
||||||
|
n.handle(status, 'settled');
|
||||||
|
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
|
||||||
|
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
|
||||||
|
assert.equal(
|
||||||
|
n.field.getAttribute('aria-describedby'),
|
||||||
|
'location-search-error',
|
||||||
|
);
|
||||||
|
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
|
||||||
|
assert.deepEqual(n.toasts, []);
|
||||||
|
n.handle('searching', 'started');
|
||||||
|
assert.equal(n.error.textContent, '');
|
||||||
|
assert.equal(n.field.getAttribute('aria-invalid'), null);
|
||||||
|
assert.equal(n.field.getAttribute('aria-describedby'), null);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -119,10 +119,17 @@ export class NavigationController {
|
|||||||
return this._navigationGeneration;
|
return this._navigationGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
_settleLocationSearchUi(generation) {
|
/** A search that found nothing keeps its query and focus so it can be corrected. */
|
||||||
|
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
|
||||||
if (this._activeLocationSearchGeneration !== generation) return;
|
if (this._activeLocationSearchGeneration !== generation) return;
|
||||||
this._activeLocationSearchGeneration = null;
|
this._activeLocationSearchGeneration = null;
|
||||||
this.searchInput?.classList.remove('searching', 'expanded');
|
this.searchInput?.classList.remove('searching');
|
||||||
|
if (keepQuery) {
|
||||||
|
this.searchInput?.focus();
|
||||||
|
this.searchInput?.select?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searchInput?.classList.remove('expanded');
|
||||||
if (this.searchInput) this.searchInput.value = '';
|
if (this.searchInput) this.searchInput.value = '';
|
||||||
this.searchInput?.blur();
|
this.searchInput?.blur();
|
||||||
}
|
}
|
||||||
@@ -241,7 +248,7 @@ export class NavigationController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_reassertNavigationHandoff(generation) {
|
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
|
||||||
return reassertNavigationHandoff({
|
return reassertNavigationHandoff({
|
||||||
generation,
|
generation,
|
||||||
currentGeneration: this._navigationGeneration,
|
currentGeneration: this._navigationGeneration,
|
||||||
@@ -253,7 +260,7 @@ export class NavigationController {
|
|||||||
// was refused by the cockpit leaves the old readout standing.
|
// was refused by the cockpit leaves the old readout standing.
|
||||||
release: () => {
|
release: () => {
|
||||||
this.clearLocation();
|
this.clearLocation();
|
||||||
return this._releaseFollowCamera();
|
return this._releaseFollowCamera({ trackingOrigin });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user