Merge branch 'main' into codex/fix-seek-layer-state

This commit is contained in:
Alex O'Neill authored and GitHub committed 2026-10-26 13:06:49 +00:00
commit 01c35a2c11
102 files changed
+2281 -531

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
## What this changes
<!-- One or two sentences: what changed and why. Link the issue if there is one. -->
## How I verified it
- [ ] `npm run build`
- [ ] `npm test`
- [ ] `npm run test:track` (dev server running)
- [ ] Feature gate for the area I touched: <!-- e.g. `node scripts/qa-radio.mjs`, or "none applies" -->
<!-- Screenshots are welcome for anything visual. -->
## New layer checklist
<!--
Fill this in if the PR adds a new data layer. Delete the whole section if it doesn't.
See "Adding a new layer" in CONTRIBUTING.md.
-->
**Screenshots**
<!-- At least one screenshot of the layer on the globe, plus its info card or panel if it has one. -->
**Recording**
<!--
Preferably 15 to 30 seconds: turn the layer on, move around, open a record.
Drag an .mp4 or .mov in here; GitHub accepts up to 10 MB on free accounts.
If it's bigger, link to it instead.
-->
**Proposed category**
<!--
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
If none fits, propose a new group and say why.
-->
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:
<!-- Bundled data must be data we have the right to redistribute. -->
- [ ] 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/<pr-number>-<slug>.md` fragment added, or this PR doesn't change runtime behaviour
+28 -1
View File
@@ -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: 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 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/<family>/`, 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/<family>/`, 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). - **🎙️ 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/`. - **🎨 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). - **🐛 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/<pr-number>-<slug>.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. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.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. 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). 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 ## Maintainers
+5
View File
@@ -39,6 +39,11 @@ COPY --chown=node:node . .
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4173 ENV PORT=4173
EXPOSE 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 # 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 # 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. # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs.
+6 -2
View File
@@ -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 Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports
cross-platform testing of the fixed installer. The launcher installs the 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. **Tried before and installation failed?** Update Pinokio and try again.
Version 8.2 fixes the launcher installation issue; Version 8.2 fixes the launcher installation issue;
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). [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 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 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 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. 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)). 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)).
</details> </details>
+5
View File
@@ -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. 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 ## Reporting a vulnerability
Please report security issues **privately** — do not open a public issue for anything exploitable. Please report security issues **privately** — do not open a public issue for anything exploitable.
+33 -1
View File
@@ -1,9 +1,29 @@
import { fileURLToPath } from 'node:url';
import { applicationHtmlPlugin } from './application-html.js'; import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium'; import cesium from 'vite-plugin-cesium';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js'; import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js'; import { panelBuildPlugin } from './panel.js';
/**
* The repository root (this file lives at `<root>/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. * Content-Security-Policy for every document the dev/preview server serves.
* No inline script is permitted, and the only foreign script origins are the * 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. // build/allowedHosts.js); IP addresses are always accepted.
allowedHosts: [...allowedHosts], allowedHosts: [...allowedHosts],
fs: { 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 // These headers protect the document containing Provider Settings and
// give the whole page a real Content-Security-Policy (BROWSER_CSP). // give the whole page a real Content-Security-Policy (BROWSER_CSP).
+3
View File
@@ -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)
@@ -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).
@@ -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).
+1
View File
@@ -0,0 +1 @@
- Normalize geocoder bias across the antimeridian (asturksever, #1272).
+3
View File
@@ -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).
@@ -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).
+3
View File
@@ -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).
@@ -0,0 +1,2 @@
- Render awareness-overlay bearings and courses that round through north as
`000°`, rather than `360°` (Rudy Celekli, #1390).
+4
View File
@@ -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).
+2
View File
@@ -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).
@@ -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).
@@ -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).
+2
View File
@@ -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).
@@ -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).
@@ -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).
@@ -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.
+4
View File
@@ -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).
+4 -1
View File
@@ -22,5 +22,8 @@ services:
# UI land in the .env on your host instead of vanishing with the # UI land in the .env on your host instead of vanishing with the
# container. node_modules stays in the image: sharp and friends are # container. node_modules stays in the image: sharp and friends are
# compiled for Linux and must not be shadowed by a host install. # 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 - /app/node_modules
-2
View File
@@ -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/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately |
| `src/data/<layer>.js` | Legacy default instances; new assembly calls layer factories with supplied sources | | `src/data/<layer>.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 | | `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 | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports |
+94 -15
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State # 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 Aircraft detection candidates reuse normalized display text until callsign or
registration changes, including in-place enrichment. Each candidate resolves its registration changes, including in-place enrichment. Each candidate resolves its
fleet model once per collection. Positions, model ownership and tracked-aircraft 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/tools/queries/environment.test.mjs` and
`src/layers/perimeters/cards.test.mjs`. `src/layers/perimeters/cards.test.mjs`.
## Scope display controls
Display's native Scope checkbox defaults ON with feather at 100%, adjustable
from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
and Active Style label with the shared secondary text color. The title plate
covers the eye and title without moving the title or tagline. Scope ON retains
the original HUD presentation; Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
## Cyber HUD — September 30, 2026 ## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -296,6 +323,10 @@ catalog construction owns each instance.
Wind animates one forecast without advancing forecast time. Separate Weather Wind animates one forecast without advancing forecast time. Separate Weather
observation layers provide radar and satellite history; none claims measured cloud volume. 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. 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 Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each
observed product is instead a raised, translucent shell: one rectangle over the 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 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. 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 Snow's sideways sway stays within a fixed range at any session age; Wind scales
its amplitude. 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 ## Display control ownership
@@ -1536,7 +1569,11 @@ camera is refused there with a JSON 404 and is served solely through
## GBFS upstream bounds ## GBFS upstream bounds
Station information is normalized in `src/sources/gbfsStations.js` for both the 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 String arrays regardless of the reported feed version. Nonempty English text
(including regional `en-*` tags, case-insensitive) takes precedence, then the first (including regional `en-*` tags, case-insensitive) takes precedence, then the first
usable translation. An unusable `name` falls back to `short_name` with the same 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 The Street View headings tool uses the same server-first selection after
resolving environment overrides per variable; its explicit `--key` wins. 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 ## Infrastructure marker visibility
Datacenter/dam registration uses fresh reusable factories with the application's 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 ## 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 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 The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry. 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 dropped. A route of exactly 200 is complete, and a roundabout exit after the
200th keeps folding into it. 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 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`. 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 renders. Both routes preserve Radio power, playback, station, filter, and volume
state. 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 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` + 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. - 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. - 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. - 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. - 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. - 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. - 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 deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search 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 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. 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) ### 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`. - `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. - **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. - **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. - 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. - **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. - 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 ## 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) - 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` - A successful Pinokio install writes the owner-only `pinokio/.installed`
marker. The nested launcher menu resolves that marker from its own directory: marker. The nested launcher menu resolves that marker from its own directory:
an absent marker exposes Install, a present marker exposes Start, and a 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` - 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) - 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) - 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, separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D). detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D).
**The 2026-08-24 defaults** (superseding the interim 08-23 values of 8%/3%): **Current defaults** (scope feather revised to 100% on 2026-10-25;
detection and outside opacity retain the 2026-08-24 defaults):
the first-run look is Detection DENSE the first-run look is Detection DENSE
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`, `75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather
and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so `100%`, and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
brackets keep their approved brightness exactly at the new OUTSIDE default. Two brackets keep their approved brightness exactly at the new OUTSIDE default. Two
terms that must never be conflated: scope FEATHER softens the black scope-mask terms that must never be conflated: scope FEATHER softens the black scope-mask
edge; detection FADE is the label/card fading band around the keyhole. The edge; detection FADE is the label/card fading band around the keyhole. The
@@ -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 calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
default, and the mapping follows bracket brightness rather than slider default, and the mapping follows bracket brightness rather than slider
position. The `ko` PARSE fallback stays at `5`. position. The `ko` PARSE fallback stays at `5`.
- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day, - **Scope: `ON`, feather: `100%`**. `SCOPE_FEATHER_RATIO_DEFAULT` in
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`, `src/scopeMask.js` matches the slider, readout and share generator. The range
mirrored by `#scope-feather-slider`'s markup value AND readout and is 0–200%; explicit 0% is a hard edge. Missing-`scf` links retain the historical
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans 35% parse fallback.
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
at `35`: a link predating `scf` was authored when 35 was what its author saw,
and restoring the author's view is what a share link is for.
- **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section - **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section
above and the DISPLAY-rail entry below (was `OFF`). above and the DISPLAY-rail entry below (was `OFF`).
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale). - Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
@@ -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 known, altitude, ground speed, track, vertical rate, position age, message
count, "Heard by your receiver"); markers are not camera-followed. Positioned count, "Heard by your receiver"); markers are not camera-followed. Positioned
aircraft join detection boxes, labelled only with a decoded callsign. 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` Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, (`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, - `scripts/qa-form-fields.mjs` exercises native selections, number validity,
calibration commit/cancel and stale-camera/disposal guards against an installed calibration commit/cancel and stale-camera/disposal guards against an installed
controller with fixture ports, plus default/Cyber and narrow translated layouts. 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.
+2 -3
View File
@@ -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 `gods-eye-view/infrastructure/geojson` exports the lower-level
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay `createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. 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 Consumers use the package exports, which do not import standalone application
factory and supplies its existing functions. Other consumers should use the package globals.
exports, which do not import standalone application globals.
Dataset files and source/license notices remain under `src/data/local_data/`. 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 Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
+3 -1
View File
@@ -22,8 +22,10 @@ module.exports = {
if (starting) { if (starting) {
const local = info.local('start.js'); const local = info.local('start.js');
if (local?.url) { 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 [ 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' }, { icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' },
]; ];
} }
-8
View File
@@ -153,14 +153,6 @@ export function checkImportDirections(root) {
file, file,
'Actions must consume feed state without the manager facade', '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; return record;
} }
+3 -3
View File
@@ -194,6 +194,7 @@
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/visualInput.test.mjs", "src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs", "scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs", "scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js", "src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs", "src/ui/segmentedControl.test.mjs",
@@ -518,7 +519,6 @@
"src/layers/bikeshare/selection.js", "src/layers/bikeshare/selection.js",
"src/data/traffic.js", "src/data/traffic.js",
"src/data/bikeshare.js", "src/data/bikeshare.js",
"src/data/flowTiles.js",
"src/layers/traffic/source.test.mjs", "src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs", "src/layers/bikeshare/source.test.mjs",
"src/layers/bikeshare/proxyRoute.test.mjs", "src/layers/bikeshare/proxyRoute.test.mjs",
@@ -651,7 +651,6 @@
"src/agent/worldPacket.test.mjs", "src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js", "src/voice/layerSummary.js",
"build/application-html.js", "build/application-html.js",
"src/app/sourceSlot.js",
"src/app/sources.js", "src/app/sources.js",
"src/ui/composition.js", "src/ui/composition.js",
"src/services/application.js", "src/services/application.js",
@@ -1089,5 +1088,6 @@
"scripts/qa-status-badges.mjs", "scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs", "src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs", "src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs" "src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs"
] ]
+10 -9
View File
@@ -805,7 +805,8 @@
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/statusBadge.js" "src/ui/statusBadge.js",
"src/scopeMaskPolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1163,7 +1164,6 @@
"satellites-layer": { "satellites-layer": {
"exports": ["./layers/satellites"], "exports": ["./layers/satellites"],
"modules": [ "modules": [
"src/data/issPass.js",
"src/data/satelliteClass.js", "src/data/satelliteClass.js",
"src/data/satellitePass.js", "src/data/satellitePass.js",
"src/sources/tle.js", "src/sources/tle.js",
@@ -1287,6 +1287,7 @@
"src/layers/awareness/navigation.js", "src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js", "src/layers/awareness/panel.js",
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
@@ -1852,14 +1853,12 @@
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/infrastructure.js", "src/data/infrastructure.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateCoordinator.js", "src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json", "src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js", "src/data/lifecycle.js",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/marine.json",
@@ -1944,6 +1943,7 @@
"src/layers/awareness/navigation.js", "src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js", "src/layers/awareness/panel.js",
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
@@ -2575,7 +2575,8 @@
"src/voice/geminiAudio.js", "src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js", "src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/sceneCommandRelease.js" "src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -2670,13 +2671,11 @@
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/infrastructure.js", "src/data/infrastructure.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/satellitePass.js", "src/data/satellitePass.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json", "src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/marine.json",
@@ -2736,6 +2735,7 @@
"src/layers/awareness/navigation.js", "src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js", "src/layers/awareness/panel.js",
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
@@ -3297,7 +3297,9 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/scopeMaskPolicy.js",
"src/ui/displayControls.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3660,7 +3662,6 @@
"exports": ["./layers/transit/standalone"], "exports": ["./layers/transit/standalone"],
"modules": [ "modules": [
"src/cyberSonar.js", "src/cyberSonar.js",
"src/app/sourceSlot.js",
"src/celestialRing.js", "src/celestialRing.js",
"src/data/approxDistance.js", "src/data/approxDistance.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
+681
View File
@@ -0,0 +1,681 @@
#!/usr/bin/env node
/**
* Scope and HUD browser acceptance. Requires the development server.
* Run: node scripts/qa-scope-hud.mjs
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures += 1;
if (detail) console.log(JSON.stringify(detail));
}
};
const readScope = () =>
page.evaluate(() => {
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
const canvas = document.getElementById('scope-mask');
const context = canvas.getContext('2d');
const radialX = Math.round(
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
);
return {
enabled: scope.enabled,
feather: scope.featherPct / 100,
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
radialAlpha: context.getImageData(
radialX,
Math.round(canvas.height / 2),
1,
1,
).data[3],
checked: document.getElementById('scope-toggle').checked,
disabled: document.getElementById('scope-feather-slider').disabled,
value: Number(document.getElementById('scope-feather-slider').value),
readout: document.getElementById('scope-feather-value').textContent,
};
});
const readHudGlyphs = () =>
page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const probe = document.createElement('span');
probe.style.display = 'none';
hud.appendChild(probe);
const baseline = {
color: getComputedStyle(probe).color,
shadow: getComputedStyle(probe).textShadow,
};
probe.remove();
const glyphs = [
'.hud-mode',
'.hud-system',
'.hud-summary-label',
'.hud-summary',
'.hud-orbital',
'#hud-mgrs',
'#hud-coll',
].map((selector) => {
const el = hud.querySelector(selector);
const style = getComputedStyle(el);
let opacity = 1;
for (
let current = el;
current && current !== hud;
current = current.parentElement
)
opacity *= Number(getComputedStyle(current).opacity);
return {
selector,
color: style.color,
shadow: style.textShadow,
opacity,
};
});
return { baseline, glyphs };
});
async function settleTextBackings(enabled) {
// The pre-existing active-style value transitions all properties for 300ms.
// Wait for its backing to finish changing before inspecting or capturing it.
await page
.waitForFunction(
(scopeEnabled) => {
const expected = scopeEnabled
? 'rgba(0, 0, 0, 0)'
: 'rgba(0, 0, 0, 0.7)';
return [
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
].every(
(el) =>
getComputedStyle(el).backgroundColor === expected &&
getComputedStyle(el).boxShadow === 'none',
);
},
{ timeout: 2_000 },
enabled,
)
.catch(() => null);
}
const readSceneChrome = () =>
page.evaluate(() => {
const title = document.querySelector('#title-bar h1');
const eye = title.querySelector('.title-logo');
const subtitle = document.querySelector('#title-bar .subtitle');
const label = document.querySelector('#style-indicator .indicator-label');
const rect = (el, text = false) => {
if (!el.getClientRects().length) return null;
const range = document.createRange();
range.selectNodeContents(el);
const box = text
? range.getBoundingClientRect()
: el.getBoundingClientRect();
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
};
};
const titleBox = rect(title);
const eyeBox = rect(eye);
const backing = getComputedStyle(title, '::before');
const plate = {
left: titleBox.left + parseFloat(backing.left),
top: titleBox.top + parseFloat(backing.top),
right: titleBox.right - parseFloat(backing.right),
bottom: titleBox.bottom - parseFloat(backing.bottom),
};
const contains = (box) =>
Object.entries(box).every(([side, value]) =>
['left', 'top'].includes(side)
? plate[side] <= value + 0.5
: plate[side] >= value - 0.5,
);
const probe = document.createElement('span');
document.body.appendChild(probe);
probe.style.color = 'var(--text-dim)';
const dim = getComputedStyle(probe).color;
probe.style.color = 'var(--text-secondary)';
const secondary = getComputedStyle(probe).color;
probe.remove();
return {
positions: {
title: titleBox,
eye: eyeBox,
text: rect(title.querySelector('[translate="no"]'), true),
subtitle: rect(subtitle, true),
},
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
titleCovered:
backing.position === 'absolute' &&
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
contains(titleBox) &&
contains(eyeBox),
leftAligned:
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
dim,
secondary,
};
});
function checkSceneChrome(name, before, after) {
const stable = Object.keys(before.positions).every((key) => {
const a = before.positions[key];
const b = after.positions[key];
return a && b
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
: a === b;
});
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
before,
after,
});
check(
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
before.titleUnbacked && after.titleCovered && after.leftAligned,
after,
);
check(
`${name}: secondary labels brighten only with Scope OFF`,
before.colors.every((color) => color === before.dim) &&
after.colors.every((color) => color === after.secondary),
{ before: before.colors, after: after.colors },
);
}
async function openApplication(url) {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
// This browser has a fresh profile. Start without authored state so the
// first assertion exercises the defaults rather than a share-link restore.
url.hash = '';
await openApplication(url);
const fresh = await readScope();
check(
'bare-URL fresh boot opens Scope ON with 100% feather',
fresh.enabled &&
fresh.checked &&
!fresh.disabled &&
fresh.feather === 1 &&
fresh.value === 100 &&
fresh.readout === '100%' &&
fresh.cornerAlpha > 0,
fresh,
);
// Use a known city view for canvas and readability checks below.
url.hash =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
// Changing only the hash does not rerun startup restoration. Leave the app
// document first so this fixture is applied by a new application boot.
await page.goto('about:blank');
await openApplication(url);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.hud.setMode('on');
manager.setStyle('normal');
});
const initial = await readScope();
check(
'Austin fixture restores Scope ON and 100% feather',
initial.enabled &&
initial.checked &&
!initial.disabled &&
initial.feather === 1 &&
initial.value === 100 &&
initial.readout === '100%' &&
initial.cornerAlpha > 0,
initial,
);
const name = await page.$eval('#scope-toggle', (el) => ({
type: el.type,
label: el.labels?.[0]?.textContent.trim(),
}));
check(
'Scope uses a native checkbox with a visible label',
name.type === 'checkbox' && name.label?.includes('Scope'),
name,
);
await page.click('#scope-toggle');
const off = await readScope();
check(
'click disables Scope and its feather slider',
!off.enabled &&
!off.checked &&
off.disabled &&
off.feather === 1 &&
off.cornerAlpha === 0,
off,
);
await page.focus('#scope-toggle');
await page.keyboard.press('Space');
const on = await readScope();
check(
'Space re-enables the native checkbox without losing feather',
on.enabled &&
on.checked &&
!on.disabled &&
on.feather === 1 &&
on.cornerAlpha > 0,
on,
);
const paintedAlpha = [];
for (const pct of [0, 11, 100, 200]) {
await page.$eval(
'#scope-feather-slider',
(el, value) => {
el.value = String(value);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
pct,
);
const state = await readScope();
paintedAlpha.push(state.radialAlpha);
check(
`feather ${pct}% reaches the mask and readout`,
state.value === pct &&
state.feather === pct / 100 &&
state.readout === `${pct}%`,
state,
);
}
check(
'painted gradient widens beyond the old 100% ceiling',
paintedAlpha[0] === 0 &&
paintedAlpha[1] === 0 &&
paintedAlpha[2] > 0 &&
paintedAlpha[3] > paintedAlpha[2],
paintedAlpha,
);
await page.focus('#scope-feather-slider');
await page.keyboard.press('Home');
check('native Home reaches the hard edge', (await readScope()).feather === 0);
await page.keyboard.press('End');
check(
'native End reaches the expanded 200% maximum',
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
`Scope OFF and 200% survive ${layout}`,
!state.enabled &&
!state.checked &&
state.disabled &&
state.feather === 2 &&
state.value === 200 &&
state.cornerAlpha === 0,
state,
);
}
const restored = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const links = manager.shareLinkManager;
manager._syncShareState();
const params = links._buildHashParams();
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
await manager._visualSettings.restoreShareState({
scopeEnabled: encoded.enabled === '1',
scopeFeatherPct: Number(encoded.feather),
});
const shared = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: true, featherPct: 200 },
});
const scene = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: false, featherPct: 200 },
});
return { encoded, shared, scene };
});
check(
'share state serializes and restores Scope OFF with 200%',
restored.encoded.enabled === '0' &&
restored.encoded.feather === '200' &&
restored.shared.enabled === false &&
restored.shared.featherPct === 200,
restored,
);
check(
'scene state restores Scope ON with 200%',
restored.scene.enabled === true && restored.scene.featherPct === 200,
restored,
);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true, featherPct: 100 },
}),
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
const originalChrome = await readSceneChrome();
const original = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const wrappers = [...hud.querySelectorAll('.hud-readout')];
const scene = [...document.querySelectorAll('.scene-text-holdout')];
const unexpectedBackings = [...wrappers, ...scene]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id || el.className,
background: style.backgroundColor,
shadow: style.boxShadow,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked: unexpectedBackings.length === 0,
unexpectedBackings,
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
};
});
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent &&
original.operatorFrame,
original,
);
check(
`${layout}: original HUD color, glow and metadata alpha remain intact`,
originalGlyphs.glyphs.every(
(glyph) =>
glyph.color === originalGlyphs.baseline.color &&
glyph.shadow === originalGlyphs.baseline.shadow,
) &&
originalGlyphs.glyphs.find(
(glyph) => glyph.selector === '.hud-summary-label',
).opacity === 0.55 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
.opacity === 0.7 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
.opacity === 0.5,
originalGlyphs,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-on-${layout}.png`,
),
});
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
const backedGlyphs = await readHudGlyphs();
checkSceneChrome(layout, originalChrome, await readSceneChrome());
check(
`${layout}: Scope OFF adds backing without changing glyph styles`,
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
backedGlyphs,
);
const coverage = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
const missing = [];
let visible = 0;
let node;
while ((node = walker.nextNode())) {
if (
!node.textContent.trim() ||
node.parentElement.closest('.hud-bracket')
)
continue;
const range = document.createRange();
range.selectNodeContents(node);
if (
![...range.getClientRects()].some(
(rect) => rect.width > 0 && rect.height > 0,
)
)
continue;
visible += 1;
const backing = node.parentElement.closest('.hud-readout');
const color = backing && getComputedStyle(backing).backgroundColor;
if (
!backing ||
color !== 'rgba(0, 0, 0, 0.7)' ||
getComputedStyle(backing).boxShadow !== 'none' ||
backing.parentElement.closest('.hud-readout')
)
missing.push(node.textContent.trim());
}
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
);
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
const overlaps = [];
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
const siblings = wrapped.filter((el) => el.parentElement === parent);
for (let i = 1; i < siblings.length; i += 1) {
const previous = siblings[i - 1].getBoundingClientRect();
const current = siblings[i].getBoundingClientRect();
const horizontal =
Math.min(previous.right, current.right) -
Math.max(previous.left, current.left);
const vertical =
Math.min(previous.bottom, current.bottom) -
Math.max(previous.top, current.top);
// Horizontal bars share a vertical band. Only intersecting text boxes
// can stack their backing alpha; allow subpixel layout rounding.
if (horizontal > 0.5 && vertical > 0.5) {
overlaps.push({
parent: parent.className,
previous: siblings[i - 1].innerText,
current: siblings[i].innerText,
vertical,
});
}
}
}
const lineIds = [
'hud-mgrs',
'hud-latlon',
'hud-gsd',
'hud-alt',
'hud-timestamp',
'hud-bottom-line',
];
const multiline = lineIds.filter((id) => {
const el = document.getElementById(id);
if (!el.getClientRects().length) return false;
const range = document.createRange();
range.selectNodeContents(el);
return (
range.getBoundingClientRect().height >
parseFloat(getComputedStyle(el).lineHeight) * 1.5
);
});
const edges = [...hud.querySelectorAll('.hud-edge')]
.filter((el) => getComputedStyle(el).display !== 'none')
.map((el) => {
const box = el.querySelector('.hud-readout').getBoundingClientRect();
return (
getComputedStyle(el).transform !== 'none' &&
box.height > box.width &&
Number(getComputedStyle(el).opacity) === 1
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
);
return {
scopeOff: document.documentElement.dataset.scopeMask === 'off',
visible,
missing,
empty,
overlaps,
multiline,
edges,
broadBackings: broadBackings.length,
};
});
check(
`${layout}: all visible HUD text has one backing`,
coverage.scopeOff &&
coverage.visible > 0 &&
coverage.missing.length === 0 &&
coverage.empty === 0 &&
coverage.broadBackings === 0,
coverage,
);
check(
`${layout}: adjacent readout backings do not overlap`,
coverage.overlaps.length === 0,
coverage,
);
check(
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
coverage,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-off-${layout}.png`,
),
});
}
}
const details = await page.evaluate(() => {
const summary = document.getElementById('hud-summary');
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
const ais = document.getElementById('hud-ais-vessel');
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
return {
summaryClipped:
summary.scrollWidth > summary.clientWidth &&
getComputedStyle(summary).textOverflow === 'ellipsis',
aisMultiline:
getComputedStyle(ais).whiteSpace === 'pre-line' &&
ais.textContent.split('\n').length === 2 &&
ais.getBoundingClientRect().height + 0.5 >=
parseFloat(getComputedStyle(ais).lineHeight) * 2,
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
};
});
check(
'summary clipping, multiline AIS and red REC dot retain their behavior',
details.summaryClipped &&
details.aisMultiline &&
details.recColor === 'rgb(255, 51, 51)',
details,
);
await page.setViewport({ width: 390, height: 844 });
await page.select('#hud-layout-select', 'minimal');
const narrow = await page.evaluate(() =>
[...document.querySelectorAll('#intel-hud .hud-readout')]
.filter(
(el) =>
el.getClientRects().length &&
getComputedStyle(el).display !== 'contents',
)
.every((el) => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth;
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true },
}),
);
await settleTextBackings(true);
const before = await readSceneChrome();
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
}
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
@@ -0,0 +1,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,
};
}
+2
View File
@@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js'; import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js'; import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js'; import { apiNotFoundPlugin } from './api-not-found.js';
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
import { standaloneVoiceTools } from './voiceTools.js'; import { standaloneVoiceTools } from './voiceTools.js';
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
@@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => {
plugins: [ plugins: [
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
localMcpPlugin(), localMcpPlugin(),
googleTokenUnavailablePlugin(),
apiNotFoundPlugin(), apiNotFoundPlugin(),
unwatchedRuntimeDirsPlugin(), unwatchedRuntimeDirsPlugin(),
], ],
-2
View File
@@ -1,2 +0,0 @@
// Compatibility entry; source contracts are owned by sources/.
export { createSourceSlot } from '../sources/sourceSlot.js';
-24
View File
@@ -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);
});
+43
View File
@@ -876,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', (
const list = directionsStepList({ route }); const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м'); 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,
);
}
});
-6
View File
@@ -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;
-162
View File
@@ -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();
}
});
-46
View File
@@ -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,
});
}
-42
View File
@@ -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);
});
+12 -1
View File
@@ -2,7 +2,18 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; 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 = { const DC_RAW = {
id: '1176042553', id: '1176042553',
-29
View File
@@ -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,
});
}
+10 -13
View File
@@ -6,13 +6,14 @@ import {
GROUND_SAMPLE_MAX_ARMED_RETRIES, GROUND_SAMPLE_MAX_ARMED_RETRIES,
LOCAL_OVERLAY_COHORT_LIMIT, LOCAL_OVERLAY_COHORT_LIMIT,
LOCAL_STEM_TIP_EPSILON_M, LOCAL_STEM_TIP_EPSILON_M,
createLocalGeoJsonLayer, createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayEntry,
createLocalInfrastructureOverlayPublisher, createLocalInfrastructureOverlayPublisher,
localDatasetError, localDatasetError,
localInfrastructureOverlayCopy, localInfrastructureOverlayCopy,
selectLocalInfrastructureOverlayCohort, selectLocalInfrastructureOverlayCohort,
} from './localGeojson.js'; } from './localGeojsonCore.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import { layerFeedState } from './manager.js'; import { layerFeedState } from './manager.js';
import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js';
import { import {
@@ -21,6 +22,13 @@ import {
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
} from '../renderGovernor.js'; } 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 { class MockLayerEvent {
constructor() { constructor() {
this.listeners = new Set(); 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'); 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();
});
});
+9 -1
View File
@@ -1,9 +1,17 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; 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'; 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({ const dataset = JSON.stringify({
type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
+4 -4
View File
@@ -5,7 +5,7 @@
* Pure selection policy for the bundled local-infrastructure layers * Pure selection policy for the bundled local-infrastructure layers
* (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer`
* dataset). Cesium-specific projection, occlusion, and stem geometry stay in * 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 * bounded "active" set — the only records that layer should spend per-frame
* stem-geometry and ground-sample work on. * 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). * 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; 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 * (callers only compare it), but every input is normalized so the output is
* always a finite number. * 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 {number} distanceM Camera→feature distance in metres.
* @param {boolean} isIncumbent Record currently holds an active stem. * @param {boolean} isIncumbent Record currently holds an active stem.
* @param {object} [options] * @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 * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice
* `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route
* flight, or a continuous orbit never emits one. Without a fallback the * flight, or a continuous orbit never emits one. Without a fallback the
+25 -8
View File
@@ -54,6 +54,21 @@ function direction(modifier) {
return DIRECTION_WORDS[key] || key; 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) { function ordinal(n) {
if (!Number.isInteger(n) || n < 1) return null; if (!Number.isInteger(n) || n < 1) return null;
const suffix = const suffix =
@@ -103,13 +118,9 @@ export function instructionFor(step) {
case 'merge': case 'merge':
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`; return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
case 'on ramp': case 'on ramp':
return dir return directedWay('Take the ramp', modifier, onto);
? `Take the ramp on the ${dir}${onto}`
: `Take the ramp${onto}`;
case 'off ramp': case 'off ramp':
return dir return directedWay('Take the exit', modifier, onto);
? `Take the exit on the ${dir}${onto}`
: `Take the exit${onto}`;
case 'roundabout': case 'roundabout':
case 'rotary': { case 'rotary': {
const exit = ordinal(step?.exit); const exit = ordinal(step?.exit);
@@ -124,8 +135,14 @@ export function instructionFor(step) {
case 'exit roundabout': case 'exit roundabout':
case 'exit rotary': case 'exit rotary':
return `Exit the roundabout${onto}`; return `Exit the roundabout${onto}`;
case 'use lane': case 'use lane': {
return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`; const lane = modifier.includes('left')
? 'left'
: modifier.includes('right')
? 'right'
: '';
return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`;
}
case 'notification': case 'notification':
default: default:
return `Continue${onto}`; return `Continue${onto}`;
+20
View File
@@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () =>
instructionFor({ type: 'on ramp', modifier: 'left' }), instructionFor({ type: 'on ramp', modifier: 'left' }),
'Take the ramp on the 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( assert.equal(
instructionFor({ instructionFor({
type: 'merge', type: 'merge',
+1 -1
View File
@@ -2,7 +2,7 @@
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
* *
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, * 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 * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
* both sides can unit-test against it with node:test. * both sides can unit-test against it with node:test.
* *
+1
View File
@@ -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_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.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.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');
}); });
+27 -26
View File
@@ -282,28 +282,28 @@ export class IntelHUD {
<div class="hud-sonar" aria-hidden="true"></div> <div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar"> <div class="hud-top-bar">
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span> <span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-top-bar-center">${this._missionId}</span> <span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-top-bar-right">PAGE 1/1</span> <span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div> </div>
<div class="hud-corner hud-top-left"> <div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div> <div class="hud-bracket">┌</div>
<div class="hud-content"> <div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div> <div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div> <div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div> <div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
<div class="hud-summary-wrap"> <div class="hud-summary-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div> <div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div> <div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div> </div>
</div> </div>
</div> </div>
<div class="hud-corner hud-top-right"> <div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right"> <div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div> <div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div> <div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div> </div>
<div class="hud-bracket">┐</div> <div class="hud-bracket">┐</div>
</div> </div>
@@ -311,33 +311,33 @@ export class IntelHUD {
<div class="hud-corner hud-bottom-left"> <div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div> <div class="hud-bracket">└</div>
<div class="hud-content"> <div class="hud-content">
<div id="hud-mgrs">MGRS: ---</div> <div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div id="hud-latlon">--°--'--"N ---°--'--"W</div> <div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div> </div>
</div> </div>
<div class="hud-corner hud-bottom-right"> <div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right"> <div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div> <div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div id="hud-alt">ALT: --m SUN: --° EL</div> <div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div> <div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div> </div>
<div class="hud-bracket">┘</div> <div class="hud-bracket">┘</div>
</div> </div>
<div class="hud-edge hud-left-edge"> <div class="hud-edge hud-left-edge">
<div id="hud-coll">COLL: --:--:--Z</div> <div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div id="hud-ona">ONA: --°</div> <div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div> </div>
<div class="hud-edge hud-right-edge"> <div class="hud-edge hud-right-edge">
<div>BAND: PAN</div> <div class="hud-readout"><div>BAND: PAN</div></div>
<div>BITS: 11</div> <div class="hud-readout"><div>BITS: 11</div></div>
<div>LVL: 1A</div> <div class="hud-readout"><div>LVL: 1A</div></div>
</div> </div>
<div class="hud-bottom-bar"> <div class="hud-bottom-bar">
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span> <span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
</div> </div>
`; `;
this._el.dataset.variant = this._variant; this._el.dataset.variant = this._variant;
@@ -574,11 +574,12 @@ export class IntelHUD {
* @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`.
*/ */
_toDMS(decimal, type) { _toDMS(decimal, type) {
const abs = Math.abs(decimal); // Round once, in hundredths of a second, so a value that rounds up to
const deg = Math.floor(abs); // 60.00" carries into the minutes and degrees instead of printing 60.
const minFloat = (abs - deg) * 60; const hundredths = Math.round(Math.abs(decimal) * 360000);
const min = Math.floor(minFloat); const deg = Math.floor(hundredths / 360000);
const sec = ((minFloat - min) * 60).toFixed(2); const min = Math.floor(hundredths / 6000) % 60;
const sec = ((hundredths % 6000) / 100).toFixed(2);
let dir; let dir;
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
+106
View File
@@ -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;
}
});
+4
View File
@@ -1867,11 +1867,15 @@
"maneuver|Merge": "Incorpórate", "maneuver|Merge": "Incorpórate",
"Merge onto {road}": "Incorpórate a {road}", "Merge onto {road}": "Incorpórate a {road}",
"Take the ramp on the {direction}": "Toma la rampa {direction}", "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 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": "Toma la rampa",
"Take the ramp onto {road}": "Toma la rampa hacia {road}", "Take the ramp onto {road}": "Toma la rampa hacia {road}",
"Take the exit on the {direction}": "Toma la salida {direction}", "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 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": "Toma la salida",
"Take the exit onto {road}": "Toma la salida hacia {road}", "Take the exit onto {road}": "Toma la salida hacia {road}",
"At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida", "At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida",
+4
View File
@@ -1899,11 +1899,15 @@
"maneuver|Merge": "Перестройтесь", "maneuver|Merge": "Перестройтесь",
"Merge onto {road}": "Въезжайте на {road}", "Merge onto {road}": "Въезжайте на {road}",
"Take the ramp on the {direction}": "Въезжайте на съезд {direction}", "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 on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
"Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
"Take the ramp": "Въезжайте на съезд", "Take the ramp": "Въезжайте на съезд",
"Take the ramp onto {road}": "Въезжайте по съезду на {road}", "Take the ramp onto {road}": "Въезжайте по съезду на {road}",
"Take the exit on the {direction}": "Сверните на съезд {direction}", "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 on the {direction} onto {road}": "Сверните на съезд {direction} на {road}",
"Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}",
"Take the exit": "Сверните на съезд", "Take the exit": "Сверните на съезд",
"Take the exit onto {road}": "Сверните на съезд на {road}", "Take the exit onto {road}": "Сверните на съезд на {road}",
"At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд", "At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд",
+4
View File
@@ -626,11 +626,15 @@
"Merge onto {road}": "并入 {road}", "Merge onto {road}": "并入 {road}",
"maneuver|Merge": "并道", "maneuver|Merge": "并道",
"Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}", "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 on the {direction}": "{direction}驶入匝道",
"Take the ramp {direction}": "{direction}驶入匝道",
"Take the ramp onto {road}": "驶入匝道,进入 {road}", "Take the ramp onto {road}": "驶入匝道,进入 {road}",
"Take the ramp": "驶入匝道", "Take the ramp": "驶入匝道",
"Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}", "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 on the {direction}": "从出口{direction}驶出",
"Take the exit {direction}": "从出口{direction}驶出",
"Take the exit onto {road}": "从出口驶出,进入 {road}", "Take the exit onto {road}": "从出口驶出,进入 {road}",
"Take the exit": "从出口驶出", "Take the exit": "从出口驶出",
"At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}", "At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}",
+20 -5
View File
@@ -206,12 +206,27 @@ export function photonSearchUrl(
url.searchParams.set('limit', String(limit)); url.searchParams.set('limit', String(limit));
const corners = String(bias ?? '').split('|'); const corners = String(bias ?? '').split('|');
if (corners.length === 2) { const tokens = corners.flatMap((corner) => corner.split(','));
const [swLat, swLng] = corners[0].split(',').map(Number); if (
const [neLat, neLng] = corners[1].split(',').map(Number); corners.length === 2 &&
if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) { 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('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(); return url.toString();
+11
View File
@@ -452,3 +452,14 @@ function readGeocodingConsumer(file) {
? [file, 'annotations/resolver.js'] : [file]; ? [file, 'annotations/resolver.js'] : [file];
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); 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);
}
});
+10
View File
@@ -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');
}
@@ -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), '—');
});
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../../data/militaryAwarenessEngine.js'; } from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
export function createRendering({ export function createRendering({
state: layerState, state: layerState,
@@ -225,12 +226,12 @@ export function createRendering({
: null; : null;
const bearingText = Number.isFinite(bearing) const bearingText = Number.isFinite(bearing)
? t('BRG {value}°', { ? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'), value: formatAwarenessDirection(bearing),
}) })
: t('BRG —'); : t('BRG —');
const courseText = Number.isFinite(item.track) const courseText = Number.isFinite(item.track)
? ` · ${t('CRS {value}°', { ? ` · ${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}`; marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
+19 -5
View File
@@ -308,16 +308,24 @@ export function directionsInstruction(step) {
? t('Merge onto {road}', values) ? t('Merge onto {road}', values)
: t('Merge', { context: 'maneuver' }); : t('Merge', { context: 'maneuver' });
case 'on ramp': case 'on ramp':
if (direction) if (modifier === 'left' || modifier === 'right')
return road return road
? t('Take the ramp on the {direction} onto {road}', values) ? t('Take the ramp on the {direction} onto {road}', values)
: t('Take the ramp on the {direction}', 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'); return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
case 'off ramp': case 'off ramp':
if (direction) if (modifier === 'left' || modifier === 'right')
return road return road
? t('Take the exit on the {direction} onto {road}', values) ? t('Take the exit on the {direction} onto {road}', values)
: t('Take the exit on the {direction}', 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'); return road ? t('Take the exit onto {road}', values) : t('Take the exit');
case 'roundabout': case 'roundabout':
case 'rotary': case 'rotary':
@@ -346,9 +354,14 @@ export function directionsInstruction(step) {
return road return road
? t('Exit the roundabout onto {road}', values) ? t('Exit the roundabout onto {road}', values)
: t('Exit the roundabout'); : t('Exit the roundabout');
case 'use lane': case 'use lane': {
if (direction) { const side = modifier.includes('left')
const lane = { road, direction: laneWord(modifier) }; ? 'left'
: modifier.includes('right')
? 'right'
: '';
if (side) {
const lane = { road, direction: laneWord(side) };
return road return road
? t('Use the {direction} lane onto {road}', lane) ? t('Use the {direction} lane onto {road}', lane)
: t('Use the {direction} lane', lane); : t('Use the {direction} lane', lane);
@@ -356,6 +369,7 @@ export function directionsInstruction(step) {
return road return road
? t('Continue onto {road}', values) ? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' }); : t('Continue', { context: 'maneuver' });
}
default: default:
return road return road
? t('Continue onto {road}', values) ? t('Continue onto {road}', values)
+1 -1
View File
@@ -152,7 +152,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude), altitude: grouped(altitude),
speed: speed === null ? DASH : speed, speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track}°`, track: track === null ? DASH : `${track % 360}°`,
}), }),
vertical === null vertical === null
? t('V/S {rate}', { rate: DASH }) ? t('V/S {rate}', { rate: DASH })
+19
View File
@@ -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°');
});
+1 -1
View File
@@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile';
* tree-shakeable; decoding happens client-side so the proxy stays a dumb * tree-shakeable; decoding happens client-side so the proxy stays a dumb
* binary cache. * binary cache.
* *
* @module data/flowTiles * @module layers/traffic/flowDecode
*/ */
const FLOW_LAYER_NAME = 'Traffic flow'; const FLOW_LAYER_NAME = 'Traffic flow';
+19 -2
View File
@@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
const MAX_MOSAICS = 6; const MAX_MOSAICS = 6;
const MAX_PREFETCH_TILES = 8; 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 /** 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. */ * tile scheduling, projection and texture disposal; the application clock is never touched. */
@@ -126,6 +128,7 @@ function createGlobeRendering({
frame.offRender?.(); frame.offRender?.();
frame.offCamera?.(); frame.offCamera?.();
clearTimeout(frame.timeout); clearTimeout(frame.timeout);
clearTimeout(frame.wake);
for (const request of frame.requests) request.cancel?.(); for (const request of frame.requests) request.cancel?.();
frame.requests.clear(); frame.requests.clear();
frame.retries.clear(); frame.retries.clear();
@@ -277,6 +280,8 @@ function createGlobeRendering({
frame.offCamera?.(); frame.offCamera?.();
frame.offCamera = null; frame.offCamera = null;
clearTimeout(frame.timeout); clearTimeout(frame.timeout);
clearTimeout(frame.wake);
frame.wake = null;
frame.offAbort?.(); frame.offAbort?.();
if (ok) { if (ok) {
lastError = null; lastError = null;
@@ -450,8 +455,20 @@ function createGlobeRendering({
if (++settled >= 2) finish(true); if (++settled >= 2) finish(true);
else viewer.scene.requestRender(); else viewer.scene.requestRender();
} else settled = 0; } else settled = 0;
if (incoming === frame && frame.pending === 0 && frame.loaded > 0) // Waiting out the quiet interval needs another readiness check
viewer.scene.requestRender(); // 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(); viewer.scene.requestRender();
onChange(); onChange();
+45 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 { createWeatherLayer } from './index.js';
import { createWeatherClock } from './clock.js'; import { createWeatherClock } from './clock.js';
import * as Cesium from 'cesium'; 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(); 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 () => { test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => {
let now = 0; let now = 0;
const h = renderingHarness({ now: () => now }); const h = renderingHarness({ now: () => now });
+1
View File
@@ -38,6 +38,7 @@ export function createGoogleTokenSource({
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
cache: 'no-store', cache: 'no-store',
}); });
if (response.status === 204) return null;
if (!response.ok) { if (!response.ok) {
await response.body?.cancel(); await response.body?.cancel();
return null; return null;
+9
View File
@@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => {
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); 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]);
});
+3 -1
View File
@@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) {
const neLat = nominatimLatitude(match[3]); const neLat = nominatimLatitude(match[3]);
const neLng = nominatimLongitude(match[4]); const neLng = nominatimLongitude(match[4]);
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null; 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}`; return `${swLng},${neLat},${neLng},${swLat}`;
} }
+9
View File
@@ -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`, `${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',
);
});
+1 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; 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 { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js';
import { vesselOverlayCohortLimit } from '../data/vesselLabels.js'; import { vesselOverlayCohortLimit } from '../data/vesselLabels.js';
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
+3
View File
@@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle
runtime.url = url; runtime.url = url;
const items = await menu(kernel, info); const items = await menu(kernel, info);
assert.equal(items.filter((item) => item.default).length, 1); 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 })); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault }));
}; };
+31 -18
View File
@@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js';
// actually decides fresh-boot layer state — including the early return that // actually decides fresh-boot layer state — including the early return that
// makes each layer's own initializer the operative default. // makes each layer's own initializer the operative default.
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on // 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11% // 2026-08-22; later revisions were 8%, then 11%, then 100% on
// on 2026-08-24, a soft // 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
// edge. The hard crop is still one drag away, and that is pinned too.
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included. // 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
// Owner: "detect should also be on by default." // Owner: "detect should also be on by default."
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with // 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
@@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js';
import { import {
SCOPE_FEATHER_RATIO_DEFAULT, SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather, getScopeMaskFeather,
isScopeMaskEnabled,
scopeMaskGeometry, scopeMaskGeometry,
setScopeMaskFeather, setScopeMaskFeather,
} from './scopeMask.js'; } from './scopeMask.js';
@@ -75,32 +75,40 @@ function managerForHash(hash) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. Scope feather — a subtle soft edge on a first run // 2. Scope ON with 100% feather on a first run
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('first run opens with a subtle scope feather, at every surface that decides it', () => { test('first run opens with Scope ON and 100% feather at every default surface', () => {
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11, assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings'); assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
assert.equal(getScopeMaskFeather(), 0.11, assert.equal(getScopeMaskFeather(), 1,
'and the live module starts there, not merely documents it'); 'and the live module starts there, not merely documents it');
// The slider and its readout are the same default rendered as markup — a // The slider and its readout are the same default rendered as markup — a
// fresh boot applies no restore, so a stale value here would show one number // fresh boot applies no restore, so a stale value here would show one number
// over a mask drawn at another. // over a mask drawn at another.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/, assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
'index.html: the feather slider ships at 11'); 'index.html: the feather slider ships at 100');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</, assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
'index.html: and its readout agrees with the handle'); 'index.html: and its readout agrees with the handle');
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
'the native Scope checkbox starts checked');
// The link this session generates must describe the mask this session draws, // The link this session generates must describe the mask this session draws,
// for the window before the first _syncShareState. // for the window before the first _syncShareState.
assert.match(shareSource, /this\._scopeFeatherPct = 11;/, assert.match(shareSource, /this\._scopeFeatherPct = 100;/,
'sharelink.js: the generator starts from the same value the mask starts at'); 'sharelink.js: the generator starts from the same value the mask starts at');
const params = managerForHash('')._buildHashParams();
assert.equal(params.get('sc'), '1');
assert.equal(params.get('scf'), '100');
}); });
test('an explicit feather still wins over the subtle default', () => { test('an explicit feather still wins over the 100% default', () => {
// A link is authored state. The new default governs a session that said // A link is authored state. The new default governs a session that said
// nothing; it must never overwrite one that said something. // nothing; it must never overwrite one that said something.
assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11,
'links carrying the previous default preserve their authored feather');
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35); assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64); assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0, assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
@@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => {
'a pre-scf link restores the author\'s view, not the new default'); 'a pre-scf link restores the author\'s view, not the new default');
}); });
test('the subtle default did not weaken the feather control, and 0 is still reachable', () => { test('share links preserve explicit Scope OFF and feather choices over the defaults', () => {
const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash();
assert.equal(shared.scopeEnabled, false);
assert.equal(shared.scopeFeatherPct, 11);
});
test('the 100% default retains the expanded feather range and hard edge', () => {
// The cheap way to move a default would be to nerf the control. Prove the // The cheap way to move a default would be to nerf the control. Prove the
// slider still spans its full range and the geometry is still DERIVED from // slider still spans its full range and the geometry is still DERIVED from
// the ratio — a check that would pass vacuously if it only ever saw one value. // the ratio — a check that would pass vacuously if it only ever saw one value.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/, assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
'the slider still offers the whole range'); 'the slider still offers the whole range');
const previous = getScopeMaskFeather(); const previous = getScopeMaskFeather();
try { try {
for (const ratio of [0.35, 0.7, 1]) { for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
setScopeMaskFeather(ratio); setScopeMaskFeather(ratio);
const geo = scopeMaskGeometry(1200, 900); const geo = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9, assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
`feather ${ratio} must still widen the band to that fraction of the keyhole`); `feather ${ratio} must still widen the band to that fraction of the keyhole`);
} }
// The new default is a real, narrow band — not the hard crop, and nowhere // The first-run value draws a band derived from the same keyhole radius.
// near the retired 35 % halo.
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
const soft = scopeMaskGeometry(1200, 900); const soft = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
+25 -21
View File
@@ -1,4 +1,5 @@
import { getKeyholeGeometry } from './celestialRing.js'; import { getKeyholeGeometry } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
/** /**
* Scope mask — the app's signature circular viewport treatment, made real. * Scope mask — the app's signature circular viewport treatment, made real.
@@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js';
/** Matches the page background the emergent scope faded into. */ /** Matches the page background the emergent scope faded into. */
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 }; const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
/** /**
* Default edge feather as a fraction of the keyhole radius. * First-run feather ratio; the Display slider and share defaults use 100%.
* * The historical missing-scf parse fallback remains 35%.
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
* set it to zero: a subtle soft edge rather than either the hard crop or the
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
* share link carries, or the operator dials in, overrides this immediately, and
* the hard-crop path at 0 is still reachable from the handle.
*
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
* fallback, which stays at 35 on purpose: a link predating that field was
* authored when 35 was what its author saw, and a link from the feather-0 era
* carries `scf=0` explicitly because the generator always writes the field.
* Pinned in reasonableDefaults.test.mjs.
*/ */
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11; export const SCOPE_FEATHER_RATIO_DEFAULT = 1;
/** /**
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so * Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
* faint stars survive in the corners at globe scale. * faint stars survive in the corners at globe scale.
@@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() {
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) { export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
const keyhole = getKeyholeGeometry(width, height); const keyhole = getKeyholeGeometry(width, height);
if (!(keyhole.radius > 0)) return null; if (!(keyhole.radius > 0)) return null;
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0)); const feather = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
);
// Feather straddles the keyhole edge so the visible radius stays anchored // Feather straddles the keyhole edge so the visible radius stays anchored
// to the shared geometry every keyhole consumer (labels, cards) fades on. // to the shared geometry every keyhole consumer (labels, cards) fades on.
const half = keyhole.radius * feather * 0.5; const half = keyhole.radius * feather * 0.5;
@@ -423,6 +412,14 @@ function draw() {
_painted = true; _painted = true;
} }
/** Expose only the OFF state; an absent attribute preserves the original HUD. */
function syncScopeMaskAppearance(enabled = _enabled) {
const dataset = globalThis.document?.documentElement?.dataset;
if (!dataset) return;
if (enabled) delete dataset.scopeMask;
else dataset.scopeMask = 'off';
}
/** /**
* Install the scope mask into the viewer container. Idempotent. * Install the scope mask into the viewer container. Idempotent.
* @param {Cesium.Viewer} viewer * @param {Cesium.Viewer} viewer
@@ -430,6 +427,7 @@ function draw() {
*/ */
export function installScopeMask(viewer) { export function installScopeMask(viewer) {
if (_canvas || !viewer?.container) return; if (_canvas || !viewer?.container) return;
syncScopeMaskAppearance();
_container = viewer.container; _container = viewer.container;
_viewer = viewer; _viewer = viewer;
_canvas = document.createElement('canvas'); _canvas = document.createElement('canvas');
@@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) {
const next = Boolean(enabled); const next = Boolean(enabled);
const reEnabled = next && !_enabled; const reEnabled = next && !_enabled;
_enabled = next; _enabled = next;
syncScopeMaskAppearance();
if (reEnabled) { if (reEnabled) {
// The camera moved freely while the scope was off and nothing sampled it, // The camera moved freely while the scope was off and nothing sampled it,
// so the painted terminus can be a whole altitude band stale. Re-sync once // so the painted terminus can be a whole altitude band stale. Re-sync once
@@ -522,11 +521,14 @@ export function isScopeMaskEnabled() {
} }
/** /**
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1]. * @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
* @returns {void} * @returns {void}
*/ */
export function setScopeMaskFeather(ratio) { export function setScopeMaskFeather(ratio) {
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0)); _featherRatio = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
);
draw(); draw();
} }
@@ -540,6 +542,7 @@ export function getScopeMaskFeather() {
* @returns {void} * @returns {void}
*/ */
export function destroyScopeMask() { export function destroyScopeMask() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect(); _resizeObserver?.disconnect();
_resizeObserver = null; _resizeObserver = null;
teardownDevicePixelRatioWatch(); teardownDevicePixelRatioWatch();
@@ -553,6 +556,7 @@ export function destroyScopeMask() {
/** Test seam. */ /** Test seam. */
export function _resetScopeMaskForTest() { export function _resetScopeMaskForTest() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect(); _resizeObserver?.disconnect();
_resizeObserver = null; _resizeObserver = null;
teardownDevicePixelRatioWatch(); teardownDevicePixelRatioWatch();
+50 -5
View File
@@ -18,6 +18,7 @@ import {
SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MIN_PCT,
SCOPE_TERMINUS_MAX_PCT, SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT, SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
SCOPE_OUTSIDE_ALPHA, SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M, SCOPE_TERMINUS_FAR_M,
@@ -26,6 +27,7 @@ import {
_resetScopeMaskForTest, _resetScopeMaskForTest,
} from './scopeMask.js'; } from './scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js';
beforeEach(() => _resetScopeMaskForTest()); beforeEach(() => _resetScopeMaskForTest());
@@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
assert.equal(geo.outerR, keyholeR); assert.equal(geo.outerR, keyholeR);
}); });
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => { test('feather ratio reaches the center at 200% and clamps larger input', () => {
const wide = scopeMaskGeometry(1000, 800, 7); const wide = scopeMaskGeometry(1000, 800, 7);
const one = scopeMaskGeometry(1000, 800, 1); const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
assert.equal(wide.innerR, one.innerR); const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.equal(wide.outerR, one.outerR); assert.equal(maximum.innerR, 0);
assert.equal(maximum.outerR, keyholeR * 2);
assert.equal(wide.innerR, maximum.innerR);
assert.equal(wide.outerR, maximum.outerR);
for (const ratio of [1, 1.5, 2]) {
setScopeMaskFeather(ratio);
assert.equal(getScopeMaskFeather(), ratio);
const geometry = scopeMaskGeometry(1000, 800);
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
}
setScopeMaskFeather(7);
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
const nan = scopeMaskGeometry(1000, 800, Number.NaN); const nan = scopeMaskGeometry(1000, 800, Number.NaN);
const zero = scopeMaskGeometry(1000, 800, 0); const zero = scopeMaskGeometry(1000, 800, 0);
assert.equal(nan.innerR, zero.innerR); assert.equal(nan.innerR, zero.innerR);
}); });
test('UI and share feather percentages share one bounded integer range', () => {
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
assert.equal(clampScopeFeatherPct(value), expected);
}
});
test('degenerate viewport yields null instead of a broken gradient', () => { test('degenerate viewport yields null instead of a broken gradient', () => {
assert.equal(scopeMaskGeometry(0, 0), null); assert.equal(scopeMaskGeometry(0, 0), null);
assert.equal(scopeMaskGeometry(-5, 100), null); assert.equal(scopeMaskGeometry(-5, 100), null);
@@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
removeEventListener: (type, fn) => listeners.delete(fn), removeEventListener: (type, fn) => listeners.delete(fn),
}), }),
}; };
globalThis.document = { createElement: () => canvas }; const documentElement = { dataset: {} };
globalThis.document = { documentElement, createElement: () => canvas };
globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.ResizeObserver = class { observe() {} disconnect() {} };
const container = { clientWidth: width, clientHeight: height, appendChild() {} }; const container = { clientWidth: width, clientHeight: height, appendChild() {} };
return { return {
canvas, canvas,
container, container,
documentElement,
/** Canvas work counters: backing-store resizes, clears, fills. */ /** Canvas work counters: backing-store resizes, clears, fills. */
ops, ops,
/** Every fillStyle string assigned since install (hard-crop path). */ /** Every fillStyle string assigned since install (hard-crop path). */
@@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
}; };
} }
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
const dom = stubScopeMaskDom();
try {
setScopeMaskEnabled(false);
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
delete dom.documentElement.dataset.scopeMask;
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
destroyScopeMask();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
setScopeMaskEnabled(true);
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
setScopeMaskEnabled(false);
_resetScopeMaskForTest();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
} finally {
destroyScopeMask();
dom.restore();
}
});
test('a DPR change with no resize still repaints the backing store', () => { test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try { try {
+11
View File
@@ -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)),
);
}
+12 -2
View File
@@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) {
} }
const now = Date.now(); const now = Date.now();
const entry = entries.get(icao); 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 // 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. // 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; windowStartMs = now;
windowCount = 0; windowCount = 0;
} }
+62
View File
@@ -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);
});
+18 -2
View File
@@ -99,6 +99,22 @@ test('Nepal locator token is valid in v2 share links', () => {
assert.equal(parsed.layerStateInvalid, false); assert.equal(parsed.layerStateInvalid, false);
}); });
test('scope feather above 100% survives share parsing and serialization', () => {
const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200');
const parsed = manager.parseInitialHash();
assert.equal(parsed.scopeEnabled, false);
assert.equal(parsed.scopeFeatherPct, 200);
assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175);
assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200);
manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 });
const params = manager._buildHashParams();
assert.equal(params.get('sc'), '0');
assert.equal(params.get('scf'), '200');
manager.onToggleChange(false, true, { scopeFeatherPct: 999 });
assert.equal(manager._buildHashParams().get('scf'), '200');
clearTimeout(manager._debounceTimer);
});
test('share-link serialization emits the current celestial state', () => { test('share-link serialization emits the current celestial state', () => {
const manager = makeManager(); const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false }); manager.onToggleChange(false, false, { celestialRingEnabled: false });
@@ -498,8 +514,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'], ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], ['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], ['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
+4 -12
View File
@@ -5,6 +5,7 @@ import {
normalizeAllocationStrategy, normalizeAllocationStrategy,
} from './data/detectionPolicy.js'; } from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js'; import { clampScopeTerminusPct } from './scopeMask.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import { import {
decodeLayerStateParams, decodeLayerStateParams,
encodeLayerStateParams, encodeLayerStateParams,
@@ -112,10 +113,7 @@ export class ShareLinkManager {
this._detectionOutsideOpacityPct = 1; this._detectionOutsideOpacityPct = 1;
this._celestialRingEnabled = false; this._celestialRingEnabled = false;
this._scopeEnabled = true; this._scopeEnabled = true;
// Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24, this._scopeFeatherPct = 100;
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value.
this._scopeFeatherPct = 11;
// null = the altitude-adaptive terminus (the default). A number pins the // null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17) // outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null; this._scopeTerminusPct = null;
@@ -227,10 +225,7 @@ export class ShareLinkManager {
// (`_scopeFeatherPct` in the constructor tracks the default: that one // (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match // mirrors live state for the link this session generates, so it must match
// the mask, not the archive.) // the mask, not the archive.)
scopeFeatherPct: Math.max( scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
0,
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior; // Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED // a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in // 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -494,10 +489,7 @@ export class ShareLinkManager {
if (typeof extras.scopeEnabled === 'boolean') if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled; this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') { if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = Math.max( this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
0,
Math.min(100, Math.round(extras.scopeFeatherPct)),
);
} }
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') { else if (typeof extras.scopeTerminusPct === 'number') {
+17 -3
View File
@@ -98,6 +98,13 @@ function dockCapacity(raw) {
return capacity - geofenced; 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. * Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers * Handles field-name variations across different GBFS providers
@@ -113,9 +120,16 @@ export function parseStationInformation(payload) {
for (const raw of stations) { for (const raw of stations) {
// Accept both GBFS (station_id) and alternate (id) field names // Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude); const lat = coordinateNumber(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude); const lon = coordinateNumber(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; if (
!stationId ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
stationMap.set(stationId, { stationMap.set(stationId, {
stationId, stationId,
+67
View File
@@ -19,6 +19,73 @@ const parseCapacity = (fields) =>
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
.capacity; .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', () => { test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
@@ -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');
},
);
});
+25 -5
View File
@@ -1,3 +1,4 @@
import { fileURLToPath } from 'node:url';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -5,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises';
import { request } from 'node:http'; import { request } from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { build, createServer, preview } from 'vite'; 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 standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js'; import * as providers from '../../server/providers/local.js';
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.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.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
assert.ok(config.server.fs.deny.includes('.env.*')); 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-Frame-Options'], 'DENY');
assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff');
const csp = config.server.headers['Content-Security-Policy']; 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); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
const standaloneProviders = config.plugins const standaloneProviders = config.plugins
.slice(4, -5) .slice(4, -6)
.map((plugin) => plugin.name); .map((plugin) => plugin.name);
assert.equal( assert.equal(
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, 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'), standaloneProviders.filter((name) => name !== 'gemini-live-proxy'),
providers.localProviderPlugins().map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name),
); );
assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); assert.equal(config.plugins.at(-7).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback. // Token fallback follows real providers and local MCP before the API 404.
assert.equal(config.plugins.at(-5).name, 'local-mcp'); 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'); assert.equal(config.plugins.at(-4).name, 'api-not-found');
// Config-only, so it sits after the provider slice rather than in it. // Config-only, so it sits after the provider slice rather than in it.
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs'); assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
+19 -2
View File
@@ -5,6 +5,24 @@ import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.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({ export const getEarthquakes = defineTool({
name: 'get_earthquakes', name: 'get_earthquakes',
title: 'Recent earthquakes', title: 'Recent earthquakes',
@@ -114,8 +132,7 @@ export const getActiveFires = defineTool({
lon: fire.lon, lon: fire.lon,
frp_mw: fire.frp ?? null, frp_mw: fire.frp ?? null,
confidence: fire.confidence ?? null, confidence: fire.confidence ?? null,
acquired: acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)),
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
satellite: fire.satellite ?? null, satellite: fire.satellite ?? null,
day_night: fire.daynight ?? null, day_night: fire.daynight ?? null,
})), })),
+46
View File
@@ -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);
}
});
+40
View File
@@ -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 = { const feed = {
id: 'capmetro', id: 'capmetro',
name: 'CapMetro', name: 'CapMetro',
+1 -1
View File
@@ -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), result.data.rows.map((row) => row.frp_mw),
[40, 12], [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({ const keyless = composeCatalog({
tools: coreTools, tools: coreTools,
-1
View File
@@ -70,7 +70,6 @@ export class AircraftDisplay {
// a screen reader without this. It matters more now that the button ships // a screen reader without this. It matters more now that the button ships
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive // ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
// tech reported was an unpressed-looking control over an armed layer. // tech reported was an unpressed-looking control over an armed layer.
// Mirrors #scope-toggle, which has always carried aria-pressed.
this._models3dBtn?.setAttribute( this._models3dBtn?.setAttribute(
'aria-pressed', 'aria-pressed',
String(this._models3dEnabled), String(this._models3dEnabled),
+30
View File
@@ -1,3 +1,4 @@
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import test from 'node:test'; import test from 'node:test';
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(voice.owner.activeStyle, 'snow'); assert.equal(voice.owner.activeStyle, 'snow');
}); });
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
const { owner } = styleOwner(t, 'tactical');
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
owner._scopeBtn = { checked: true };
owner._scopeFeatherSlider = { value: '11', disabled: false };
owner._scopeFeatherValue = {};
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
assert.equal(owner._scopeBtn.checked, false);
assert.equal(owner._scopeFeatherSlider.disabled, true);
assert.equal(owner._scopeFeatherSlider.value, '200');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
owner._setHudVariant(layout, { applyVisualDefaults: true });
assert.equal(isScopeMaskEnabled(), false, layout);
assert.equal(getScopeMaskFeather(), 2, layout);
assert.equal(owner._scopeBtn.checked, false, layout);
}
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
assert.equal(isScopeMaskEnabled(), true);
assert.equal(getScopeMaskFeather(), 1.75);
assert.equal(owner._scopeBtn.checked, true);
assert.equal(owner._scopeFeatherSlider.disabled, false);
assert.equal(owner._scopeFeatherValue.textContent, '175%');
await owner.applyVisualState({ scope: { featherPct: 500 } });
assert.equal(getScopeMaskFeather(), 2);
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => { test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical'); const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir'); owner.setStyle('noir');
+12 -8
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { createFrameRateMonitor } from './frameRateMonitor.js'; import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js'; import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js'; import { bindDisplayControls, syncScopeControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.js'; import { canonicalizeDensity } from '../data/detectionPolicy.js';
/** Own keyboard/display event subscriptions; settings remain with their state owners. */ /** Own keyboard/display event subscriptions; settings remain with their state owners. */
@@ -92,6 +93,11 @@ export class DisplayBindings {
}, },
}); });
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
isScopeMaskEnabled(),
);
this._displayControls?.destroy(); this._displayControls?.destroy();
this._displayControls = bindDisplayControls({ this._displayControls = bindDisplayControls({
elements: { elements: {
@@ -100,7 +106,7 @@ export class DisplayBindings {
bloomSlider: this._bloomSlider, bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn, sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider, sharpenSlider: this._sharpenSlider,
scopeButton: this._scopeBtn, scopeToggle: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider, scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect, hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn, hudButton: this._hudBtn,
@@ -134,17 +140,15 @@ export class DisplayBindings {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenEnabled(!this.sharpenEnabled); this._setSharpenEnabled(!this.sharpenEnabled);
}, },
toggleScope: () => { setScopeEnabled: (enabled) => {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
const next = !isScopeMaskEnabled(); setScopeMaskEnabled(enabled);
setScopeMaskEnabled(next); syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
this._scopeBtn.classList.toggle('active', next);
this._scopeBtn.setAttribute('aria-pressed', String(next));
this._syncShareState(); this._syncShareState();
}, },
setScopeFeather: (value) => { setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = Math.max(0, Math.min(100, value || 0)); const pct = clampScopeFeatherPct(value);
if (this._scopeFeatherValue) if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`; this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100); setScopeMaskFeather(pct / 100);
+7 -1
View File
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
for (const [name, action] of [ for (const [name, action] of [
['bloomButton', 'toggleBloom'], ['bloomButton', 'toggleBloom'],
['sharpenButton', 'toggleSharpen'], ['sharpenButton', 'toggleSharpen'],
['scopeButton', 'toggleScope'],
['cleanViewButton', 'toggleCleanView'], ['cleanViewButton', 'toggleCleanView'],
['cleanViewExitButton', 'exitCleanView'], ['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'], ['celestialButton', 'toggleCelestial'],
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
['scopeFeatherSlider', 'setScopeFeather'], ['scopeFeatherSlider', 'setScopeFeather'],
]) ])
listen(elements[name], 'input', action, integer); listen(elements[name], 'input', action, integer);
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [ for (const [name, action] of [
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
}, },
}; };
} }
/** Keep the native Scope checkbox and feather availability in step with the mask. */
export function syncScopeControls(toggle, featherSlider, enabled) {
if (toggle) toggle.checked = enabled;
if (featherSlider) featherSlider.disabled = !enabled;
}
+26
View File
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
assert.deepEqual(calls, ['b']); assert.deepEqual(calls, ['b']);
two.destroy(); two.destroy();
}); });
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
const scopeToggle = element();
scopeToggle.checked = true;
const scopeFeatherSlider = element('200');
const calls = [];
const controls = bindDisplayControls({
elements: { scopeToggle, scopeFeatherSlider },
actions: {
setScopeEnabled: (value) => calls.push(['enabled', value]),
setScopeFeather: (value) => calls.push(['feather', value]),
},
});
scopeToggle.checked = false;
scopeToggle.dispatchEvent(new Event('change'));
scopeToggle.checked = true;
scopeToggle.dispatchEvent(new Event('change'));
scopeFeatherSlider.dispatchEvent(new Event('input'));
controls.destroy();
scopeToggle.dispatchEvent(new Event('change'));
assert.deepEqual(calls, [
['enabled', false],
['enabled', true],
['feather', 200],
]);
});
+1 -1
View File
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
if (!host || !frameEvent) return { destroy() {} }; if (!host || !frameEvent) return { destroy() {} };
const readout = documentRef.createElement('div'); const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout'; readout.className = 'frame-rate-readout scene-text-holdout';
readout.hidden = true; readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language // i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —'; readout.textContent = 'FPS —';
+6
View File
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
bottom: calc(2vh + 5.4rem); 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 { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem); bottom: calc(2vh + 9rem);
} }
+21
View File
@@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
color: var(--accent); 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) ───────── */ /* ── Bloom Slider (inline with toggle) ───────── */
.pp-toggle-group { .pp-toggle-group {
display: flex; display: flex;
+131
View File
@@ -371,3 +371,134 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap { #intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px; 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;
}
+5 -9
View File
@@ -106,19 +106,15 @@
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title> <label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span> <span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</span> <span class="pp-label" data-i18n>Scope</span>
</button> <input type="checkbox" id="scope-toggle" checked />
</label>
<div class="pp-slider-row visible" id="scope-slider-row"> <div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span> <span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock <input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings); <span class="pp-slider-value" id="scope-feather-value">100%</span>
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
+3 -3
View File
@@ -11,13 +11,13 @@
<div id="title-bar"> <div id="title-bar">
<div class="title-glow"></div> <div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1> <h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p> <p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
</div> </div>
<!-- Style indicator --> <!-- Style indicator -->
<div id="style-indicator"> <div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span> <span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span> <span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
</div> </div>
<!-- Top-center camera and share actions --> <!-- Top-center camera and share actions -->
+13 -1
View File
@@ -159,18 +159,30 @@ export class VisualEffects {
this.requestRender('bloom'); 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) { applySharpenIntensity(value) {
if (this.stopped) return; if (this.stopped) return;
this.sharpenIntensity = value; this.sharpenIntensity = value;
if (this.sharpenStage) if (this.sharpenStage)
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
this.syncSharpenEnabled();
this.requestRender('sharpen'); this.requestRender('sharpen');
} }
setSharpenEnabled(enabled) { setSharpenEnabled(enabled) {
if (this.stopped) return; if (this.stopped) return;
this.sharpenEnabled = !!enabled; this.sharpenEnabled = !!enabled;
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; this.syncSharpenEnabled();
this.requestRender('sharpen'); this.requestRender('sharpen');
} }
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more