diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2c2de9e..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -153,6 +153,33 @@ ownership and adoption process. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers diff --git a/Dockerfile b/Dockerfile index d34f4887a..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/README.md b/README.md index 80f052571..0a7509efa 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went @@ -207,6 +209,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). diff --git a/SECURITY.md b/SECURITY.md index 1baad029f..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. diff --git a/build/vite.js b/build/vite.js index a02f2340f..960603814 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,29 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -165,7 +185,19 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT_GLOB}/dist/**`, + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md new file mode 100644 index 000000000..bc6b62402 --- /dev/null +++ b/changelog.d/1222-pinokio-open-in-browser.md @@ -0,0 +1,3 @@ +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). diff --git a/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..4f3da2a97 --- /dev/null +++ b/changelog.d/1232-gbfs-invalid-coordinates.md @@ -0,0 +1,3 @@ +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md new file mode 100644 index 000000000..48b803534 --- /dev/null +++ b/changelog.d/1272-search-issue-fix.md @@ -0,0 +1 @@ +- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md new file mode 100644 index 000000000..4f6817e9c --- /dev/null +++ b/changelog.d/1343-route-ramp-lane-wording.md @@ -0,0 +1,4 @@ +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md new file mode 100644 index 000000000..af7fb4da3 --- /dev/null +++ b/changelog.d/1390-awareness-bearing-north.md @@ -0,0 +1,2 @@ +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md new file mode 100644 index 000000000..186147a2e --- /dev/null +++ b/changelog.d/1391-docker-selinux.md @@ -0,0 +1,4 @@ +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md new file mode 100644 index 000000000..75ead9667 --- /dev/null +++ b/changelog.d/1394-docker-healthcheck.md @@ -0,0 +1,2 @@ +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md new file mode 100644 index 000000000..17d74e481 --- /dev/null +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -0,0 +1,6 @@ +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md new file mode 100644 index 000000000..9235a19a3 --- /dev/null +++ b/changelog.d/1396-local-adsb-track-north.md @@ -0,0 +1,3 @@ +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md new file mode 100644 index 000000000..ab05aa6f2 --- /dev/null +++ b/changelog.d/1398-sharpen-zero-off.md @@ -0,0 +1,2 @@ +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md new file mode 100644 index 000000000..73196986a --- /dev/null +++ b/changelog.d/1399-ground-snap-clock-step.md @@ -0,0 +1,3 @@ +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md new file mode 100644 index 000000000..da95c7dcd --- /dev/null +++ b/changelog.d/1403-remove-unused-modules.md @@ -0,0 +1,5 @@ +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md new file mode 100644 index 000000000..38cfdf48e --- /dev/null +++ b/changelog.d/1410-scope-controls-hud-readability.md @@ -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. diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/compose.yaml b/compose.yaml index c2d3bf3d4..38cf438ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,5 +22,8 @@ services: # UI land in the .env on your host instead of vanishing with the # container. node_modules stays in the image: sharp and friends are # compiled for Linux and must not be shadowed by a host install. - - .:/app + # `:z` relabels the checkout for containers on SELinux hosts (Fedora, + # RHEL); without it the container cannot read /app at all and Node + # reports the launcher as "Cannot find module". Elsewhere it is ignored. + - .:/app:z - /app/node_modules diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9085a7b72..9f8e0f629 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -81,8 +81,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | -| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | -| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` | | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df71ef895..415218b26 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +Docker Compose mounts the checkout with the shared SELinux `:z` label so +containers can read it on enforcing SELinux hosts. The separate image-owned +`node_modules` volume and loopback-only port publishing remain unchanged. + +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft @@ -186,6 +196,23 @@ Regression coverage lives in `src/tools/area.test.mjs`, `src/tools/queries/environment.test.mjs` and `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 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -296,6 +323,10 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared @@ -1306,6 +1337,8 @@ Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. Snow's sideways sway stays within a fixed range at any session age; Wind scales its amplitude. +Sharpen runs only when enabled with intensity above 0.001. At slider zero its +stage is disabled; raising the slider resumes it when sharpen is enabled. ## Display control ownership @@ -1536,7 +1569,11 @@ camera is refused there with a JSON 404 and is served solely through ## GBFS upstream bounds Station information is normalized in `src/sources/gbfsStations.js` for both the -globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized +globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or +out-of-range coordinates (latitude outside ±90, longitude outside ±180) are +skipped. Numbers and nonblank numeric strings are accepted, including genuine +zero and the `latitude`/`longitude` aliases; other JSON types are rejected. +Names accept legacy strings or GBFS v3 Localized String arrays regardless of the reported feed version. Nonempty English text (including regional `en-*` tags, case-insensitive) takes precedence, then the first usable translation. An unusable `name` falls back to `short_name` with the same @@ -1838,6 +1875,12 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. +In standalone development and preview servers, a missing Google tile-token +provider returns an uncached `204 No Content` for `GET /api/google/tiles-token` +(and HEAD) instead of a browser-visible 404. The fallback is registered after +real token providers and before the unknown-API handler, so existing Google +browser keys, provider-issued tokens, and unrelated API routes remain unchanged. + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's @@ -1989,6 +2032,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, +including zero-padded hours and minutes; missing or invalid dates and times +remain `null`, including impossible calendar dates from a custom source. The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. @@ -4152,6 +4198,10 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. +Directions ramp and exit text uses “on the left/right” only for a bare side; +slight and sharp modifiers read as “slightly/sharply left/right”. Lane instructions +name the left or right lane. The localized page and proxy English stay in sync. + Route duration labels select their unit after rounding seconds, switching to minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`. @@ -4654,7 +4704,7 @@ current route throughout enabling, enabled, disabling, and uncertain lifecycle renders. Both routes preserve Radio power, playback, station, filter, and volume state. -Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojson.js` (Vite `?url` assets) when toggled on. +Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on. A bundled dataset that fails to load is a broken install, not a blip, so it is never swallowed: `localGeojson.js` guards `response.ok`, reports `error` + @@ -4686,6 +4736,7 @@ silently demoting every later lookup for the session. - Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards. - Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout. - While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact. +- Awareness `BRG` and `CRS` labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `000°` rather than `360°`. - Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct. - With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip. - The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence. @@ -5188,9 +5239,14 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +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. +For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds +before computing their midpoint, then wraps that midpoint back into ±180 degrees. +Malformed viewport bounds add no proximity bias. Nominatim omits a crossing +request viewbox and continues searching without that geographic preference. + ### Map Stack Switcher (June 2026) - `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. @@ -5300,6 +5356,11 @@ pins and share links already supply complete boxes. - **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. +- **Ground-snap clock rollback recovery.** If the wall clock moves before the + sampling-window start, ground snap opens a new window while retaining the + normal four-samples-per-250-ms budget. Retry timestamps more than the maximum + 30-second backoff ahead of the current clock are ignored. A smaller rollback + can retain a bounded retry delay of up to 30 seconds. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. @@ -5356,11 +5417,21 @@ pins and share links already supply complete boxes. ## Auth + Launch +Vite's development filesystem deny list excludes local `.gev-logs/` and +`.gev-cache/` contents and the package checkout's root `dist/`, while allowing +dependency `dist/` assets. Checkout glob characters are escaped. This does not +apply to `public/` assets or built-preview serving. + + - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: an absent marker exposes Install, a present marker exposes Start, and a - running server with a captured ready URL exposes Open God's Eye View. + running server with a captured ready URL exposes Open God's Eye View. That + default item uses Pinokio's `popout` option to open the system browser; the + Server item retains the launcher console. The app's framing headers stay + unchanged. This does not assume all Pinokio desktop versions enforce those + headers in their embedded view. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) @@ -5496,10 +5567,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather, 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 -`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`, -and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so +`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather +`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 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 @@ -5569,14 +5641,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 default, and the mapping follows bracket brightness rather than slider 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, - `35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`, - mirrored by `#scope-feather-slider`'s markup value AND readout and - `_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans - 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. +- **Scope: `ON`, feather: `100%`**. `SCOPE_FEATHER_RATIO_DEFAULT` in + `src/scopeMask.js` matches the slider, readout and share generator. The range + is 0–200%; explicit 0% is a hard edge. Missing-`scf` links retain the historical + 35% parse fallback. - **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section 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). @@ -5999,6 +6067,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when known, altitude, ground speed, track, vertical rate, position age, message count, "Heard by your receiver"); markers are not camera-followed. Positioned aircraft join detection boxes, labelled only with a decoded callsign. +The card's `TRK` label wraps after rounding to whole degrees, so a track of +359.5° displays as `TRK 0°` rather than `TRK 360°`. Receivers publish one record shape from `src/sources/adsbRecords.js` (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, @@ -6207,3 +6277,12 @@ when their footprints touch or contain one another, never across a seam toleranc - `scripts/qa-form-fields.mjs` exercises native selections, number validity, calibration commit/cancel and stale-camera/disposal guards against an installed controller with fixture ports, plus default/Cyber and narrow translated layouts. + +## Docker health reporting + +The Docker image probes its local HTTP root with Node every 30 seconds, using +the launcher's parsed `PORT` (default 4173). A successful HTTP response marks +the container healthy. Checks time out after 10 seconds, with a 30-second +startup grace period and three consecutive failures before reporting unhealthy. +This reports app HTTP availability, not individual provider health, and does not +configure automatic restarts. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 76567e605..31d3cb4cb 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -53,9 +53,8 @@ Cesium installation shared with its viewer; it must not bundle separate copies. `gods-eye-view/infrastructure/geojson` exports the lower-level `createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. -The standalone app's `src/data/localGeojson.js` keeps its original single-argument -factory and supplies its existing functions. Other consumers should use the package -exports, which do not import standalone application globals. +Consumers use the package exports, which do not import standalone application +globals. Dataset files and source/license notices remain under `src/data/local_data/`. Their input counts are 4,351 datacenter features and 704 dam features. Cesium may diff --git a/pinokio/pinokio.js b/pinokio/pinokio.js index d781c1a58..49c42a361 100644 --- a/pinokio/pinokio.js +++ b/pinokio/pinokio.js @@ -22,8 +22,10 @@ module.exports = { if (starting) { const local = info.local('start.js'); if (local?.url) { + // Open in the system browser without changing the app's framing policy. + // This also avoids relying on Pinokio's embedded browser behavior. return [ - { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url }, + { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url, popout: true }, { icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' }, ]; } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 3f4fec617..b6d8dba67 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -153,14 +153,6 @@ export function checkImportDirections(root) { file, 'Actions must consume feed state without the manager facade', ); - if ( - file === 'src/app/constructCatalog.js' && - to === 'src/data/localGeojson.js' - ) - report( - file, - 'Catalog must use the services owner without compatibility layer construction', - ); } return record; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f88be2b6b..e60413624 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -194,6 +194,7 @@ "src/ui/visualInput.js", "src/ui/visualInput.test.mjs", "scripts/qa-visual-input.mjs", + "scripts/qa-scope-hud.mjs", "scripts/qa-segmented-controls.mjs", "src/ui/displayControls.js", "src/ui/segmentedControl.test.mjs", @@ -518,7 +519,6 @@ "src/layers/bikeshare/selection.js", "src/data/traffic.js", "src/data/bikeshare.js", - "src/data/flowTiles.js", "src/layers/traffic/source.test.mjs", "src/layers/bikeshare/source.test.mjs", "src/layers/bikeshare/proxyRoute.test.mjs", @@ -651,7 +651,6 @@ "src/agent/worldPacket.test.mjs", "src/voice/layerSummary.js", "build/application-html.js", - "src/app/sourceSlot.js", "src/app/sources.js", "src/ui/composition.js", "src/services/application.js", @@ -1089,5 +1088,6 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", - "src/voice/globeResetConfirmation.test.mjs" + "src/voice/globeResetConfirmation.test.mjs", + "src/hudDms.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 68a57066a..d15897175 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -805,7 +805,8 @@ "src/ui/keyGuidance.js", "src/ui/layerNames.js", "src/ui/statusText.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/scopeMaskPolicy.js" ], "external": ["cesium"] }, @@ -1163,7 +1164,6 @@ "satellites-layer": { "exports": ["./layers/satellites"], "modules": [ - "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", "src/sources/tle.js", @@ -1287,6 +1287,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -1852,14 +1853,12 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -1944,6 +1943,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -2575,7 +2575,8 @@ "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", "src/ui/statusBadge.js", - "src/sceneCommandRelease.js" + "src/sceneCommandRelease.js", + "src/scopeMaskPolicy.js" ], "external": [ "hls.js", @@ -2670,13 +2671,11 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -2736,6 +2735,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -3297,7 +3297,9 @@ "src/i18n/locale.js", "src/i18n/locales/es.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"] }, @@ -3660,7 +3662,6 @@ "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", - "src/app/sourceSlot.js", "src/celestialRing.js", "src/data/approxDistance.js", "src/data/cameraSensitivity.js", diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs new file mode 100644 index 000000000..7fece45ee --- /dev/null +++ b/scripts/qa-scope-hud.mjs @@ -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; diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffe5124ce..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js deleted file mode 100644 index fe7590e3b..000000000 --- a/src/app/sourceSlot.js +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility entry; source contracts are owned by sources/. -export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/app/sourceSlot.test.mjs b/src/app/sourceSlot.test.mjs deleted file mode 100644 index 1b1face0c..000000000 --- a/src/app/sourceSlot.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createSourceSlot } from './sourceSlot.js'; - -test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => { - let finish; - const slot = createSourceSlot({ read: async () => 'default' }, ['read']); - const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) }); - const pending = slot.source.read(); - const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } }); - old(); - finish('stale'); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(await slot.source.read(), 'replacement'); - assert.equal(slot.source.attribution.name, 'Replacement'); - stop(); - assert.throws(() => slot.source.read(), /not configured/); -}); - -test('an invalid replacement leaves the working source intact', async () => { - const slot = createSourceSlot({ read: async () => 42 }, ['read']); - assert.throws(() => slot.configure({}), TypeError); - assert.equal(await slot.source.read(), 42); -}); diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index b0c7503ac..bf6e2878e 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -876,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', ( const list = directionsStepList({ route }); assert.equal(list.items[1].lead, '240\u00a0м'); }); + +test('ramp and lane wording stays localized after choosing the maneuver form', (t) => { + t.after(() => setLocale('en')); + const cases = [ + [ + 'ru', + 'Въезжайте по съезду плавно налево на I-95', + 'Сверните на съезд резко направо', + 'Займите левую полосу', + ], + [ + 'es', + 'Toma la rampa ligeramente a la izquierda hacia I-95', + 'Toma la salida bruscamente a la derecha', + 'Toma el carril izquierdo', + ], + [ + 'zh-CN', + '向左前方驶入匝道,进入 I-95', + '从出口向右后方驶出', + '使用左转车道', + ], + ]; + for (const [locale, ramp, exit, lane] of cases) { + setLocale(locale); + assert.equal( + directionsInstruction({ + type: 'on ramp', + modifier: 'slight left', + name: 'I-95', + }), + ramp, + ); + assert.equal( + directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }), + exit, + ); + assert.equal( + directionsInstruction({ type: 'use lane', modifier: 'sharp left' }), + lane, + ); + } +}); diff --git a/src/data/flowTiles.js b/src/data/flowTiles.js deleted file mode 100644 index 07efb0d9a..000000000 --- a/src/data/flowTiles.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createFlowTileSource } from '../layers/traffic/flowSource.js'; -export { tilesForBounds } from './tomtomTiles.js'; -export { decodeFlowTile } from '../layers/traffic/flowDecode.js'; -const source = createFlowTileSource(); -export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } = - source; diff --git a/src/data/flowTiles.test.mjs b/src/data/flowTiles.test.mjs deleted file mode 100644 index 61a7ddb78..000000000 --- a/src/data/flowTiles.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -// src/data/flowTiles.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - decodeFlowTile, - fetchFlowForBounds, - tilesForBounds, - getFlowSessionStats, - resetFlowTileCache, -} from './flowTiles.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16). -const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf'); -const FIXTURE_TILE = { z: 12, x: 935, y: 1686 }; - -function loadFixture() { - return fs.readFileSync(FIXTURE); -} - -// ── decodeFlowTile against the real fixture ───────────────── - -test('fixture decode: more than 50 flow segments', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.length > 50, `got ${segments.length}`); -}); - -test('fixture decode: every trafficLevel is within [0, 1]', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`); - assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`); - } -}); - -test('fixture decode: all coordinates land in downtown Austin', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short'); - for (const [lon, lat] of s.coords) { - assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`); - assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`); - } - } -}); - -test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found'); -}); - -test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.equal(typeof s.roadType, 'string'); - assert.equal(typeof s.closure, 'boolean'); - } - // The fixture carries real closures — closure decoding is exercised, not vacuous. - assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture'); -}); - -test('decode of a non-MVT buffer returns [] (defensive)', () => { - assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []); -}); - -// ── tilesForBounds (re-exported slippy math) ──────────────── - -test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => { - const tiles = tilesForBounds({ - south: 30.2672 - 0.001, north: 30.2672 + 0.001, - west: -97.7431 - 0.001, east: -97.7431 + 0.001, - }, 12); - assert.ok( - tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686), - `fixture tile missing: ${JSON.stringify(tiles)}` - ); -}); - -// ── fetchFlowForBounds (stubbed fetch: cache + abort) ─────── - -/** Bounds fully inside the fixture tile. */ -const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 }; - -function stubFetch(impl) { - const original = globalThis.fetch; - globalThis.fetch = impl; - return () => { globalThis.fetch = original; }; -} - -test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => { - resetFlowTileCache(); - const calls = []; - const restore = stubFetch(async (url) => { - calls.push(String(url)); - return new Response(loadFixture(), { - status: 200, - headers: { 'Content-Type': 'application/x-protobuf' }, - }); - }); - try { - const segments = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls.length, 1); - assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/); - assert.ok(segments.length > 50); - assert.ok(getFlowSessionStats().tilesFetched >= 1); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => { - resetFlowTileCache(); - let calls = 0; - const restore = stubFetch(async () => { - calls += 1; - return new Response(loadFixture(), { status: 200 }); - }); - try { - const first = await fetchFlowForBounds(FIXTURE_BOUNDS); - const second = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls, 1, 'second call must be served from the decode cache'); - assert.equal(second.length, first.length); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => { - resetFlowTileCache(); - const restore = stubFetch(async (url, opts) => { - // Mimic real fetch abort semantics. - if (opts?.signal?.aborted) { - const err = new Error('aborted'); - err.name = 'AbortError'; - throw err; - } - return new Response(loadFixture(), { status: 200 }); - }); - try { - const controller = new AbortController(); - controller.abort(); - await assert.rejects( - fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }), - (err) => err.name === 'AbortError' - ); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => { - resetFlowTileCache(); - const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 })); - try { - await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS)); - } finally { - restore(); - } -}); diff --git a/src/data/issPass.js b/src/data/issPass.js deleted file mode 100644 index c3807d59f..000000000 --- a/src/data/issPass.js +++ /dev/null @@ -1,46 +0,0 @@ -// src/data/issPass.js -/** - * Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the - * original findNextIssPass signature and defaults still work. - */ -import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; - -export { lookAnglesAt }; - -/** - * Predict the next ISS pass over an observer location. - * @param {Object} options - * @param {Object} options.satrec SGP4 satellite record - * @param {number} options.latDeg Observer latitude in degrees [-90, 90] - * @param {number} options.lonDeg Observer longitude in degrees [-180, 180] - * @param {number} options.fromMs UTC start time in milliseconds - * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees - * @param {number} [options.horizonHours=24] Maximum search window in hours - * @param {number} [options.coarseStepSec=30] Coarse search step in seconds - * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility - * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass - * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null} - */ -export function findNextIssPass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg = 10, - horizonHours = 24, - coarseStepSec = 30, - fineStepSec = 5, - requireVisible = false, -}) { - return findNextSatellitePass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg, - horizonHours, - coarseStepSec, - fineStepSec, - requireVisible, - }); -} diff --git a/src/data/issPass.test.mjs b/src/data/issPass.test.mjs deleted file mode 100644 index aaaa87a55..000000000 --- a/src/data/issPass.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// src/data/issPass.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { twoline2satrec } from 'satellite.js'; -import { findNextIssPass, lookAnglesAt } from './issPass.js'; -import { findNextSatellitePass } from './satellitePass.js'; - -// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC). -const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; -const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; -const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 }; -const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch - -test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 }); - assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit'); - assert.ok(pass.riseMs > FROM_MS); - assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs); - assert.ok(pass.maxElevDeg >= 10); - assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360); - - // Independent re-check: elevation at the reported peak really is the max-ish - // and ≥ threshold; one minute before rise it is below threshold. - const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5); - const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(before && before.elevDeg < 10); -}); - -test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => { - const satrec = twoline2satrec(L1, L2); - const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 }; - assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 })); - assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 })); -}); - -test('returns null when no pass clears an absurd threshold', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 }); - assert.equal(pass, null); -}); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index 061a7f27c..33d35a039 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -2,7 +2,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { + createLocalGeoJsonLayer as createLayer, + mapAnalystRecord, +} from './localGeojsonCore.js'; + +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); const DC_RAW = { id: '1176042553', diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js deleted file mode 100644 index 61f6c3abf..000000000 --- a/src/data/localGeojson.js +++ /dev/null @@ -1,29 +0,0 @@ -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { - createLocalGeoJsonLayer as createLayer, - createLocalInfrastructureOverlayPublisher as createPublisher, -} from './localGeojsonCore.js'; - -export * from './localGeojsonCore.js'; - -/** Create a layer using the standalone app's operations; retain overlay overrides. */ -export function createLocalGeoJsonLayer(options) { - return createLayer(options, { - ...localGeoJsonServices, - overlayHost: - options.overlayHost === undefined - ? localGeoJsonServices.overlayHost - : options.overlayHost, - }); -} - -/** Create an overlay publisher using the standalone app's host by default. */ -export function createLocalInfrastructureOverlayPublisher(options) { - return createPublisher({ - ...options, - host: - options.host === undefined - ? localGeoJsonServices.overlayHost - : options.host, - }); -} diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c4499914..13cde65a5 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -6,13 +6,14 @@ import { GROUND_SAMPLE_MAX_ARMED_RETRIES, LOCAL_OVERLAY_COHORT_LIMIT, LOCAL_STEM_TIP_EPSILON_M, - createLocalGeoJsonLayer, + createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayPublisher, localDatasetError, localInfrastructureOverlayCopy, selectLocalInfrastructureOverlayCohort, -} from './localGeojson.js'; +} from './localGeojsonCore.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { layerFeedState } from './manager.js'; import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; import { @@ -21,6 +22,13 @@ import { _resetRenderGovernorForTest, } from '../renderGovernor.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, + }); + class MockLayerEvent { constructor() { this.listeners = new Set(); @@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); }); - -test('standalone publisher retains its default host for an undefined override', () => { - const publisher = createLocalInfrastructureOverlayPublisher({ - sourceId: 'local-default-host-test', host: undefined, - }); - assert.doesNotThrow(() => { - publisher.show(); - publisher.hide(); - publisher.destroy(); - }); -}); diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs index 245867155..8b5621ec4 100644 --- a/src/data/localGeojsonLifecycle.test.mjs +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -1,9 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js'; import { getContextStore } from './contextStore.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); + const dataset = JSON.stringify({ type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index cfd082c04..4a2dd92da 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -5,7 +5,7 @@ * Pure selection policy for the bundled local-infrastructure layers * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` * dataset). Cesium-specific projection, occlusion, and stem geometry stay in - * `localGeojson.js`; this module turns the resulting in-view records into a + * `localGeojsonCore.js`; this module turns the resulting in-view records into a * bounded "active" set — the only records that layer should spend per-frame * stem-geometry and ground-sample work on. * @@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250; /** * Distance at or beyond which the proximity penalty is fully applied (m). - * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js. + * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js. */ export const INFRA_LOD_FAR_M = 12_000_000; @@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; * (callers only compare it), but every input is normalized so the output is * always a finite number. * - * @param {number} priority Existing label-priority score (localGeojson.js). + * @param {number} priority Existing label-priority score (localGeojsonCore.js). * @param {number} distanceM Camera→feature distance in metres. * @param {boolean} isIncumbent Record currently holds an active stem. * @param {object} [options] @@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({ * ------------------------------------------------------------------ */ /** - * Probe window for the motion fallback. `localGeojson.js` marks its selection + * Probe window for the motion fallback. `localGeojsonCore.js` marks its selection * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * flight, or a continuous orbit never emits one. Without a fallback the diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index e3144bdf7..736d812ee 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -54,6 +54,21 @@ function direction(modifier) { return DIRECTION_WORDS[key] || key; } +/** + * "on the left" only fits a bare side. "slight left" is the adverb + * "slightly left", and "on the slightly left" is not an instruction. + * @param {string} prefix "Take the ramp" or "Take the exit". + * @param {string} modifier OSRM maneuver modifier. + * @param {string} onto " onto …", or "". + */ +function directedWay(prefix, modifier, onto) { + const dir = direction(modifier); + if (dir === 'left' || dir === 'right') + return `${prefix} on the ${dir}${onto}`; + if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`; + return `${prefix}${onto}`; +} + function ordinal(n) { if (!Number.isInteger(n) || n < 1) return null; const suffix = @@ -103,13 +118,9 @@ export function instructionFor(step) { case 'merge': return dir ? `Merge ${dir}${onto}` : `Merge${onto}`; case 'on ramp': - return dir - ? `Take the ramp on the ${dir}${onto}` - : `Take the ramp${onto}`; + return directedWay('Take the ramp', modifier, onto); case 'off ramp': - return dir - ? `Take the exit on the ${dir}${onto}` - : `Take the exit${onto}`; + return directedWay('Take the exit', modifier, onto); case 'roundabout': case 'rotary': { const exit = ordinal(step?.exit); @@ -124,8 +135,14 @@ export function instructionFor(step) { case 'exit roundabout': case 'exit rotary': return `Exit the roundabout${onto}`; - case 'use lane': - return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`; + case 'use lane': { + const lane = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`; + } case 'notification': default: return `Continue${onto}`; diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index 6bf74dd08..c0eb445dc 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () => instructionFor({ type: 'on ramp', modifier: 'left' }), 'Take the ramp on the left', ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }), + 'Take the ramp slightly left onto I-95', + ); + assert.equal( + instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }), + 'Take the exit sharply right onto I-280', + ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }), + 'Take the ramp onto US 101', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'slight left' }), + 'Use the left lane', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }), + 'Use the right lane onto Main St', + ); assert.equal( instructionFor({ type: 'merge', diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index af4fc95b3..26ec539ab 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -2,7 +2,7 @@ * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. * * Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, - * daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles + * daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so * both sides can unit-test against it with node:test. * diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index 213fe59dc..4e97849c0 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -75,4 +75,5 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); + assert.match(compose, /^\s+- \.:\/app:z$/m, 'SELinux hosts can read the bind-mounted checkout'); }); diff --git a/src/hud.js b/src/hud.js index cf7983da7..954e3ea94 100644 --- a/src/hud.js +++ b/src/hud.js @@ -282,28 +282,28 @@ export class IntelHUD {
- TOP SECRET // SI-TK // NOFORN - ${this._missionId} - PAGE 1/1 + TOP SECRET // SI-TK // NOFORN + ${this._missionId} + PAGE 1/1
┌
-
TOP SECRET // SI-TK // NOFORN
-
${this._missionId} ${this._sensorId}
-
${t(MODE_LABELS.normal)}
+
TOP SECRET // SI-TK // NOFORN
+
${this._missionId} ${this._sensorId}
+
${t(MODE_LABELS.normal)}
-
${t('SUMMARY')}
-
${t('Awaiting telemetry...')}
+
${t('SUMMARY')}
+
${t('Awaiting telemetry...')}
-
● ${t('REC')} 2026-01-01 00:00:00Z
-
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
+
● ${t('REC')} 2026-01-01 00:00:00Z
+
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -311,33 +311,33 @@ export class IntelHUD {
└
-
MGRS: ---
-
--°--'--"N ---°--'--"W
+
MGRS: ---
+
--°--'--"N ---°--'--"W
-
GSD: --m NIIRS: --
-
ALT: --m SUN: --° EL
-
${t('AIS: --')}
+
GSD: --m NIIRS: --
+
ALT: --m SUN: --° EL
+
${t('AIS: --')}
┘
-
COLL: --:--:--Z
-
ONA: --°
+
COLL: --:--:--Z
+
ONA: --°
-
BAND: PAN
-
BITS: 11
-
LVL: 1A
+
BAND: PAN
+
BITS: 11
+
LVL: 1A
- LAT: -- LON: -- MGRS: --- + LAT: -- LON: -- MGRS: ---
`; this._el.dataset.variant = this._variant; @@ -574,11 +574,12 @@ export class IntelHUD { * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. */ _toDMS(decimal, type) { - const abs = Math.abs(decimal); - const deg = Math.floor(abs); - const minFloat = (abs - deg) * 60; - const min = Math.floor(minFloat); - const sec = ((minFloat - min) * 60).toFixed(2); + // Round once, in hundredths of a second, so a value that rounds up to + // 60.00" carries into the minutes and degrees instead of printing 60. + const hundredths = Math.round(Math.abs(decimal) * 360000); + const deg = Math.floor(hundredths / 360000); + const min = Math.floor(hundredths / 6000) % 60; + const sec = ((hundredths % 6000) / 100).toFixed(2); let dir; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs new file mode 100644 index 000000000..064906b5f --- /dev/null +++ b/src/hudDms.test.mjs @@ -0,0 +1,106 @@ +// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to +// two decimals, so a value within 0.005" of the next whole minute rounds up. +// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a +// coordinate. Ordinary decimal inputs land there through binary floating +// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00". +// +// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM +// loader cannot see, so this installs the same one-specifier stub as +// hudAltitudeDatum.test.mjs before importing it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type); + +test('a rounded-up second carries into the minutes', () => { + assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`); + assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`); + assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`); +}); + +test('a carry through 59 minutes reaches the degrees', () => { + assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`); + assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`); + assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`); +}); + +test('ordinary values keep their existing format', () => { + assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`); + assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`); + assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`); + assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`); + assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`); + assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`); +}); + +test('the telemetry tick paints the carried value into both readouts', () => { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { + latitude: (37.75 * Math.PI) / 180, + longitude: (-122.35 * Math.PI) / 180, + height: 1500, + }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + let hud; + try { + hud = new IntelHUD(viewer); + hud._updateCameraData(); + const latlon = elements.get('hud-latlon').textContent; + const bottom = elements.get('hud-bottom-line').textContent; + assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`); + assert.match(bottom, /LON: 122°21'00\.00"W/); + assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/); + } finally { + hud?.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4fe130f23..098c82f55 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1867,11 +1867,15 @@ "maneuver|Merge": "Incorpórate", "Merge onto {road}": "Incorpórate a {road}", "Take the ramp on the {direction}": "Toma la rampa {direction}", + "Take the ramp {direction}": "Toma la rampa {direction}", "Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}", + "Take the ramp {direction} onto {road}": "Toma la rampa {direction} hacia {road}", "Take the ramp": "Toma la rampa", "Take the ramp onto {road}": "Toma la rampa hacia {road}", "Take the exit on the {direction}": "Toma la salida {direction}", + "Take the exit {direction}": "Toma la salida {direction}", "Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}", + "Take the exit {direction} onto {road}": "Toma la salida {direction} hacia {road}", "Take the exit": "Toma la salida", "Take the exit onto {road}": "Toma la salida hacia {road}", "At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 978640951..8d12ba43e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1899,11 +1899,15 @@ "maneuver|Merge": "Перестройтесь", "Merge onto {road}": "Въезжайте на {road}", "Take the ramp on the {direction}": "Въезжайте на съезд {direction}", + "Take the ramp {direction}": "Въезжайте на съезд {direction}", "Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", + "Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", "Take the ramp": "Въезжайте на съезд", "Take the ramp onto {road}": "Въезжайте по съезду на {road}", "Take the exit on the {direction}": "Сверните на съезд {direction}", + "Take the exit {direction}": "Сверните на съезд {direction}", "Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}", + "Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}", "Take the exit": "Сверните на съезд", "Take the exit onto {road}": "Сверните на съезд на {road}", "At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 01c495a27..e727b8516 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -626,11 +626,15 @@ "Merge onto {road}": "并入 {road}", "maneuver|Merge": "并道", "Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}", + "Take the ramp {direction} onto {road}": "{direction}驶入匝道,进入 {road}", "Take the ramp on the {direction}": "{direction}驶入匝道", + "Take the ramp {direction}": "{direction}驶入匝道", "Take the ramp onto {road}": "驶入匝道,进入 {road}", "Take the ramp": "驶入匝道", "Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}", + "Take the exit {direction} onto {road}": "从出口{direction}驶出,进入 {road}", "Take the exit on the {direction}": "从出口{direction}驶出", + "Take the exit {direction}": "从出口{direction}驶出", "Take the exit onto {road}": "从出口驶出,进入 {road}", "Take the exit": "从出口驶出", "At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}", diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..7ca99a96d 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -206,12 +206,27 @@ export function photonSearchUrl( url.searchParams.set('limit', String(limit)); const corners = String(bias ?? '').split('|'); - if (corners.length === 2) { - const [swLat, swLng] = corners[0].split(',').map(Number); - const [neLat, neLng] = corners[1].split(',').map(Number); - if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) { + const tokens = corners.flatMap((corner) => corner.split(',')); + if ( + corners.length === 2 && + corners.every((corner) => corner.split(',').length === 2) && + tokens.every((token) => token.trim() !== '') + ) { + const [swLat, swLng, neLat, neLng] = tokens.map(Number); + if ( + [swLat, swLng, neLat, neLng].every(Number.isFinite) && + Math.abs(swLat) <= 90 && + Math.abs(neLat) <= 90 && + swLat <= neLat && + Math.abs(swLng) <= 180 && + Math.abs(neLng) <= 180 + ) { + const midpoint = (swLng + neLng + (swLng > neLng ? 360 : 0)) / 2; url.searchParams.set('lat', String((swLat + neLat) / 2)); - url.searchParams.set('lon', String((swLng + neLng) / 2)); + url.searchParams.set( + 'lon', + String(midpoint > 180 ? midpoint - 360 : midpoint), + ); } } return url.toString(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..1a702fd02 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -452,3 +452,14 @@ function readGeocodingConsumer(file) { ? [file, 'annotations/resolver.js'] : [file]; return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } + + +test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { + for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { + const params = new URL(photonSearchUrl('island', { bias })).searchParams; + assert.equal(Number(params.get('lon')), expected); + } + for (const bias of ['-91,0|0,1', '0,181|1,0', '10,0|0,1', ',|,', '0,1,2|3,4', '0,1|Infinity,2']) { + assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false); + } +}); diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js new file mode 100644 index 000000000..7439907d9 --- /dev/null +++ b/src/layers/awareness/presentation.js @@ -0,0 +1,10 @@ +/** + * Format a compass direction for the awareness overlay. + * @param {number} degrees + * @returns {string} + */ +export function formatAwarenessDirection(degrees) { + if (!Number.isFinite(degrees)) return '—'; + const normalized = ((degrees % 360) + 360) % 360; + return String(Math.round(normalized) % 360).padStart(3, '0'); +} diff --git a/src/layers/awareness/presentation.test.mjs b/src/layers/awareness/presentation.test.mjs new file mode 100644 index 000000000..54d40765f --- /dev/null +++ b/src/layers/awareness/presentation.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatAwarenessDirection } from './presentation.js'; + +test('awareness bearings that round through north read 000, not 360', () => { + assert.equal(formatAwarenessDirection(359.4), '359'); + assert.equal(formatAwarenessDirection(359.6), '000'); + assert.equal(formatAwarenessDirection(0), '000'); + assert.equal(formatAwarenessDirection(-0.4), '000'); + assert.equal(formatAwarenessDirection(Number.NaN), '—'); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8fe147e46..957e0bbdd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -11,6 +11,7 @@ import { } from '../../data/militaryAwarenessEngine.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { formatAwarenessDirection } from './presentation.js'; export function createRendering({ state: layerState, @@ -225,12 +226,12 @@ export function createRendering({ : null; const bearingText = Number.isFinite(bearing) ? t('BRG {value}°', { - value: String(Math.round(bearing)).padStart(3, '0'), + value: formatAwarenessDirection(bearing), }) : t('BRG —'); const courseText = Number.isFinite(item.track) ? ` · ${t('CRS {value}°', { - value: String(Math.round(item.track)).padStart(3, '0'), + value: formatAwarenessDirection(item.track), })}` : ''; marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index e90e1be18..b8cceff02 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -308,16 +308,24 @@ export function directionsInstruction(step) { ? t('Merge onto {road}', values) : t('Merge', { context: 'maneuver' }); case 'on ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the ramp on the {direction} onto {road}', values) : t('Take the ramp on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the ramp {direction} onto {road}', values) + : t('Take the ramp {direction}', values); return road ? t('Take the ramp onto {road}', values) : t('Take the ramp'); case 'off ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the exit on the {direction} onto {road}', values) : t('Take the exit on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the exit {direction} onto {road}', values) + : t('Take the exit {direction}', values); return road ? t('Take the exit onto {road}', values) : t('Take the exit'); case 'roundabout': case 'rotary': @@ -346,9 +354,14 @@ export function directionsInstruction(step) { return road ? t('Exit the roundabout onto {road}', values) : t('Exit the roundabout'); - case 'use lane': - if (direction) { - const lane = { road, direction: laneWord(modifier) }; + case 'use lane': { + const side = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + if (side) { + const lane = { road, direction: laneWord(side) }; return road ? t('Use the {direction} lane onto {road}', lane) : t('Use the {direction} lane', lane); @@ -356,6 +369,7 @@ export function directionsInstruction(step) { return road ? t('Continue onto {road}', values) : t('Continue', { context: 'maneuver' }); + } default: return road ? t('Continue onto {road}', values) diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 1b0122925..c1c923a8c 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -152,7 +152,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track}°`, + track: track === null ? DASH : `${track % 360}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/localAdsb/card.test.mjs b/src/layers/localAdsb/card.test.mjs new file mode 100644 index 000000000..ec53024da --- /dev/null +++ b/src/layers/localAdsb/card.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../../i18n/index.js'; +import { localAdsbCardModel } from './card.js'; + +test('a track of 359.7° reads TRK 0°, not TRK 360°', () => { + setLocale('en'); + const { details } = localAdsbCardModel( + { + icao: 'abc123', + altitudeFt: 12500, + groundSpeedKt: 240, + trackDeg: 359.7, + lastPositionAt: 1000, + }, + 5000, + ); + assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°'); +}); diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js index 691496554..9a0c248dd 100644 --- a/src/layers/traffic/flowDecode.js +++ b/src/layers/traffic/flowDecode.js @@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile'; * tree-shakeable; decoding happens client-side so the proxy stays a dumb * binary cache. * - * @module data/flowTiles + * @module layers/traffic/flowDecode */ const FLOW_LAYER_NAME = 'Traffic flow'; diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3; // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. const MAX_MOSAICS = 6; const MAX_PREFETCH_TILES = 8; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 486d42520..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now }); diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 884c5b199..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,6 +38,7 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); + if (response.status === 204) return null; if (!response.ok) { await response.body?.cancel(); return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index d8a1e84ed..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/nominatimGeocode.js b/src/nominatimGeocode.js index b7dd6145a..4a5a1948e 100644 --- a/src/nominatimGeocode.js +++ b/src/nominatimGeocode.js @@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) { const neLat = nominatimLatitude(match[3]); const neLng = nominatimLongitude(match[4]); if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null; - if (swLat >= neLat) return null; + // One Nominatim viewbox cannot express a wrapped interval. Omit this soft + // preference rather than biasing the search toward the opposite hemisphere. + if (swLat >= neLat || swLng > neLng) return null; return `${swLng},${neLat},${neLng},${swLat}`; } diff --git a/src/nominatimGeocode.test.mjs b/src/nominatimGeocode.test.mjs index b139ad820..8517649d5 100644 --- a/src/nominatimGeocode.test.mjs +++ b/src/nominatimGeocode.test.mjs @@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () => `${JSON.stringify(bounds)} must not become a viewbox`, ); }); + +test('wrapped viewports omit the Nominatim bias without rejecting the search', () => { + assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null); + assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null); + assert.equal( + nominatimViewboxFromBounds('-20,170|-10,180'), + '170,-10,180,-20', + ); +}); diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..2ff6d6c8c 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js'; +import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojsonCore.js'; import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js'; import { vesselOverlayCohortLimit } from '../data/vesselLabels.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..78748ff32 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle runtime.url = url; const items = await menu(kernel, info); assert.equal(items.filter((item) => item.default).length, 1); + const ready = installed && running === 'start.js' && Boolean(url); + assert.equal(items[0].popout, ready ? true : undefined); + assert.ok(items.slice(1).every((item) => item.popout === undefined)); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault })); }; diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index dfd951377..766079aa4 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js'; // actually decides fresh-boot layer state — including the early return that // makes each layer's own initializer the operative default. // 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% -// on 2026-08-24, a soft -// edge. The hard crop is still one drag away, and that is pinned too. +// 2026-08-22; later revisions were 8%, then 11%, then 100% on +// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available. // 3. Detection ON (Dense @ 75%) for EVERY style, Normal included. // 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 @@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js'; import { SCOPE_FEATHER_RATIO_DEFAULT, getScopeMaskFeather, + isScopeMaskEnabled, scopeMaskGeometry, setScopeMaskFeather, } 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', () => { - assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11, - 'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings'); - assert.equal(getScopeMaskFeather(), 0.11, +test('first run opens with Scope ON and 100% feather at every default surface', () => { + assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled'); + assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25'); + assert.equal(getScopeMaskFeather(), 1, 'and the live module starts there, not merely documents it'); // 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 // over a mask drawn at another. - assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/, - 'index.html: the feather slider ships at 11'); - assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%]*\svalue="100"/, + 'index.html: the feather slider ships at 100'); + assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%]*\schecked\s*\/>/, + 'the native Scope checkbox starts checked'); + // The link this session generates must describe the mask this session draws, // 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'); + 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 // 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=64').parseInitialHash().scopeFeatherPct, 64); 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'); }); -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 // 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. - 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'); const previous = getScopeMaskFeather(); try { - for (const ratio of [0.35, 0.7, 1]) { + for (const ratio of [0.35, 0.7, 1, 1.75, 2]) { setScopeMaskFeather(ratio); const geo = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; 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`); } - // The new default is a real, narrow band — not the hard crop, and nowhere - // near the retired 35 % halo. + // The first-run value draws a band derived from the same keyhole radius. setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); const soft = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; diff --git a/src/scopeMask.js b/src/scopeMask.js index 775a257f7..c43108377 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -1,4 +1,5 @@ import { getKeyholeGeometry } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; /** * 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. */ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 }; /** - * Default edge feather as a fraction of the keyhole radius. - * - * 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. + * First-run feather ratio; the Display slider and share defaults use 100%. + * The historical missing-scf parse fallback remains 35%. */ -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 * faint stars survive in the corners at globe scale. @@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() { export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) { const keyhole = getKeyholeGeometry(width, height); 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 // to the shared geometry every keyhole consumer (labels, cards) fades on. const half = keyhole.radius * feather * 0.5; @@ -423,6 +412,14 @@ function draw() { _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. * @param {Cesium.Viewer} viewer @@ -430,6 +427,7 @@ function draw() { */ export function installScopeMask(viewer) { if (_canvas || !viewer?.container) return; + syncScopeMaskAppearance(); _container = viewer.container; _viewer = viewer; _canvas = document.createElement('canvas'); @@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) { const next = Boolean(enabled); const reEnabled = next && !_enabled; _enabled = next; + syncScopeMaskAppearance(); if (reEnabled) { // 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 @@ -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} */ 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(); } @@ -540,6 +542,7 @@ export function getScopeMaskFeather() { * @returns {void} */ export function destroyScopeMask() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); @@ -553,6 +556,7 @@ export function destroyScopeMask() { /** Test seam. */ export function _resetScopeMaskForTest() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index 7f5dcba62..d37b6b23b 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -18,6 +18,7 @@ import { SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT, SCOPE_FEATHER_RATIO_DEFAULT, + getScopeMaskFeather, SCOPE_OUTSIDE_ALPHA, SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_FAR_M, @@ -26,6 +27,7 @@ import { _resetScopeMaskForTest, } from './scopeMask.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js'; beforeEach(() => _resetScopeMaskForTest()); @@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => { 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 one = scopeMaskGeometry(1000, 800, 1); - assert.equal(wide.innerR, one.innerR); - assert.equal(wide.outerR, one.outerR); + const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX); + const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS; + 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 zero = scopeMaskGeometry(1000, 800, 0); 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', () => { assert.equal(scopeMaskGeometry(0, 0), 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), }), }; - globalThis.document = { createElement: () => canvas }; + const documentElement = { dataset: {} }; + globalThis.document = { documentElement, createElement: () => canvas }; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; const container = { clientWidth: width, clientHeight: height, appendChild() {} }; return { canvas, container, + documentElement, /** Canvas work counters: backing-store resizes, clears, fills. */ ops, /** 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', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { diff --git a/src/scopeMaskPolicy.js b/src/scopeMaskPolicy.js new file mode 100644 index 000000000..13515fb43 --- /dev/null +++ b/src/scopeMaskPolicy.js @@ -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)), + ); +} diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 159cd66fd..eecb1fa34 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) { } const now = Date.now(); const entry = entries.get(icao); - if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force + // Backoff in force, unless it ends further off than any backoff can: + // a retry stamped before the clock stepped back would otherwise wait for + // the clock to catch up. + if ( + entry && + now < entry.nextRetryMs && + entry.nextRetryMs - now <= RETRY_MAX_MS + ) + return heldSnapM(entry, surfacePos); // Per-window budget: deny WITHOUT a retry stamp so the overflow simply // tries again next tick instead of waiting out a backoff it didn't earn. - if (now - windowStartMs > SAMPLE_WINDOW_MS) { + // A clock that stepped back also opens a new window, or none would open + // until it caught up. + if (now < windowStartMs || now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; } diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs new file mode 100644 index 000000000..8dfe42c46 --- /dev/null +++ b/src/services/groundSnap.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGroundSnap } from './groundSnap.js'; + +const groundFloor = { + cachedMeshFloor: () => null, + meshFloorPreferred: () => false, + reportValidatedMeshFloorCell: () => {}, +}; + +/** A viewer whose ground is a loaded 3D tileset, counting height samples. */ +function makeViewer() { + const calls = { samples: 0 }; + const tileset = { show: true, tilesLoaded: true }; + return { + calls, + scene: { + primitives: { length: 1, get: () => tileset }, + sampleHeight() { + calls.samples += 1; + return 1650; + }, + globe: { getHeight: () => 1600 }, + }, + }; +} + +const atDenverAirport = Cesium.Cartesian3.fromDegrees(-104.6737, 39.8561, 0); + +test('a clock that steps back opens a new sample window instead of denying samples until it catches up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const viewer = makeViewer(); + const snap = createGroundSnap({ groundFloor }); + const icaos = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005', 'a00006']; + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal(viewer.calls.samples, 4, 'four samples per 250 ms window'); + t.mock.timers.setTime(1_000_000 - 60_000); + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal( + viewer.calls.samples, + 6, + 'the two denied aircraft sample in the new window', + ); +}); + +test('a retry backoff stamped before the clock stepped back does not wait for it to catch up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 10_000_000 }); + const viewer = makeViewer(); + const tileset = viewer.scene.primitives.get(0); + const snap = createGroundSnap({ groundFloor }); + // Tiles still streaming: a miss, backed off for 2 s. + tileset.tilesLoaded = false; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + tileset.tilesLoaded = true; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + assert.equal(viewer.calls.samples, 0, 'the backoff holds'); + // The clock steps back an hour: the stamp is an hour off, past any backoff. + t.mock.timers.setTime(10_000_000 - 3_600_000); + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), 1650); + assert.equal(viewer.calls.samples, 1); +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 230843584..746d5b3bc 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -99,6 +99,22 @@ test('Nepal locator token is valid in v2 share links', () => { 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', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); @@ -498,8 +514,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'], ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], - ['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], - ['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], + ['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'], + ['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'], ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], diff --git a/src/sharelink.js b/src/sharelink.js index 7e0e78b35..c184a59f5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, encodeLayerStateParams, @@ -112,10 +113,7 @@ export class ShareLinkManager { this._detectionOutsideOpacityPct = 1; this._celestialRingEnabled = false; this._scopeEnabled = true; - // Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24, - // 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; + this._scopeFeatherPct = 100; // null = the altitude-adaptive terminus (the default). A number pins the // outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17) this._scopeTerminusPct = null; @@ -227,10 +225,7 @@ export class ShareLinkManager { // (`_scopeFeatherPct` in the constructor tracks the default: that one // mirrors live state for the link this session generates, so it must match // the mask, not the archive.) - scopeFeatherPct: Math.max( - 0, - Math.min(100, Math.round(parseOr(params.get('scf'), 35))), - ), + scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)), // Absent (or non-numeric) `sce` = adaptive (null), the default behavior; // 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 @@ -494,10 +489,7 @@ export class ShareLinkManager { if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled; if (typeof extras.scopeFeatherPct === 'number') { - this._scopeFeatherPct = Math.max( - 0, - Math.min(100, Math.round(extras.scopeFeatherPct)), - ); + this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct); } if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; else if (typeof extras.scopeTerminusPct === 'number') { diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index c37c2fae4..c942579db 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -98,6 +98,13 @@ function dockCapacity(raw) { return capacity - geofenced; } +/** Read a numeric coordinate without treating blank text or other JSON types as zero. */ +function coordinateNumber(value) { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) + return NaN; + return Number(value); +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -113,9 +120,16 @@ export function parseStationInformation(payload) { for (const raw of stations) { // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const lat = coordinateNumber(raw?.lat ?? raw?.latitude); + const lon = coordinateNumber(raw?.lon ?? raw?.longitude); + if ( + !stationId || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; stationMap.set(stationId, { stationId, diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 91983df98..1ab273556 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -19,6 +19,73 @@ const parseCapacity = (fields) => parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') .capacity; +test('station information skips missing, blank and malformed coordinates', () => { + for (const field of ['lat', 'lon', 'latitude', 'longitude']) { + for (const value of [ + undefined, + null, + '', + ' \t ', + false, + true, + [], + [0], + {}, + 'bad', + NaN, + Infinity, + ]) { + const raw = { station_id: 'bad', lat: 0, lon: 0 }; + if (field === 'latitude') delete raw.lat; + if (field === 'longitude') delete raw.lon; + raw[field] = value; + const parsed = parseStationInformation({ + data: { stations: [raw, station({ station_id: 'good' })] }, + }); + assert.deepEqual( + [...parsed.keys()], + ['good'], + `${field} = ${JSON.stringify(value)}`, + ); + } + } +}); + +test('station information skips coordinates outside latitude and longitude bounds', () => { + for (const fields of [ + { lat: 90.001 }, + { lat: -90.001 }, + { lon: 180.001 }, + { lon: -180.001 }, + { lat: undefined, latitude: '91' }, + { lon: undefined, longitude: '-181' }, + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }); + assert.equal(parsed.size, 0, JSON.stringify(fields)); + } +}); + +test('station information preserves zero, boundary coordinates and numeric aliases', () => { + for (const [fields, expected] of [ + [{ lat: 0, lon: 0 }, [0, 0]], + [{ lat: ' 0 ', lon: '0' }, [0, 0]], + [{ lat: 90, lon: 180 }, [90, 180]], + [{ lat: '-90', lon: '-180' }, [-90, -180]], + [ + { lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' }, + [30.2681, -97.7469], + ], + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }).get('s1'); + assert.ok(parsed, JSON.stringify(fields)); + assert.deepEqual([parsed.lat, parsed.lon], expected); + } +}); + test('capacity counts docks, not the geofenced area PBSC adds to it', () => { // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); diff --git a/src/tooling/googleTokenUnavailable.test.mjs b/src/tooling/googleTokenUnavailable.test.mjs new file mode 100644 index 000000000..e0ee19232 --- /dev/null +++ b/src/tooling/googleTokenUnavailable.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js'; + +async function withServer({ beforeFallback } = {}, check) { + const middlewares = []; + const app = { + middlewares: { + use: (handler) => middlewares.push(handler), + }, + }; + if (beforeFallback) app.middlewares.use(beforeFallback); + googleTokenUnavailablePlugin().configureServer(app); + app.middlewares.use((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + + const server = createServer((req, res) => { + let index = 0; + const next = () => middlewares[index++]?.(req, res, next); + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const url = 'http://127.0.0.1:' + address.port; + try { + await check(url); + } finally { + server.close(); + await once(server, 'close'); + } +} + +test('keyless GET and HEAD return uncached 204 instead of 404', async () => { + await withServer({}, async (url) => { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url + '/api/google/tiles-token?probe=1', { + method, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await response.text(), ''); + } + }); +}); + +test('the existing client treats unavailable tokens as null', async () => { + await withServer({}, async (url) => { + const source = createGoogleTokenSource({ + fetchImpl: (path, options) => fetch(new URL(path, url), options), + }); + assert.equal(await source.token(), null); + }); +}); + +test('unrelated API paths and unsupported methods remain 404', async () => { + await withServer({}, async (url) => { + for (const [path, method] of [ + ['/api/google/tiles-token/extra', 'GET'], + ['/api/not-a-token', 'GET'], + ['/api/google/tiles-token', 'POST'], + ]) { + const response = await fetch(new URL(path, url), { method }); + assert.equal(response.status, 404); + } + }); +}); + +test('a real token provider registered first still wins', async () => { + await withServer( + { + beforeFallback: (req, res, next) => { + if (req.url !== '/api/google/tiles-token') return next(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + accessToken: 'server-token', + expiresAt: 10000000000000, + }), + ); + }, + }, + async (url) => { + const response = await fetch(url + '/api/google/tiles-token'); + assert.equal(response.status, 200); + assert.equal((await response.json()).accessToken, 'server-token'); + }, + ); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 7417f7145..a9e8c5398 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -5,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises'; import { request } from 'node:http'; import path from 'node:path'; import { build, createServer, preview } from 'vite'; -import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js'; @@ -30,6 +35,20 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + const root = fileURLToPath(new URL('../../', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + assert.deepEqual( + config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')), + [`${escapeFsDenyPath(root)}/dist/**`], + 'only project build output is denied, not dependency dist directories', + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'), + 'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; @@ -98,7 +117,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -5) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -112,9 +131,10 @@ test('root config retains existing named exports and standalone provider order', standaloneProviders.filter((name) => name !== 'gemini-live-proxy'), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); - // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-5).name, 'local-mcp'); + assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + // Token fallback follows real providers and local MCP before the API 404. + assert.equal(config.plugins.at(-6).name, 'local-mcp'); + assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs'); diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 475df97ba..2b11c639c 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -5,6 +5,24 @@ import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +function acquiredMsUtc(acqDate, acqTime) { + if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) + return NaN; + const timeText = String(acqTime ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) return NaN; + const hhmm = timeText.padStart(4, '0'); + const hours = Number(hhmm.slice(0, 2)); + const minutes = Number(hhmm.slice(2, 4)); + if (hours > 23 || minutes > 59) return NaN; + const timestamp = Date.parse( + `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, + ); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString().slice(0, 10) === acqDate + ? timestamp + : NaN; +} + export const getEarthquakes = defineTool({ name: 'get_earthquakes', title: 'Recent earthquakes', @@ -114,8 +132,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: - [fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null, + acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs new file mode 100644 index 000000000..fa17158dd --- /dev/null +++ b/src/tools/queries/hazards.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog } from '../catalog.js'; +import { getActiveFires } from './hazards.js'; + +const catalogWith = (fires) => + composeCatalog({ + tools: [getActiveFires], + services: { fires: { getSnapshot: async () => ({ fires }) } }, + }); + +test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { + const result = await catalogWith([ + { lat: 24, lon: 121, frp: 5, acqDate: '2026-01-01', acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); +}); + +test('fire acquisition timestamps reject impossible calendar dates', async () => { + for (const acqDate of [ + '2026-02-29', + '2026-02-30', + '2026-04-31', + '1900-02-29', + '2026-13-01', + '2026-01-00', + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, null, acqDate); + } +}); + +test('fire acquisition timestamps preserve valid leap days and UTC times', async () => { + for (const [acqDate, acqTime, expected] of [ + ['2024-02-29', '45', '2024-02-29T00:45:00.000Z'], + ['2000-02-29', 0, '2000-02-29T00:00:00.000Z'], + ['2026-04-30', '2359', '2026-04-30T23:59:00.000Z'], + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, expected); + } +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 676351b6c..0ea882727 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async () ); }); +test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => { + const stations = [ + { station_id: 'blank', lat: 51.5, lon: ' ' }, + { station_id: 'zero', lat: 51.5, lon: 0 }, + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: [system('fixture', 'Fixture Bikes', 51.5, 0)], + async getStations(url) { + return { + data: { + stations: url.endsWith('station_information.json') + ? stations + : stations.map(({ station_id }) => ({ + station_id, + num_bikes_available: 4, + })), + }, + }; + }, + }, + }, + }); + const result = await catalog.call('get_bike_share', { + area: { lat: 51.5, lon: 0, radius_km: 2 }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.station_id), + ['zero'], + ); + assert.equal(result.data.total, 1); + assert.equal(result.data.rows[0].lon, 0); + assert.equal( + result.summary, + '1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.', + ); +}); + const feed = { id: 'capmetro', name: 'CapMetro', diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs index b64f5e009..5f53cf9e8 100644 --- a/src/tools/queries/queries.test.mjs +++ b/src/tools/queries/queries.test.mjs @@ -125,7 +125,7 @@ test('fires need an area and report a missing key as unavailable', async () => { result.data.rows.map((row) => row.frp_mw), [40, 12], ); - assert.equal(result.data.rows[1].acquired, '2026-01-01 0130'); + assert.equal(result.data.rows[1].acquired, '2026-01-01T01:30:00.000Z'); const keyless = composeCatalog({ tools: coreTools, diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index ba325f6e9..6e14e0adf 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -70,7 +70,6 @@ export class AircraftDisplay { // 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 // tech reported was an unpressed-looking control over an armed layer. - // Mirrors #scope-toggle, which has always carried aria-pressed. this._models3dBtn?.setAttribute( 'aria-pressed', String(this._models3dEnabled), diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 68710a36a..03ee75f08 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,3 +1,4 @@ +import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; 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'); }); +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) => { const { owner, hud } = styleOwner(t, 'tactical'); owner.setStyle('noir'); diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index 329681798..3ae874c3c 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -1,6 +1,7 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { createFrameRateMonitor } from './frameRateMonitor.js'; import { bindApplicationShortcuts } from './visualInput.js'; -import { bindDisplayControls } from './displayControls.js'; +import { bindDisplayControls, syncScopeControls } from './displayControls.js'; import { canonicalizeDensity } from '../data/detectionPolicy.js'; /** 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 = bindDisplayControls({ elements: { @@ -100,7 +106,7 @@ export class DisplayBindings { bloomSlider: this._bloomSlider, sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, - scopeButton: this._scopeBtn, + scopeToggle: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, @@ -134,17 +140,15 @@ export class DisplayBindings { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setSharpenEnabled(!this.sharpenEnabled); }, - toggleScope: () => { + setScopeEnabled: (enabled) => { this.shareLinkManager?.claimRestoreLane?.('visual'); - const next = !isScopeMaskEnabled(); - setScopeMaskEnabled(next); - this._scopeBtn.classList.toggle('active', next); - this._scopeBtn.setAttribute('aria-pressed', String(next)); + setScopeMaskEnabled(enabled); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled); this._syncShareState(); }, setScopeFeather: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = Math.max(0, Math.min(100, value || 0)); + const pct = clampScopeFeatherPct(value); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; setScopeMaskFeather(pct / 100); diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js index e0d76d338..ceab61d91 100644 --- a/src/ui/displayControls.js +++ b/src/ui/displayControls.js @@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) { for (const [name, action] of [ ['bloomButton', 'toggleBloom'], ['sharpenButton', 'toggleSharpen'], - ['scopeButton', 'toggleScope'], ['cleanViewButton', 'toggleCleanView'], ['cleanViewExitButton', 'exitCleanView'], ['celestialButton', 'toggleCelestial'], @@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) { ['scopeFeatherSlider', 'setScopeFeather'], ]) listen(elements[name], 'input', action, integer); + listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked); listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); 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; +} diff --git a/src/ui/displayControls.test.mjs b/src/ui/displayControls.test.mjs index 705a9f775..f7c5bcafb 100644 --- a/src/ui/displayControls.test.mjs +++ b/src/ui/displayControls.test.mjs @@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned' assert.deepEqual(calls, ['b']); 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], + ]); +}); diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js index 3bad73a20..2f6ab8e8d 100644 --- a/src/ui/frameRateMonitor.js +++ b/src/ui/frameRateMonitor.js @@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { if (!host || !frameEvent) return { destroy() {} }; const readout = documentRef.createElement('div'); - readout.className = 'frame-rate-readout'; + readout.className = 'frame-rate-readout scene-text-holdout'; readout.hidden = true; // i18n-ignore: FPS is a unit code, the same in every language readout.textContent = 'FPS —'; diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index eef3c6001..547037de7 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has( bottom: calc(2vh + 5.4rem); } +:root[data-scope-mask='off'] + #intel-hud[data-variant='minimal'] + .hud-bottom-left { + bottom: calc(2vh + 6.5rem); +} + #intel-hud[data-variant='minimal'] .hud-bottom-bar { bottom: calc(2vh + 9rem); } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index f11378bb9..59f275109 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn { color: var(--accent); } +.pp-scope-toggle input { + width: 14px; + height: 14px; + margin: 0 0 0 auto; + accent-color: var(--accent); + cursor: pointer; +} + +.pp-scope-toggle:has(input:checked) .pp-label { + color: var(--accent); +} + +.pp-scope-toggle:has(input:focus-visible) { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +#scope-slider-row:has(input:disabled) { + opacity: 0.45; +} + /* ── Bloom Slider (inline with toggle) ───────── */ .pp-toggle-group { display: flex; diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 227d2d6d0..7f491439b 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -371,3 +371,134 @@ #intel-hud[data-variant='minimal'] .hud-summary-wrap { max-width: 280px; } + +/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */ +.hud-readout { + display: contents; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + .hud-readout, +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout { + background: rgba(0, 0, 0, 0.7); + box-shadow: none; + border-radius: 2px; + padding-block: 0; + padding-inline: 6px; + margin-block: 0; + margin-inline: -6px; + width: fit-content; + max-width: 100%; + box-sizing: content-box; + white-space: nowrap; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + .hud-readout { + display: block; + width: max-content; + min-width: 0; + box-sizing: content-box; + white-space: nowrap; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + :is(.hud-top-right, .hud-bottom-right) + .hud-readout, +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .scene-text-holdout { + margin-inline-start: auto; +} + +/* Preserve glyph opacity without dimming the vertical backplates. */ +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + .hud-edge { + opacity: 1; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + .hud-edge + .hud-readout + > * { + opacity: 0.5; +} + +:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner, +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) { + background: transparent; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) { + width: fit-content; + max-width: calc(100vw - 120px); + gap: 24px; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) + .hud-readout { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + h1::before { + content: ''; + position: absolute; + inset: -0.12em -6px; + z-index: -1; + border-radius: 2px; + background: rgba(0, 0, 0, 0.7); +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + :is(#title-bar .subtitle, #style-indicator .indicator-label) { + color: var(--text-secondary); +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle { + margin-top: 8px; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .indicator-value { + margin-top: 4px; +} + +:root[data-scope-mask='off'] + #intel-hud[data-variant='minimal'] + .hud-readout:has(> :is(.hud-classification, .hud-system)) { + display: none; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + .frame-rate-readout { + margin-top: 6px; +} diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index f31453b6c..f6af31127 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -106,19 +106,15 @@
- + +
Feather - - - 11% + + 100%
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html index b3c9785f1..ba786c30b 100644 --- a/src/ui/templates/scene-chrome.html +++ b/src/ui/templates/scene-chrome.html @@ -11,13 +11,13 @@

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

+

NO PLACE LEFT BEHIND

- ACTIVE STYLE - NORMAL + ACTIVE STYLE + NORMAL
diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 8b5234ef2..9d10f0cef 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -159,18 +159,30 @@ export class VisualEffects { this.requestRender('bloom'); } + /** + * Sharpen runs only when it is on and its slider is above 0, as the style + * stages do: the pass costs the same at any amount (~1.5 ms GPU per frame + * on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0. + */ + syncSharpenEnabled() { + if (this.stopped || !this.sharpenStage) return; + this.sharpenStage.enabled = + this.sharpenEnabled && this.sharpenIntensity > 0.001; + } + applySharpenIntensity(value) { if (this.stopped) return; this.sharpenIntensity = value; if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } setSharpenEnabled(enabled) { if (this.stopped) return; this.sharpenEnabled = !!enabled; - if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 68fa45962..9e9542c52 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled', f.effects.destroy(); }); +test('sharpen at 0 runs no pass, and comes back with the slider', () => { + const f = fixture(); + f.effects.initPostProcess(0.49); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.applySharpenIntensity(0); + assert.equal( + f.effects.sharpenStage.enabled, + false, + 'no residual sharpen at 0', + ); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off'); + f.effects.applySharpenIntensity(0.2); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.destroy(); +}); + test('stop revokes pending work immediately but retains stages until final destruction', () => { const f = fixture(); f.effects.initStyles(); diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index c804ea342..1d5f16a28 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,3 +1,5 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { syncScopeControls } from './displayControls.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; @@ -136,11 +138,10 @@ export class VisualSettings { } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); + const pct = clampScopeFeatherPct(scopeFeatherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1302,11 +1303,14 @@ export class VisualSettings { const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); - this._scopeBtn?.classList.toggle('active', scopeState.enabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + scopeState.enabled, + ); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); + const pct = clampScopeFeatherPct(scopeState.featherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`;