mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
122
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379c2aaa3c | ||
|
|
40b471f1e3 | ||
|
|
cfbdf22121 | ||
|
|
d8c264b501 | ||
|
|
c3e782ef73 | ||
|
|
2a3e0d669f | ||
|
|
9b52e83fd7 | ||
|
|
8886e063e6 | ||
|
|
6642176de5 | ||
|
|
3de745bd33 | ||
|
|
9aeeb37ccf | ||
|
|
5ae18032fc | ||
|
|
224d71753b | ||
|
|
315c0f5dc6 | ||
|
|
57df5f61ba | ||
|
|
ac8dc84f3c | ||
|
|
69fba2ebbe | ||
|
|
405c7f0d20 | ||
|
|
e6e8bb0e95 | ||
|
|
db74129eb0 | ||
|
|
8f02541657 | ||
|
|
1244ec1332 | ||
|
|
6ebdee6710 | ||
|
|
1031569f47 | ||
|
|
769ecbfe98 | ||
|
|
5ef12391e1 | ||
|
|
99429904d4 | ||
|
|
496d024431 | ||
|
|
5b6c52437e | ||
|
|
ba783b4eea | ||
|
|
81af549ad7 | ||
|
|
3cd795527c | ||
|
|
3f9b44ecc8 | ||
|
|
48e649a95e | ||
|
|
455dbf2b5f | ||
|
|
4d64ac48bc | ||
|
|
c30d2d3473 | ||
|
|
d8d3ac85b6 | ||
|
|
d6ae581652 | ||
|
|
5c8d371552 | ||
|
|
be869f9160 | ||
|
|
9d07e67cf8 | ||
|
|
6c225605c4 | ||
|
|
b7e00f18a4 | ||
|
|
eb8b63fc62 | ||
|
|
6bbf9b94c4 | ||
|
|
f6e7f63a63 | ||
|
|
ad79e57028 | ||
|
|
6fee8382ce | ||
|
|
91144e88ad | ||
|
|
1a72da81b5 | ||
|
|
dae03f9a11 | ||
|
|
abd81155f9 | ||
|
|
75eaa56c15 | ||
|
|
b07352ea6a | ||
|
|
d53d68b7cf | ||
|
|
e7cf071a1c | ||
|
|
45a093fb42 | ||
|
|
7cdaa6044b | ||
|
|
5b8e6fbc38 | ||
|
|
8dec9eaa10 | ||
|
|
5d21beed9a | ||
|
|
86d52a0f7a | ||
|
|
29db102f59 | ||
|
|
134505f716 | ||
|
|
04b6a99a30 | ||
|
|
fae7703b4f | ||
|
|
6b458c897d | ||
|
|
ad3f26be2d | ||
|
|
979fd652cf | ||
|
|
27cfbe5562 | ||
|
|
f490404b7f | ||
|
|
a888e0fa22 | ||
|
|
77e233600a | ||
|
|
f2ee3c984f | ||
|
|
19de386bcc | ||
|
|
5d23a874b1 | ||
|
|
b616188cbf | ||
|
|
b304fd4a34 | ||
|
|
6d47525e4e | ||
|
|
5673831619 | ||
|
|
6b85990fec | ||
|
|
80cd3476bf | ||
|
|
023be3c189 | ||
|
|
5754e8f046 | ||
|
|
7a8ba29186 | ||
|
|
a2a3aba1d2 | ||
|
|
2fc9ca8516 | ||
|
|
19ed82af07 | ||
|
|
88803f16e8 | ||
|
|
77b485d619 | ||
|
|
4ba0a1b57f | ||
|
|
2a48e1d186 | ||
|
|
aa33bb5ec3 | ||
|
|
aa9a7d41bf | ||
|
|
cf4e8e1d87 | ||
|
|
89a6f8c96f | ||
|
|
a9eda1d9e7 | ||
|
|
9f8ed74e7b | ||
|
|
1469cc2ade | ||
|
|
05316bb34b | ||
|
|
b8f8a8685d | ||
|
|
2bcd27c1e0 | ||
|
|
2beef9be39 | ||
|
|
1b52298c6c | ||
|
|
0b19f11ad0 | ||
|
|
7276e300eb | ||
|
|
66ea77237f | ||
|
|
0309a58643 | ||
|
|
e60cbb42e3 | ||
|
|
626852401a | ||
|
|
ba2dda5beb | ||
|
|
a94677aed0 | ||
|
|
80292fab72 | ||
|
|
6d3e7c6c67 | ||
|
|
fb88dd6e1b | ||
|
|
6bb2856403 | ||
|
|
657fd589d7 | ||
|
|
843c8b1a6b | ||
|
|
9011918731 | ||
|
|
cec5d99e13 | ||
|
|
681ec84748 |
No files matched your search
@@ -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
|
||||||
@@ -6,6 +6,15 @@
|
|||||||
never derived: an aircraft that has not transmitted a rate reads as `null`
|
never derived: an aircraft that has not transmitted a rate reads as `null`
|
||||||
rather than a level-flight `0`.
|
rather than a level-flight `0`.
|
||||||
|
|
||||||
|
- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted
|
||||||
|
where it could go — https, an allowlisted host, a station feed path, no
|
||||||
|
redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted
|
||||||
|
how often, so the dev server would make unlimited requests at a third-party
|
||||||
|
bikeshare operator on any caller's behalf. The route now admits 120
|
||||||
|
requests/minute per client address, refusing the rest with 429 and
|
||||||
|
`Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a
|
||||||
|
minute with every catalogued system in range, so ordinary use is unchanged.
|
||||||
|
|
||||||
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
|
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
|
||||||
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
|
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
|
||||||
licence quote and attribution. A feed can now name its own modes, so Toronto's
|
licence quote and attribution. A feed can now name its own modes, so Toronto's
|
||||||
|
|||||||
+28
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results
|
||||||
|
(`track_entity`, `get_entity_context`, pointer context); the voice card still
|
||||||
|
shows them in the interface language (daikaginza, #1207).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is
|
||||||
|
now a button that closes the card the same way ESC does; touch screens read
|
||||||
|
DISMISS, and phones no longer hide it, so choosing a mission is not the only
|
||||||
|
way out (KnottyDyes, #1226).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest
|
||||||
|
tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata
|
||||||
|
released on failed or cancelled tile reads (quanticsoul4772, #1239).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- The terrain-heights bounds tests no longer read the checkout's
|
||||||
|
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
|
||||||
|
`npm test` no longer fails after the dev server has cached terrain near
|
||||||
|
Austin (KnottyDyes, #1244).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the
|
||||||
|
shape before the core went slim, draws its lines again instead of a silent
|
||||||
|
blank; an overview level or any answer that is not a whole zoom shows the
|
||||||
|
zoom-in hint (asturksever, #1288).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
|
||||||
|
cached model run (quanticsoul4772, #1303).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Evict terrain-height cache entries in fetch-time order without repeatedly
|
||||||
|
sorting the full cache, while keeping refreshed points newest and ordering
|
||||||
|
restored entries by their fetch time (daikaginza, #1326).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Read a hemisphere letter that is separated from its number, so a search for
|
||||||
|
`40.7128 N 74.0060 W` flies there instead of falling through to the geocoders
|
||||||
|
(Rudy Celekli, @rudycelekli, #1340).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Label NHC cyclone classifications by their published meanings: `PTC` is a
|
||||||
|
post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical
|
||||||
|
systems arrive as `STS` and `STD` (rudycelekli, #1349).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Convert NHC cyclone movement speed from miles per hour to knots, so
|
||||||
|
`movement.speedKt` in cyclone query results is no longer about 15% high
|
||||||
|
(rudycelekli, #1350).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
|
||||||
|
static data arrives; the static message only merges name, type, destination
|
||||||
|
and IMO (rudycelekli, #1351).
|
||||||
|
- Apply changed static metadata immediately, retaining known fields when a
|
||||||
|
later static report omits them, without refreshing the vessel's position
|
||||||
|
(samehkhamis, #1351).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Read a destination heading that rounds to north as 000. 359.6° was shown as
|
||||||
|
360° on the destination bearing while the compass tape already uses 000
|
||||||
|
(Rudy Celekli, @rudycelekli, #1352).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Count a transit report that is 90 seconds old as one minute. The card kept
|
||||||
|
seconds through 89s, then rounded 90s up to "Reported 2 min ago"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1353).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW
|
||||||
|
detection was labeled 10.0 MW because the one-decimal cutoff ran before
|
||||||
|
rounding (Rudy Celekli, @rudycelekli, #1354).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's
|
||||||
|
dynamic environment map, a sky render and GPU readback per model. With
|
||||||
|
~150 models in the scene at Atlanta on Google 3D, GPU time per frame at
|
||||||
|
rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison
|
||||||
|
showed no visible change (quanticsoul4772, #1366).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Turning a layer off frees the memory its entities held. Cesium kept
|
||||||
|
removed layers' draw commands in reused per-pass arrays, so Data Centers
|
||||||
|
kept 116 MiB after it was switched off, submarine cables 74 MiB and dams
|
||||||
|
30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Increase touch-first globe zoom sensitivity to three times the default while
|
||||||
|
retaining Cesium collision and distance limits.
|
||||||
|
- Reuse one delayed display timestamp per civil-aircraft fleet update and cache
|
||||||
|
extrapolation coordinate frames until their fix coordinates change. Preserve
|
||||||
|
contact counts, movement cadence, tracked motion and ground corrections.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Reduce repeated work in aircraft detection candidates by caching unchanged
|
||||||
|
callsign/registration text and reusing resolved fleet models. Metadata updates,
|
||||||
|
live positions and tracked-aircraft/model handoffs remain immediate
|
||||||
|
(samehkhamis, #1379).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- The HUD's AI place summary drops a reply that arrives after the camera has
|
||||||
|
started moving, so a summary asked for before a flight no longer names the
|
||||||
|
old place mid-flight. Cancelled or partially typed summaries retry even if the place
|
||||||
|
context stays the same (quanticsoul4772, #1384).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step
|
||||||
|
cards kept seconds below 60, then rounded 59.6 s up to "60 s"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1385).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a route leg of 995 m or more as 1.0 km. The route summary and step
|
||||||
|
cards kept meters below 1000, then rounded 996 m up to "1000 m"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1386).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters
|
||||||
|
below 1000, then rounded 999.6 m up to "1000 m"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1387).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres
|
||||||
|
below 1000, then rounded 999.6 m up to "1000 m"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1388).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6°
|
||||||
|
printed "N · 360°" on the local wind direction
|
||||||
|
(Rudy Celekli, @rudycelekli, #1389).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 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 text color, glow or
|
||||||
|
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1397).
|
||||||
+132
-15
@@ -1,5 +1,21 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Aircraft detection candidates reuse normalized display text until callsign or
|
||||||
|
registration changes, including in-place enrichment. Each candidate resolves its
|
||||||
|
fleet model once per collection. Positions, model ownership and tracked-aircraft
|
||||||
|
anchors remain live on every collection; contact admission is unchanged.
|
||||||
|
|
||||||
|
Civil aircraft share one delayed render timestamp per fleet update. Extrapolated
|
||||||
|
fixes reuse their local coordinate frame until their coordinates change, including
|
||||||
|
in-place ground-height corrections. Update cadence, contact counts, interpolation,
|
||||||
|
turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly
|
||||||
|
keyed by each position fix and do not retain retired history.
|
||||||
|
|
||||||
|
Touch-first devices use three times the default Cesium zoom sensitivity, so
|
||||||
|
pinching traverses globe and street scales with fewer gestures. Cesium retains
|
||||||
|
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
||||||
|
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
||||||
|
|
||||||
Analyst region queries resolve unique Natural Earth names offline without
|
Analyst region queries resolve unique Natural Earth names offline without
|
||||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||||
Duplicate names require a geocoded containment anchor; when several entries for
|
Duplicate names require a geocoded containment anchor; when several entries for
|
||||||
@@ -170,6 +186,32 @@ 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 controls and HUD text readability
|
||||||
|
|
||||||
|
Display uses a native, visibly checked Scope checkbox. Turning it off disables
|
||||||
|
its feather slider while retaining the value. Scope enabled state and feather
|
||||||
|
are independent of Tactical, Operator, Minimal and Cyber layout changes. Share
|
||||||
|
links (`sc`/`scf`) and scene visual state restore both choices, including feather
|
||||||
|
above 100%. The supported feather range is 0–200% of the keyhole radius: 0% is a
|
||||||
|
hard edge and 200% extends the gradient to the center while keeping its midpoint
|
||||||
|
on the same keyhole edge. First run opens with Scope ON and feather 100%; links
|
||||||
|
predating `scf` retain their historical 35% parse fallback.
|
||||||
|
|
||||||
|
When Scope is OFF, Tactical, Operator and Minimal add small black backplates
|
||||||
|
at 70% opacity behind telemetry, vertical metadata, multiline AIS and scene
|
||||||
|
chrome. They have rounded corners, no halo and no overlap between adjacent lines.
|
||||||
|
Text keeps its original color, glow and opacity in both Scope states. The engine
|
||||||
|
publishes the OFF state as `data-scope-mask="off"` on the document root, so direct
|
||||||
|
setters, share links and scene restoration drive the same treatment. Scope ON
|
||||||
|
uses the original HUD layout, Operator frames and scene chrome, with no added
|
||||||
|
backplates. The OFF-only Minimal coordinate offset clears map attribution.
|
||||||
|
Cyber retains its existing skin.
|
||||||
|
|
||||||
|
`node scripts/qa-scope-hud.mjs` exercises native checkbox click and Space,
|
||||||
|
feather range and keyboard boundaries, all four layout transitions, share/scene
|
||||||
|
restore, original Scope ON styling, OFF-only backing coverage and non-overlapping
|
||||||
|
geometry, multiline AIS, summary clipping and narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode 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
|
||||||
@@ -405,6 +447,9 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound
|
|||||||
same-origin provider. The status endpoint lacks browser CORS; each installation
|
same-origin provider. The status endpoint lacks browser CORS; each installation
|
||||||
fetches keyless public sources at runtime, with no centralized ingestion service.
|
fetches keyless public sources at runtime, with no centralized ingestion service.
|
||||||
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
|
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
|
||||||
|
Classification labels follow NHC status codes: `PC` is a potential tropical
|
||||||
|
cyclone, `PTC` a post-tropical cyclone, and `STS`/`STD` a subtropical
|
||||||
|
storm/depression. Unknown codes remain visible unchanged.
|
||||||
Current positions, advisory issue time and position time stay distinct. NHC may
|
Current positions, advisory issue time and position time stay distinct. NHC may
|
||||||
release an advisory before its nominal time (routinely up to about 30 minutes,
|
release an advisory before its nominal time (routinely up to about 30 minutes,
|
||||||
rarely about an hour), so advisory issue and position times up to and including
|
rarely about an hour), so advisory issue and position times up to and including
|
||||||
@@ -417,6 +462,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty,
|
|||||||
not storm size or the full hazard area. Successful empty and unavailable states
|
not storm size or the full hazard area. Successful empty and unavailable states
|
||||||
are distinct. One owned native Cesium data source preserves polygon holes and
|
are distinct. One owned native Cesium data source preserves polygon holes and
|
||||||
geographic seams and releases on disable; forecast animation is not implied.
|
geographic seams and releases on disable; forecast animation is not implied.
|
||||||
|
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||||
|
proxy; advisory wind intensity is already in knots and is passed through.
|
||||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||||
Consult the linked official advisory for safety decisions.
|
Consult the linked official advisory for safety decisions.
|
||||||
|
|
||||||
@@ -453,6 +500,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel.
|
|||||||
Genuine readings are unchanged, including a stopped vessel's zero and the
|
Genuine readings are unchanged, including a stopped vessel's zero and the
|
||||||
highest speed and course the fields can encode.
|
highest speed and course the fields can encode.
|
||||||
|
|
||||||
|
For an already tracked vessel, AISStream static messages update name, type,
|
||||||
|
destination and IMO without changing its position, motion, fix timestamps,
|
||||||
|
position freshness or trail. Non-empty static values replace earlier values;
|
||||||
|
omitted, empty or unavailable fields retain known metadata across Class B's
|
||||||
|
separate reports and in the cache used by the next position update. Position
|
||||||
|
reports still prefer their own metadata name. Invalid Class B parts are ignored;
|
||||||
|
absent fields prefer the latest live value over the static cache.
|
||||||
|
|
||||||
Native `<select>` controls declare a dark colour scheme and explicit option
|
Native `<select>` controls declare a dark colour scheme and explicit option
|
||||||
colours, so the browser-painted option list matches the panel it drops out of.
|
colours, so the browser-painted option list matches the panel it drops out of.
|
||||||
The scheme is declared on the controls rather than on the document root, so
|
The scheme is declared on the controls rather than on the document root, so
|
||||||
@@ -525,6 +580,9 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
|||||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||||
|
FIRMS and ALPR entity labels remain English in shared context and tool data;
|
||||||
|
voice-card titles and pointer presentation translate those known labels at display
|
||||||
|
time, while arbitrary names and callsigns remain unchanged.
|
||||||
The model-free `gods-eye-view/agent/world-packet` export exposes
|
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||||
non-voice consumers. It reads the same enabled-only state and feed
|
non-voice consumers. It reads the same enabled-only state and feed
|
||||||
@@ -744,6 +802,12 @@ transports and cancellation through response parsing. Stop and application
|
|||||||
teardown abort pending connections; reconnect requests a new client secret.
|
teardown abort pending connections; reconnect requests a new client secret.
|
||||||
Microphone, radio handoff, tool schemas and default model behavior are preserved.
|
Microphone, radio handoff, tool schemas and default model behavior are preserved.
|
||||||
|
|
||||||
|
Coordinate search accepts whitespace-separated decimal degrees with prefix or
|
||||||
|
suffix hemisphere letters, including `40.7128 N 74.0060 W`. Components require
|
||||||
|
an actual separator and an unambiguous hemisphere assignment; malformed numeric
|
||||||
|
fragments and scientific notation fall through to geocoders instead of becoming
|
||||||
|
exact coordinate results.
|
||||||
|
|
||||||
Geospatial lookups are composed through `src/search`: forward/reverse geocoding,
|
Geospatial lookups are composed through `src/search`: forward/reverse geocoding,
|
||||||
text/nearby search and routing use configurable providers/endpoints. Annotation,
|
text/nearby search and routing use configurable providers/endpoints. Annotation,
|
||||||
HUD and voice consumers share the configured service. Existing Google/Photon
|
HUD and voice consumers share the configured service. Existing Google/Photon
|
||||||
@@ -1663,6 +1727,14 @@ The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
|
|||||||
status, cache or upstream work, refusing cross-site browser requests while
|
status, cache or upstream work, refusing cross-site browser requests while
|
||||||
preserving same-origin app requests and non-browser local callers. The browser
|
preserving same-origin app requests and non-browser local callers. The browser
|
||||||
layers and their rendering remain in their existing modules.
|
layers and their rendering remain in their existing modules.
|
||||||
|
Past the daily tile budget, or while TomTom fails, the proxy answers with its
|
||||||
|
last copy of a tile (`x-tomtom-cache: STALE-BUDGET` / `STALE-ERROR`) however old
|
||||||
|
it is, and every tile carries `x-tomtom-fetched-at`, the proxy fetch time
|
||||||
|
(or the persisted cache file time after restart). The flow source keeps that mark with each decoded tile, so
|
||||||
|
`get_traffic_flow` returns `stale` and `oldest_tile_at` and says when traffic
|
||||||
|
may be out of date instead of presenting cached tiles as live. Pending freshness
|
||||||
|
metadata is released when body reads fail, are cancelled, or exceed their size
|
||||||
|
limit, as well as when a tile decodes or the source resets.
|
||||||
GBFS null or empty availability counts retain an unknown value through station
|
GBFS null or empty availability counts retain an unknown value through station
|
||||||
parsing and rendering; they use the neutral station color rather than reporting
|
parsing and rendering; they use the neutral station color rather than reporting
|
||||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||||
@@ -1938,6 +2010,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.
|
||||||
@@ -2041,10 +2116,18 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
|||||||
> `localStorage['gev:first-run-mission:v1'] === 'suppressed'`, written **only**
|
> `localStorage['gev:first-run-mission:v1'] === 'suppressed'`, written **only**
|
||||||
> by the "Don't show this again" checkbox → the per-session
|
> by the "Don't show this again" checkbox → the per-session
|
||||||
> `sessionStorage['gev:first-run-mission-session:v1'] === 'dismissed'`, written
|
> `sessionStorage['gev:first-run-mission-session:v1'] === 'dismissed'`, written
|
||||||
> by **every** close path (mission, Explore, ESC). So it returns each fresh
|
> by **every** close path (mission, Explore, ESC, the footer dismiss button). So
|
||||||
> browser session until the visitor ticks the box; clearing storage un-ticks it,
|
> it returns each fresh browser session until the visitor ticks the box; clearing
|
||||||
> which is accepted. Both stores fail open — an unreadable store still shows the
|
> storage un-ticks it, which is accepted. Both stores fail open — an unreadable
|
||||||
> launcher rather than silently swallowing first launch.
|
> store still shows the launcher rather than silently swallowing first launch.
|
||||||
|
>
|
||||||
|
> **The footer dismiss button is the keyboardless ESC.** Phones and tablets have
|
||||||
|
> no ESC key, so the footer hint is a `data-first-run-dismiss` button that calls
|
||||||
|
> the same `dismiss()` as ESC. It stays visible at every width (phones used to
|
||||||
|
> hide it, leaving a mission as the only way out) and reads DISMISS on a
|
||||||
|
> `(hover: none) and (pointer: coarse)` screen, "ESC to dismiss" otherwise. Its
|
||||||
|
> 44px touch target is a `::before` outside the layout, so the footer row keeps
|
||||||
|
> its one-line height.
|
||||||
>
|
>
|
||||||
> **What a mission may persist (do not "simplify" this).** Layer enablement is
|
> **What a mission may persist (do not "simplify" this).** Layer enablement is
|
||||||
> durable in this app (`gev:layer-state:v2`, written by
|
> durable in this app (`gev:layer-state:v2`, written by
|
||||||
@@ -2105,6 +2188,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
|||||||
> always exits non-zero, `1` meaning the control is healthy and `2` meaning it
|
> always exits non-zero, `1` meaning the control is healthy and `2` meaning it
|
||||||
> is not. Plus the unit pins above.
|
> is not. Plus the unit pins above.
|
||||||
|
|
||||||
|
> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138
|
||||||
|
> keeps the draw commands past each frame's count in the opaque and other
|
||||||
|
> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view,
|
||||||
|
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
|
||||||
|
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
|
||||||
|
> requests one frame through `governorRequestRender` after every layer
|
||||||
|
> disable and then trims those arrays. It reads private Cesium fields;
|
||||||
|
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames
|
||||||
|
> them or changes which passes it truncates.
|
||||||
|
|
||||||
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
|
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
|
||||||
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
||||||
> animator; discrete mutators call `governorRequestRender`). Any NEW
|
> animator; discrete mutators call `governorRequestRender`). Any NEW
|
||||||
@@ -3729,6 +3822,10 @@ disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the pr
|
|||||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||||
requests for a tile.
|
requests for a tile.
|
||||||
|
|
||||||
|
Line coverage accepts a provider’s numeric `zoomFor` result or its legacy
|
||||||
|
`{zoom, kind}` shape. Overview levels and invalid zooms show the zoom-in hint
|
||||||
|
instead of silently requesting no tiles.
|
||||||
|
|
||||||
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||||
the panel says to zoom in. Tiles come from the visible ground within range of
|
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||||
the camera, nearest first, at most 9 (the halves of a view across
|
the camera, nearest first, at most 9 (the halves of a view across
|
||||||
@@ -4023,6 +4120,10 @@ routing it shows `Routing…`, and a router miss reads
|
|||||||
The layer holds continuous render only while a route is drawn (the dashes
|
The layer holds continuous render only while a route is drawn (the dashes
|
||||||
animate). Disable clears all state.
|
animate). Disable clears all state.
|
||||||
|
|
||||||
|
At the metre/kilometre boundary, route distance labels use the rounded
|
||||||
|
ten-metre value to select the unit, switching to kilometres at 995 metres;
|
||||||
|
a 996-metre leg displays as `1.0 km` rather than `1000 m`.
|
||||||
|
|
||||||
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
|
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
|
||||||
until the operator turns it on.
|
until the operator turns it on.
|
||||||
|
|
||||||
@@ -4075,6 +4176,9 @@ 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.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
A step's card opens from its dot on the globe or from its line in the list, and
|
A step's card opens from its dot on the globe or from its line in the list, and
|
||||||
the list covers every step including departure and arrival, which have no dot.
|
the list covers every step including departure and arrival, which have no dot.
|
||||||
What a card needs is a resolved ground anchor: a step whose ground cell has not
|
What a card needs is a resolved ground anchor: a step whose ground cell has not
|
||||||
@@ -4187,7 +4291,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
|
|||||||
|
|
||||||
**Cards and DETECT.** The card presents route, mode/operator, displayed motion,
|
**Cards and DETECT.** The card presents route, mode/operator, displayed motion,
|
||||||
display delay and newest-report age, reported speed/stop, vehicle ID, and
|
display delay and newest-report age, reported speed/stop, vehicle ID, and
|
||||||
available trail duration. Its anchor follows the rendered marker; text is
|
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
|
||||||
|
count is below 90; otherwise it shows completed minutes calculated from the
|
||||||
|
rounded second count. Its anchor follows the rendered marker; text is
|
||||||
checked at 4 Hz and published when it changes. DETECT labels retain route,
|
checked at 4 Hz and published when it changes. DETECT labels retain route,
|
||||||
mode and motion, with the selected label suppressed beside its fuller card.
|
mode and motion, with the selected label suppressed beside its fuller card.
|
||||||
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
|
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
|
||||||
@@ -4303,6 +4409,9 @@ silent for five minutes are removed. Credits appear when the feed is used.
|
|||||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||||
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
||||||
|
|
||||||
|
Contacts distance labels select metres or kilometres after rounding to the
|
||||||
|
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
|
||||||
|
|
||||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||||
entries: count/category updates and identity replacement do not run keyhole or
|
entries: count/category updates and identity replacement do not run keyhole or
|
||||||
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
|
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
|
||||||
@@ -4633,7 +4742,7 @@ silently demoting every later lookup for the session.
|
|||||||
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
||||||
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
||||||
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
||||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
|
||||||
- **CCTV v2 foundation:** a pitched
|
- **CCTV v2 foundation:** a pitched
|
||||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||||
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||||
@@ -5037,6 +5146,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||||
|
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|
||||||
@@ -5169,6 +5279,7 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||||
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
||||||
|
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
|
||||||
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
||||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
||||||
@@ -5214,6 +5325,7 @@ 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.
|
||||||
- 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.
|
||||||
- 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.
|
||||||
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
||||||
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
||||||
@@ -5387,7 +5499,8 @@ pins and share links already supply complete boxes.
|
|||||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||||
|
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||||
@@ -5407,10 +5520,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
|
||||||
@@ -5480,11 +5594,12 @@ 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%`** — a broad, soft scope-mask edge (revised on
|
||||||
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
|
2026-10-25 from the prior `11%`; earlier defaults were `0%`, `8%` and `35%`).
|
||||||
|
`SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
|
||||||
mirrored by `#scope-feather-slider`'s markup value AND readout and
|
mirrored by `#scope-feather-slider`'s markup value AND readout and
|
||||||
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans
|
`_scopeFeatherPct` in `sharelink.js`. The slider spans
|
||||||
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
|
0–200, 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
|
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,
|
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.
|
and restoring the author's view is what a share link is for.
|
||||||
@@ -5910,6 +6025,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`,
|
||||||
@@ -6016,7 +6133,7 @@ closed. Performance and live agency reliability require recorded validation.
|
|||||||
|
|
||||||
## Wind forecast models
|
## Wind forecast models
|
||||||
|
|
||||||
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
|
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary.
|
||||||
|
|
||||||
### Local Weather review candidate
|
### Local Weather review candidate
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -805,7 +805,9 @@
|
|||||||
"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/ui/scopeControls.js",
|
||||||
|
"src/scopeMaskPolicy.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -957,6 +959,7 @@
|
|||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
|
"src/data/aircraftModelLighting.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/routePlausible.js",
|
"src/data/routePlausible.js",
|
||||||
@@ -1009,6 +1012,7 @@
|
|||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
|
"src/data/aircraftModelLighting.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/trackedModelRegime.js",
|
"src/data/trackedModelRegime.js",
|
||||||
@@ -1054,6 +1058,7 @@
|
|||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
|
"src/data/aircraftModelLighting.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/routePlausible.js",
|
"src/data/routePlausible.js",
|
||||||
@@ -1872,6 +1877,7 @@
|
|||||||
"src/data/militaryInstallations.js",
|
"src/data/militaryInstallations.js",
|
||||||
"src/data/militaryRegistry.js",
|
"src/data/militaryRegistry.js",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
|
"src/data/aircraftModelLighting.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
"src/data/naturalEarthRegions.js",
|
"src/data/naturalEarthRegions.js",
|
||||||
"src/data/adminBoundaries.js",
|
"src/data/adminBoundaries.js",
|
||||||
@@ -2570,7 +2576,10 @@
|
|||||||
"src/voice/geminiSession.js",
|
"src/voice/geminiSession.js",
|
||||||
"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/ui/scopeControls.js",
|
||||||
|
"src/scopeMaskPolicy.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -2681,6 +2690,7 @@
|
|||||||
"src/data/installationNames.js",
|
"src/data/installationNames.js",
|
||||||
"src/data/militaryInstallationData.js",
|
"src/data/militaryInstallationData.js",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
|
"src/data/aircraftModelLighting.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
"src/data/naturalEarthRegions.js",
|
"src/data/naturalEarthRegions.js",
|
||||||
"src/data/adminBoundaries.js",
|
"src/data/adminBoundaries.js",
|
||||||
@@ -3291,7 +3301,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/ui/scopeControls.js",
|
||||||
|
"src/scopeMaskPolicy.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
#!/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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const readUnexpectedBackings = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
[
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'#intel-hud .hud-readout, .scene-text-holdout',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((el) => {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
selector: el.id ? `#${el.id}` : `.${el.className}`,
|
||||||
|
background: style.backgroundColor,
|
||||||
|
shadow: style.boxShadow,
|
||||||
|
transition: `${style.transitionProperty} ${style.transitionDuration}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(style) =>
|
||||||
|
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const transitioning = await readUnexpectedBackings();
|
||||||
|
if (transitioning.length)
|
||||||
|
console.log(
|
||||||
|
`[INFO] ${layout}: waiting for existing style transitions ${JSON.stringify(transitioning)}`,
|
||||||
|
);
|
||||||
|
await settleTextBackings(true);
|
||||||
|
const originalGlyphs = await readHudGlyphs();
|
||||||
|
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 unbacked = [...wrappers, ...scene].every(
|
||||||
|
(el) =>
|
||||||
|
getComputedStyle(el).backgroundColor === 'rgba(0, 0, 0, 0)' &&
|
||||||
|
getComputedStyle(el).boxShadow === 'none',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
|
||||||
|
unbacked,
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
original.unexpectedBackings = await readUnexpectedBackings();
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
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;
|
||||||
@@ -43,6 +43,11 @@ function number(value, min, max) {
|
|||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
||||||
}
|
}
|
||||||
|
// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in
|
||||||
|
// knots); convert so `speedKt` means what it says.
|
||||||
|
function knotsFromMph(mph) {
|
||||||
|
return mph === null ? null : Math.round(mph * 0.868976);
|
||||||
|
}
|
||||||
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
|
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
|
||||||
// up to about half an hour before; allow that, but not a clock far ahead.
|
// up to about half an hour before; allow that, but not a clock far ahead.
|
||||||
const EARLY_ADVISORY_MS = 90 * 60_000;
|
const EARLY_ADVISORY_MS = 90 * 60_000;
|
||||||
@@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) {
|
|||||||
pressureHpa: number(raw.pressure, 800, 1100),
|
pressureHpa: number(raw.pressure, 800, 1100),
|
||||||
movement: {
|
movement: {
|
||||||
directionDegrees: number(raw.movementDir, 0, 360),
|
directionDegrees: number(raw.movementDir, 0, 360),
|
||||||
speedKt: number(raw.movementSpeed, 0, 200),
|
speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)),
|
||||||
},
|
},
|
||||||
advisoryUrl: officialLink(forecast.url),
|
advisoryUrl: officialLink(forecast.url),
|
||||||
outlookUrl: raw.id.startsWith('al')
|
outlookUrl: raw.id.startsWith('al')
|
||||||
|
|||||||
@@ -90,15 +90,23 @@ export function terrainHeightsProxy(options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the oldest entries until the map is back inside its ceiling.
|
* Drop the oldest entries until the map is back inside its ceiling.
|
||||||
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
|
* The map is kept in fetch-time order (sorted once on load, and the resolver
|
||||||
* Terrain does not move, so evicting an old point costs one refetch, never
|
* re-inserts every point it refetches), so the oldest are at the front and
|
||||||
* a wrong answer.
|
* no write sorts the map. Terrain does not move, so evicting an old point
|
||||||
|
* costs one refetch, never a wrong answer.
|
||||||
*/
|
*/
|
||||||
function evictOverflow() {
|
function evictOverflow() {
|
||||||
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
for (const key of mem.keys()) {
|
||||||
|
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
||||||
|
mem.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put the map in fetch-time order; a file is not guaranteed to be. */
|
||||||
|
function orderByAge() {
|
||||||
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
||||||
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
|
mem.clear();
|
||||||
mem.delete(byAge[i][0]);
|
for (const [key, entry] of byAge) mem.set(key, entry);
|
||||||
}
|
}
|
||||||
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
||||||
const inflight = new Map();
|
const inflight = new Map();
|
||||||
@@ -165,6 +173,7 @@ export function terrainHeightsProxy(options = {}) {
|
|||||||
}
|
}
|
||||||
diskDirty = mem.size > 0;
|
diskDirty = mem.size > 0;
|
||||||
}
|
}
|
||||||
|
orderByAge();
|
||||||
evictOverflow();
|
evictOverflow();
|
||||||
} catch {
|
} catch {
|
||||||
/* no disk cache yet */
|
/* no disk cache yet */
|
||||||
|
|||||||
@@ -182,14 +182,17 @@ export function tomtomProxy() {
|
|||||||
});
|
});
|
||||||
res.end(JSON.stringify(obj));
|
res.end(JSON.stringify(obj));
|
||||||
};
|
};
|
||||||
const sendTile = (buf, cacheStatus) => {
|
// x-tomtom-fetched-at is when the proxy fetched the tile (ms since the
|
||||||
|
// epoch), so a client handed a STALE-* copy can say how old it is.
|
||||||
|
const sendTile = (entry, cacheStatus) => {
|
||||||
if (res.headersSent) return;
|
if (res.headersSent) return;
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/x-protobuf',
|
'Content-Type': 'application/x-protobuf',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'x-tomtom-cache': cacheStatus,
|
'x-tomtom-cache': cacheStatus,
|
||||||
|
'x-tomtom-fetched-at': String(Math.round(entry.at)),
|
||||||
});
|
});
|
||||||
res.end(buf);
|
res.end(entry.buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -235,14 +238,14 @@ export function tomtomProxy() {
|
|||||||
}
|
}
|
||||||
// Fresh cache hit — never counts against the budget.
|
// Fresh cache hit — never counts against the budget.
|
||||||
if (entry && now - entry.at < TILE_TTL_MS) {
|
if (entry && now - entry.at < TILE_TTL_MS) {
|
||||||
sendTile(entry.buf, 'HIT');
|
sendTile(entry, 'HIT');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
||||||
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
sendTile(entry.buf, 'STALE-BUDGET');
|
sendTile(entry, 'STALE-BUDGET');
|
||||||
} else {
|
} else {
|
||||||
sendJson(429, { error: 'budget' });
|
sendJson(429, { error: 'budget' });
|
||||||
}
|
}
|
||||||
@@ -271,9 +274,9 @@ export function tomtomProxy() {
|
|||||||
}
|
}
|
||||||
const fresh = await inflight.get(key);
|
const fresh = await inflight.get(key);
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
sendTile(fresh.buf, 'MISS');
|
sendTile(fresh, 'MISS');
|
||||||
} else if (entry) {
|
} else if (entry) {
|
||||||
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
|
sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty
|
||||||
} else {
|
} else {
|
||||||
sendJson(502, { error: 'upstream' });
|
sendJson(502, { error: 'upstream' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,14 +43,55 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!mmsi) return false;
|
if (!mmsi) return false;
|
||||||
|
|
||||||
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
||||||
|
const previous = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const live = _aisStreamVessels.get(mmsi);
|
||||||
|
const liveName = live?.name === `MMSI ${mmsi}` ? '' : live?.name;
|
||||||
|
const fields =
|
||||||
|
messageType === 'StaticDataReport'
|
||||||
|
? {
|
||||||
|
Name:
|
||||||
|
message.ReportA?.Valid === false
|
||||||
|
? undefined
|
||||||
|
: message.ReportA?.Name,
|
||||||
|
Type:
|
||||||
|
message.ReportB?.Valid === false
|
||||||
|
? undefined
|
||||||
|
: message.ReportB?.ShipType,
|
||||||
|
}
|
||||||
|
: message;
|
||||||
|
// Static reports may be partial (including Class B's separate A/B parts).
|
||||||
|
// Keep known values in the cache as well as in the current live row.
|
||||||
const staticData = {
|
const staticData = {
|
||||||
name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)),
|
// The envelope repeats a cached name even on Class B's type-only part.
|
||||||
type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)),
|
// Only an actual static name report replaces a known live name.
|
||||||
destination: stringValue(message.Destination),
|
name:
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO),
|
stringValue(fields.Name ?? fields.ShipName) ||
|
||||||
|
liveName ||
|
||||||
|
previous.name ||
|
||||||
|
stringValue(metadata.ShipName),
|
||||||
|
type:
|
||||||
|
staticCode(fields.Type ?? fields.ShipType) ||
|
||||||
|
staticCode(live?.type) ||
|
||||||
|
staticCode(previous.type) ||
|
||||||
|
'',
|
||||||
|
destination:
|
||||||
|
stringValue(fields.Destination) ||
|
||||||
|
live?.destination ||
|
||||||
|
previous.destination ||
|
||||||
|
'',
|
||||||
|
imo:
|
||||||
|
staticCode(fields.ImoNumber ?? fields.IMO) ||
|
||||||
|
staticCode(live?.imo) ||
|
||||||
|
staticCode(previous.imo) ||
|
||||||
|
'',
|
||||||
};
|
};
|
||||||
_aisStreamStatic.set(mmsi, staticData);
|
_aisStreamStatic.set(mmsi, staticData);
|
||||||
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
||||||
|
// AISStream repeats the last known position in a static message's
|
||||||
|
// MetaData, but the message has no speed, course or heading and is not a
|
||||||
|
// new fix. Rebuilding a tracked vessel from it would blank its motion and
|
||||||
|
// restamp its position time, so only merge the static fields.
|
||||||
|
if (_aisStreamVessels.has(mmsi)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lat = numberValue(
|
const lat = numberValue(
|
||||||
@@ -191,12 +232,10 @@ function approxMetersBetween(lat1, lon1, lat2, lon2) {
|
|||||||
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
||||||
const existing = _aisStreamVessels.get(mmsi);
|
const existing = _aisStreamVessels.get(mmsi);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`))
|
if (staticData.name) existing.name = staticData.name;
|
||||||
existing.name = staticData.name;
|
if (staticData.type) existing.type = staticData.type;
|
||||||
if (staticData.type && !existing.type) existing.type = staticData.type;
|
if (staticData.destination) existing.destination = staticData.destination;
|
||||||
if (staticData.destination && !existing.destination)
|
if (staticData.imo) existing.imo = staticData.imo;
|
||||||
existing.destination = staticData.destination;
|
|
||||||
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function vesselNameFromAis(metadata, message, staticData = {}) {
|
function vesselNameFromAis(metadata, message, staticData = {}) {
|
||||||
@@ -266,6 +305,12 @@ function stringValue(value) {
|
|||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIS uses zero for unavailable ship type and IMO, not as a replacement value.
|
||||||
|
function staticCode(value) {
|
||||||
|
const number = numberValue(value);
|
||||||
|
return Number.isInteger(number) && number > 0 ? String(number) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function numberValue(value) {
|
function numberValue(value) {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
if (value === null || value === undefined || value === '') return null;
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
|
|||||||
@@ -208,9 +208,10 @@ export function formatMeasure(session) {
|
|||||||
if (!session) return '';
|
if (!session) return '';
|
||||||
if (session.shape === 'line') {
|
if (session.shape === 'line') {
|
||||||
const m = pathLengthM(session.vertices);
|
const m = pathLengthM(session.vertices);
|
||||||
return m >= 1000
|
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
|
||||||
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
|
const whole = Math.round(m);
|
||||||
: `${Math.round(m)} m`;
|
if (whole < 1000) return `${whole} m`;
|
||||||
|
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
|
||||||
}
|
}
|
||||||
if (session.shape === 'area') {
|
if (session.shape === 'area') {
|
||||||
const m2 = ringAreaM2(session.vertices);
|
const m2 = ringAreaM2(session.vertices);
|
||||||
|
|||||||
@@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
|
|||||||
assert.equal(wrapLongitude(180), -180);
|
assert.equal(wrapLongitude(180), -180);
|
||||||
assert.equal(unwrapLongitudes([]).length, 0);
|
assert.equal(unwrapLongitudes([]).length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||||
|
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||||
|
const lat = 999.6 / metresPerDegree;
|
||||||
|
const line = createDrawSession('line');
|
||||||
|
addVertex(line, { lon: 0, lat: 0 });
|
||||||
|
addVertex(line, { lon: 0, lat });
|
||||||
|
const metres = pathLengthM(line.vertices);
|
||||||
|
assert.ok(
|
||||||
|
metres >= 999.5 && metres < 1000,
|
||||||
|
`length should round to 1000 m, got ${metres}`,
|
||||||
|
);
|
||||||
|
assert.equal(formatMeasure(line), '1.0 km');
|
||||||
|
assert.match(drawHint(line), /^1\.0 km · /);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
|||||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||||
import { installViews, isEmbeddedInline } from './embed.js';
|
import { installViews, isEmbeddedInline } from './embed.js';
|
||||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||||
|
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||||
import {
|
import {
|
||||||
installRenderGovernor,
|
installRenderGovernor,
|
||||||
getRenderGovernorDiagnostics,
|
getRenderGovernorDiagnostics,
|
||||||
@@ -110,6 +111,15 @@ export function createApplicationTools({
|
|||||||
// nothing animates per frame. Installed AFTER every module above has had
|
// nothing animates per frame. Installed AFTER every module above has had
|
||||||
// its chance to register pre-install holds. (perf wave 2)
|
// its chance to register pre-install holds. (perf wave 2)
|
||||||
installRenderGovernor(viewer);
|
installRenderGovernor(viewer);
|
||||||
|
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||||
|
// alive in reused per-pass arrays; trim them after each disable.
|
||||||
|
defer(
|
||||||
|
installLayerReleaseTrim({
|
||||||
|
scene: viewer.scene,
|
||||||
|
dataManager,
|
||||||
|
requestRender: governorRequestRender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Install the explicit scope mask used by the DISPLAY controls.
|
// Install the explicit scope mask used by the DISPLAY controls.
|
||||||
installScopeMask(viewer);
|
installScopeMask(viewer);
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
viewer.targetFrameRate = 60;
|
viewer.targetFrameRate = 60;
|
||||||
|
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
|
||||||
|
// Keep Cesium's distance-aware zoom and collision limits, with less travel
|
||||||
|
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||||
|
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||||
|
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||||
// render loop. See app/atmosphereCompat.js.
|
// render loop. See app/atmosphereCompat.js.
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ export function normalizeHeading(value) {
|
|||||||
return ((value % 360) + 360) % 360;
|
return ((value % 360) + 360) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-digit heading readout, 000–359.
|
||||||
|
* Rounding 359.5° and above lands on north (000), not 360.
|
||||||
|
*/
|
||||||
|
export function formatHeadingReadout(degrees) {
|
||||||
|
if (!Number.isFinite(degrees)) return '---';
|
||||||
|
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
|
||||||
|
}
|
||||||
|
|
||||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||||
export function slewHeading(current, target, maxStepDeg) {
|
export function slewHeading(current, target, maxStepDeg) {
|
||||||
const from = normalizeHeading(current);
|
const from = normalizeHeading(current);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
formatCockpitContextScope,
|
formatCockpitContextScope,
|
||||||
formatCockpitEmergency,
|
formatCockpitEmergency,
|
||||||
formatCompassDivision,
|
formatCompassDivision,
|
||||||
|
formatHeadingReadout,
|
||||||
formatSpeedRulerTick,
|
formatSpeedRulerTick,
|
||||||
normalizeHeading,
|
normalizeHeading,
|
||||||
relativeBearing,
|
relativeBearing,
|
||||||
@@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
|||||||
assert.equal(formatCompassDivision(30), '030');
|
assert.equal(formatCompassDivision(30), '030');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a heading that rounds to north reads 000, not 360', () => {
|
||||||
|
assert.equal(formatHeadingReadout(359.6), '000');
|
||||||
|
assert.equal(formatHeadingReadout(359.4), '359');
|
||||||
|
assert.equal(formatHeadingReadout(0), '000');
|
||||||
|
assert.equal(formatHeadingReadout(10.2), '010');
|
||||||
|
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||||
|
});
|
||||||
|
|
||||||
test('great-circle bearing resolves cardinal directions', () => {
|
test('great-circle bearing resolves cardinal directions', () => {
|
||||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
|
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
|
||||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
|
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||||
|
* Flights, Local ADS-B).
|
||||||
|
*
|
||||||
|
* Cesium gives each Model a dynamic environment map: it renders the sky around
|
||||||
|
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||||
|
* when the model is first placed and again when it moves more than 1 km, when
|
||||||
|
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||||
|
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||||
|
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||||
|
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||||
|
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||||
|
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||||
|
*/
|
||||||
|
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||||
|
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||||
|
* the glTF cache and are never added to the scene, so they are left out:
|
||||||
|
* drawn models are the calls that set a minimum pixel size.
|
||||||
|
*/
|
||||||
|
const SOURCES = [
|
||||||
|
{
|
||||||
|
name: 'flights',
|
||||||
|
source: readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'militaryFlights',
|
||||||
|
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'localAdsb',
|
||||||
|
source: readFileSync(
|
||||||
|
new URL('../layers/localAdsb/models.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('the shared option turns the dynamic environment map off', () => {
|
||||||
|
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
|
||||||
|
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { name, source } of SOURCES) {
|
||||||
|
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||||
|
const calls = [
|
||||||
|
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||||
|
]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||||
|
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
|
||||||
|
for (const [index, options] of calls.entries())
|
||||||
|
assert.match(
|
||||||
|
options,
|
||||||
|
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
|
||||||
|
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
AISSTREAM_STALE_MS,
|
||||||
aisStreamRows,
|
aisStreamRows,
|
||||||
ingestAisStreamEnvelope,
|
ingestAisStreamEnvelope,
|
||||||
|
readAisTrack,
|
||||||
} from '../../server/providers/vessels/ais-store.js';
|
} from '../../server/providers/vessels/ais-store.js';
|
||||||
|
|
||||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
|||||||
assert.equal(row.speed, 102.2);
|
assert.equal(row.speed, 102.2);
|
||||||
assert.equal(row.course, 359.9);
|
assert.equal(row.course, 359.9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||||
|
ingestAisStreamEnvelope(
|
||||||
|
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||||
|
);
|
||||||
|
// AISStream's MetaData carries the last known position on every message,
|
||||||
|
// static data included.
|
||||||
|
assert.equal(
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'ShipStaticData',
|
||||||
|
Message: {
|
||||||
|
ShipStaticData: {
|
||||||
|
UserID: 244690000,
|
||||||
|
Name: 'EXAMPLE VESSEL',
|
||||||
|
Type: 70,
|
||||||
|
Destination: 'ROTTERDAM',
|
||||||
|
ImoNumber: 9876543,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '244690000',
|
||||||
|
latitude: 53.40879,
|
||||||
|
longitude: 6.19741,
|
||||||
|
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = rowFor('244690000');
|
||||||
|
assert.equal(row.speed, 12.4);
|
||||||
|
assert.equal(row.course, 86.7);
|
||||||
|
assert.equal(row.heading, 87);
|
||||||
|
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||||
|
assert.equal(row.destination, 'ROTTERDAM');
|
||||||
|
assert.equal(row.imo, '9876543');
|
||||||
|
});
|
||||||
|
|
||||||
|
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||||
|
const classBStatic = (part, value) => ({
|
||||||
|
MessageID: 24,
|
||||||
|
RepeatIndicator: 0,
|
||||||
|
Valid: true,
|
||||||
|
Reserved: 0,
|
||||||
|
PartNumber: part === 'B',
|
||||||
|
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||||
|
ReportB: {
|
||||||
|
Valid: part === 'B',
|
||||||
|
ShipType: part === 'B' ? value : 0,
|
||||||
|
VendorIDName: '',
|
||||||
|
VenderIDModel: 0,
|
||||||
|
VenderIDSerial: 0,
|
||||||
|
CallSign: '',
|
||||||
|
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||||
|
FixType: 0,
|
||||||
|
Spare: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, scenario] of [
|
||||||
|
{
|
||||||
|
type: 'ShipStaticData',
|
||||||
|
initial: [
|
||||||
|
{
|
||||||
|
Name: 'OLD VESSEL',
|
||||||
|
Type: 70,
|
||||||
|
Destination: 'ROTTERDAM',
|
||||||
|
ImoNumber: 9876543,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
Name: 'NEW VESSEL',
|
||||||
|
Type: 80,
|
||||||
|
Destination: 'HAMBURG',
|
||||||
|
ImoNumber: 9876544,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||||
|
expected: {
|
||||||
|
name: 'NEW VESSEL',
|
||||||
|
type: '80',
|
||||||
|
destination: 'HAMBURG',
|
||||||
|
imo: '9876544',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'StaticDataReport',
|
||||||
|
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||||
|
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||||
|
empty: classBStatic('A', ''),
|
||||||
|
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||||
|
},
|
||||||
|
].entries()) {
|
||||||
|
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||||
|
const mmsi = String(244690100 + index);
|
||||||
|
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||||
|
let now = start;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
const position = (
|
||||||
|
lat,
|
||||||
|
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||||
|
) => {
|
||||||
|
const envelope = positionReport(mmsi, body);
|
||||||
|
Object.assign(envelope.MetaData, {
|
||||||
|
latitude: lat,
|
||||||
|
time_utc: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
return ingestAisStreamEnvelope(envelope);
|
||||||
|
};
|
||||||
|
const sendStatic = (body) =>
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: scenario.type,
|
||||||
|
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||||
|
MetaData: {
|
||||||
|
MMSI: mmsi,
|
||||||
|
ShipName: 'STALE HEADER',
|
||||||
|
latitude: 54,
|
||||||
|
longitude: 7,
|
||||||
|
time_utc: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
position(53.40879);
|
||||||
|
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||||
|
now += 60_000;
|
||||||
|
position(53.41879);
|
||||||
|
const before = rowFor(mmsi);
|
||||||
|
const track = readAisTrack(mmsi);
|
||||||
|
assert.equal(track.length, 2);
|
||||||
|
|
||||||
|
now += 5 * 60_000;
|
||||||
|
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
assert.deepEqual(readAisTrack(mmsi), track);
|
||||||
|
|
||||||
|
// Empty or omitted static fields must not erase known metadata, including
|
||||||
|
// the cached values used when the next actual position arrives.
|
||||||
|
assert.equal(sendStatic(scenario.empty), true);
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
assert.deepEqual(readAisTrack(mmsi), track);
|
||||||
|
if (scenario.type === 'StaticDataReport') {
|
||||||
|
sendStatic({
|
||||||
|
...classBStatic('A', ''),
|
||||||
|
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||||
|
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||||
|
});
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
}
|
||||||
|
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||||
|
assert.equal(
|
||||||
|
rowFor(mmsi),
|
||||||
|
undefined,
|
||||||
|
'static traffic must not renew position freshness',
|
||||||
|
);
|
||||||
|
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||||
|
const next = rowFor(mmsi);
|
||||||
|
for (const [key, value] of Object.entries(scenario.expected))
|
||||||
|
assert.equal(next[key], value, key);
|
||||||
|
assert.equal(next.speed, 0);
|
||||||
|
assert.equal(next.course, 0);
|
||||||
|
assert.equal(next.heading, 0);
|
||||||
|
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||||
|
assert.equal(readAisTrack(mmsi).length, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||||
|
const mmsi = '244690200';
|
||||||
|
const staticReport = (body) => ({
|
||||||
|
MessageType: 'ShipStaticData',
|
||||||
|
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||||
|
MetaData: { MMSI: mmsi },
|
||||||
|
});
|
||||||
|
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||||
|
const extended = positionReport(mmsi, {});
|
||||||
|
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||||
|
extended.Message = {
|
||||||
|
ExtendedClassBPositionReport: {
|
||||||
|
UserID: Number(mmsi),
|
||||||
|
Type: 80,
|
||||||
|
Sog: 12,
|
||||||
|
Cog: 86,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||||
|
ingestAisStreamEnvelope(extended);
|
||||||
|
const before = rowFor(mmsi);
|
||||||
|
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||||
|
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||||
|
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||||
|
assert.equal(rowFor(mmsi).type, '80');
|
||||||
|
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
|
|||||||
} from './alprCameras.js';
|
} from './alprCameras.js';
|
||||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||||
import { DATA_CREDITS } from './dataCredits.js';
|
import { DATA_CREDITS } from './dataCredits.js';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
|
import { shownEntityLabel } from '../voice/speech.js';
|
||||||
import {
|
import {
|
||||||
clearSelectedEntityContextForLayer,
|
clearSelectedEntityContextForLayer,
|
||||||
getContextStore,
|
getContextStore,
|
||||||
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The voice model reads the label as data; the voice card translates it.
|
||||||
|
test('the camera label stays English data in a translated interface', async () => {
|
||||||
|
const h = cameraHarness();
|
||||||
|
try {
|
||||||
|
setLocale('ru');
|
||||||
|
h.setFetch(async () =>
|
||||||
|
cameraResponse([
|
||||||
|
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await alprCamerasLayer.update();
|
||||||
|
h.click(h.source.entities.values[0].id);
|
||||||
|
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||||
|
assert.equal(
|
||||||
|
shownEntityLabel(getSelectedEntityContext().label),
|
||||||
|
'Камера ALPR',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
||||||
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
|
|||||||
assert.equal(storm.issuedAt, TIME);
|
assert.equal(storm.issuedAt, TIME);
|
||||||
assert.equal(storm.positionAt, TIME);
|
assert.equal(storm.positionAt, TIME);
|
||||||
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
||||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
|
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
|
||||||
|
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
|
||||||
assert.equal(storm.windKt, 25);
|
assert.equal(storm.windKt, 25);
|
||||||
assert.equal(storm.pressureHpa, 1006);
|
assert.equal(storm.pressureHpa, 1006);
|
||||||
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||||
|
|||||||
@@ -846,9 +846,13 @@ test('route numbers follow the page language and never break from their unit', (
|
|||||||
t.after(() => setLocale('en'));
|
t.after(() => setLocale('en'));
|
||||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||||
|
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||||||
|
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||||
|
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||||||
|
assert.equal(routeDurationText(59.6), '1\u00a0min');
|
||||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||||
assert.equal(routeDistanceText(-1), '');
|
assert.equal(routeDistanceText(-1), '');
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
|
|||||||
assert.equal(card.details[0], 'high · N20');
|
assert.equal(card.details[0], 'high · N20');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||||
|
const crossed = buildFireCard(
|
||||||
|
{ fire: fire({ frp: 9.96 }), position: {} },
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(crossed.title, '▲ 10 MW');
|
||||||
|
const below = buildFireCard(
|
||||||
|
{ fire: fire({ frp: 9.94 }), position: {} },
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(below.title, '▲ 9.9 MW');
|
||||||
|
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
||||||
|
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||||
|
});
|
||||||
|
|
||||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||||
assert.match(card.details[0], /SNPP$/);
|
assert.match(card.details[0], /SNPP$/);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
|
|||||||
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
||||||
import { fireDetectionKey } from './firmsLabels.js';
|
import { fireDetectionKey } from './firmsLabels.js';
|
||||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||||
|
import { getSelectedEntityContext } from './contextStore.js';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
|
import { shownEntityLabel } from '../voice/speech.js';
|
||||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The voice model reads these labels as data (track_entity, get_entity_context,
|
||||||
|
// pointer_context); the voice card translates them where it shows them.
|
||||||
|
test('fire labels stay English data in a translated interface', () => {
|
||||||
|
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
|
||||||
|
try {
|
||||||
|
setLocale('ru');
|
||||||
|
h.click();
|
||||||
|
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
|
||||||
|
// The card's pattern must match what the producer actually writes.
|
||||||
|
assert.equal(
|
||||||
|
shownEntityLabel(getSelectedEntityContext().label),
|
||||||
|
'Пожар · FRP 1520 МВт',
|
||||||
|
);
|
||||||
|
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
|
||||||
|
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a click on neither sprite nor card clears without moving the camera', () => {
|
test('a click on neither sprite nor card clears without moving the camera', () => {
|
||||||
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
|
|||||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
|
||||||
|
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
|
||||||
|
* @param {number} frp Megawatts.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatFrpMw(frp) {
|
||||||
|
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
|
||||||
|
return Number(frp).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||||
export function satelliteShortName(satellite) {
|
export function satelliteShortName(satellite) {
|
||||||
const s = String(satellite || '')
|
const s = String(satellite || '')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||||
FIRMS_OVERLAY_SOURCE_ID,
|
FIRMS_OVERLAY_SOURCE_ID,
|
||||||
satelliteShortName,
|
satelliteShortName,
|
||||||
|
formatFrpMw,
|
||||||
} from './firmsLabels.js';
|
} from './firmsLabels.js';
|
||||||
|
|
||||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||||
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
|||||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
|
||||||
|
assert.equal(formatFrpMw(9.96), '10');
|
||||||
|
assert.equal(formatFrpMw(9.94), '9.9');
|
||||||
|
assert.equal(formatFrpMw(0.8), '0.8');
|
||||||
|
assert.equal(formatFrpMw(1520.27), '1520');
|
||||||
|
assert.equal(formatFrpMw(210.4), '210');
|
||||||
|
});
|
||||||
|
|
||||||
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
|
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
|
||||||
assert.equal(satelliteShortName('N20'), 'N20');
|
assert.equal(satelliteShortName('N20'), 'N20');
|
||||||
assert.equal(satelliteShortName('N21'), 'N21');
|
assert.equal(satelliteShortName('N21'), 'N21');
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ import {
|
|||||||
formatAwarenessLabel,
|
formatAwarenessLabel,
|
||||||
getAwarenessNavigationTargets,
|
getAwarenessNavigationTargets,
|
||||||
} from './militaryAwarenessEngine.js';
|
} from './militaryAwarenessEngine.js';
|
||||||
import { formatAwarenessCount } from '../layers/awareness/panel.js';
|
import {
|
||||||
|
formatAwarenessCount,
|
||||||
|
presentAwarenessDistance,
|
||||||
|
} from '../layers/awareness/panel.js';
|
||||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||||
|
|
||||||
const militaryAwarenessSource = readLayerSource(
|
const militaryAwarenessSource = readLayerSource(
|
||||||
@@ -69,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => {
|
|||||||
assert.equal(formatAwarenessCount({ count: null }), '?');
|
assert.equal(formatAwarenessCount({ count: null }), '?');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
|
||||||
|
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
|
||||||
|
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
|
||||||
|
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
|
||||||
|
});
|
||||||
|
|
||||||
function installAwarenessRuntime({
|
function installAwarenessRuntime({
|
||||||
isEnabled = () => true,
|
isEnabled = () => true,
|
||||||
isEffectivelyEnabled = isEnabled,
|
isEffectivelyEnabled = isEnabled,
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({
|
|||||||
for (let i = 0; i < missing.length; i += 1) {
|
for (let i = 0; i < missing.length; i += 1) {
|
||||||
const result = fetched[i];
|
const result = fetched[i];
|
||||||
if (validTerrainResult(result)) {
|
if (validTerrainResult(result)) {
|
||||||
|
// Re-insert rather than overwrite: a Map keeps an existing key in
|
||||||
|
// its old position, and the proxy evicts from the front.
|
||||||
|
cache.delete(missing[i].key);
|
||||||
cache.set(missing[i].key, { at: fetchedAt, result });
|
cache.set(missing[i].key, { at: fetchedAt, result });
|
||||||
cacheChanged = true;
|
cacheChanged = true;
|
||||||
} else if (absentTerrainDatum(result)) {
|
} else if (absentTerrainDatum(result)) {
|
||||||
|
|||||||
@@ -543,6 +543,10 @@ export function initFirstRunExperience({
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const button of buttons) button.addEventListener('click', onChoice);
|
for (const button of buttons) button.addEventListener('click', onChoice);
|
||||||
|
// The same dismissal as ESC, for screens without a keyboard.
|
||||||
|
root
|
||||||
|
.querySelector('[data-first-run-dismiss]')
|
||||||
|
?.addEventListener('click', () => dismiss());
|
||||||
suppressBox?.addEventListener('change', onSuppressChange);
|
suppressBox?.addEventListener('change', onSuppressChange);
|
||||||
// Capture phase: the app binds its own global hotkeys (including bare letters
|
// Capture phase: the app binds its own global hotkeys (including bare letters
|
||||||
// that cycle detection and styles), and the launcher owns the keyboard first.
|
// that cycle detection and styles), and the launcher owns the keyboard first.
|
||||||
|
|||||||
@@ -913,6 +913,48 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
|||||||
assert.match(choicesBlock, /overflow-y: auto;/);
|
assert.match(choicesBlock, /overflow-y: auto;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', () => {
|
||||||
|
const html = expandApplicationHtml(
|
||||||
|
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||||
|
);
|
||||||
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// The whole stylesheet, so a hide rule added in any other file is caught too.
|
||||||
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
const footer = html.slice(
|
||||||
|
html.indexOf('class="first-run-footer"'),
|
||||||
|
html.indexOf('data-first-run-status'),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
footer,
|
||||||
|
/<button type="button" class="first-run-dismiss" data-first-run-dismiss>/,
|
||||||
|
);
|
||||||
|
assert.match(footer, /data-i18n>ESC to dismiss</);
|
||||||
|
assert.match(footer, /data-i18n>DISMISS</);
|
||||||
|
// It closes the way ESC does, through the one dismiss path, so the session
|
||||||
|
// flag and focus handling are the same.
|
||||||
|
assert.match(
|
||||||
|
module,
|
||||||
|
/querySelector\('\[data-first-run-dismiss\]'\)\s*\?\.addEventListener\('click', \(\) => dismiss\(\)\)/,
|
||||||
|
);
|
||||||
|
// Phones used to hide the hint, leaving no way out but picking a mission.
|
||||||
|
assert.doesNotMatch(css, /first-run-footer > span:last-child/);
|
||||||
|
assert.doesNotMatch(css, /\.first-run-dismiss\s*\{[^}]*display:\s*none/);
|
||||||
|
// A 44px touch target that does not add to the footer row's height.
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/\.first-run-dismiss::before \{[^}]*position: absolute;[^}]*height: 44px;/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(css, /\.first-run-dismiss \{[^}]*min-height/);
|
||||||
|
// Touch screens read DISMISS rather than a key they do not have.
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/@media \(hover: none\) and \(pointer: coarse\) \{\s*\.first-run-dismiss-key \{\s*display: none;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||||
const module = fs.readFileSync(
|
const module = fs.readFileSync(
|
||||||
new URL('./firstRunExperience.js', import.meta.url),
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
|||||||
+63
-26
@@ -224,6 +224,20 @@ export class IntelHUD {
|
|||||||
// session, so this flips once — and both altitude readouts have to move
|
// session, so this flips once — and both altitude readouts have to move
|
||||||
// together when it does (see the repaint in _updateCameraData).
|
// together when it does (see the repaint in _updateCameraData).
|
||||||
this._geoidCorrectionApplied = false;
|
this._geoidCorrectionApplied = false;
|
||||||
|
// A summary built for one view must not land on the next: a request in
|
||||||
|
// flight when the camera starts moving is aborted and nothing it ends
|
||||||
|
// with paints (the revision moves), a reply still typing stops, and the
|
||||||
|
// summary is marked dirty so the next periodic tick asks about the view
|
||||||
|
// the camera has moved to.
|
||||||
|
this._onCameraMoveStart = () => {
|
||||||
|
this._markSummaryDirty();
|
||||||
|
if (this._summaryRequest || this._summaryTypingInterval) {
|
||||||
|
// Cancelled requests and partially typed replies are not reusable answers.
|
||||||
|
this._lastSummarySignature = null;
|
||||||
|
}
|
||||||
|
this._summaryRequest?.abort();
|
||||||
|
this._stopSummaryTyping();
|
||||||
|
};
|
||||||
this._onCameraMoveEnd = () => {
|
this._onCameraMoveEnd = () => {
|
||||||
this._markSummaryDirty();
|
this._markSummaryDirty();
|
||||||
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
|
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
|
||||||
@@ -249,6 +263,7 @@ export class IntelHUD {
|
|||||||
this._passNum = 100 + Math.floor(Math.random() * 200);
|
this._passNum = 100 + Math.floor(Math.random() * 200);
|
||||||
|
|
||||||
this._buildDOM();
|
this._buildDOM();
|
||||||
|
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
|
||||||
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
||||||
this._startTimers();
|
this._startTimers();
|
||||||
}
|
}
|
||||||
@@ -267,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>
|
||||||
@@ -296,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;
|
||||||
@@ -766,6 +781,12 @@ export class IntelHUD {
|
|||||||
return provenance ? `${line} | ${provenance}` : line;
|
return provenance ? `${line} | ${provenance}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||||
|
_stopSummaryTyping() {
|
||||||
|
clearInterval(this._summaryTypingInterval);
|
||||||
|
this._summaryTypingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animate the summary text into the DOM using a typewriter effect
|
* Animate the summary text into the DOM using a typewriter effect
|
||||||
* (2 characters every 24ms).
|
* (2 characters every 24ms).
|
||||||
@@ -816,8 +837,10 @@ export class IntelHUD {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[HUD] summary context unavailable:', error);
|
console.warn('[HUD] summary context unavailable:', error);
|
||||||
// Left dirty on purpose: the next periodic tick retries instead of
|
// Left dirty on purpose: the next periodic tick retries instead of
|
||||||
// sticking on the fallback line for the rest of the session.
|
// sticking on the fallback line for the rest of the session. The
|
||||||
this._setSummaryText(fallbackText, animate);
|
// fallback was composed for this view; a moved camera keeps its own.
|
||||||
|
if (revision === this._summaryRevision)
|
||||||
|
this._setSummaryText(fallbackText, animate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
@@ -832,7 +855,11 @@ export class IntelHUD {
|
|||||||
this._summaryDirty = false;
|
this._summaryDirty = false;
|
||||||
this._lastSummarySignature = signature;
|
this._lastSummarySignature = signature;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 5000);
|
let timedOut = false;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, 5000);
|
||||||
this._summaryRequest = controller;
|
this._summaryRequest = controller;
|
||||||
try {
|
try {
|
||||||
this.summaryPolicy.onRequest?.();
|
this.summaryPolicy.onRequest?.();
|
||||||
@@ -855,17 +882,24 @@ export class IntelHUD {
|
|||||||
animate,
|
animate,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== 'AbortError') {
|
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||||
|
// destroy's) is not.
|
||||||
|
const moved = revision !== this._summaryRevision;
|
||||||
|
if (error?.name !== 'AbortError' || timedOut) {
|
||||||
console.warn('[HUD] AI summary unavailable:', error);
|
console.warn('[HUD] AI summary unavailable:', error);
|
||||||
// Invalidate the committed signature so the next periodic tick
|
// Invalidate the committed signature so the next periodic tick
|
||||||
// retries instead of sticking on the fallback line forever.
|
// retries instead of sticking on the fallback line forever.
|
||||||
this._lastSummarySignature = null;
|
this._lastSummarySignature = null;
|
||||||
this._summaryDirty = true;
|
this._summaryDirty = true;
|
||||||
}
|
}
|
||||||
this._setSummaryText(fallbackText, animate);
|
if (!moved) this._setSummaryText(fallbackText, animate);
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
if (this._summaryRequest === controller) this._summaryRequest = null;
|
if (this._summaryRequest === controller) this._summaryRequest = null;
|
||||||
|
// A request whose answer was dropped (aborted by a move, or a reply
|
||||||
|
// that came back after one) committed a signature it never painted;
|
||||||
|
// forget it, or the next tick over the same place would skip asking.
|
||||||
|
if (revision !== this._summaryRevision) this._lastSummarySignature = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +908,8 @@ export class IntelHUD {
|
|||||||
this._typeSummary(text);
|
this._typeSummary(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A plain paint replaces any reply still typing.
|
||||||
|
this._stopSummaryTyping();
|
||||||
const el = document.getElementById('hud-summary');
|
const el = document.getElementById('hud-summary');
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1080,7 @@ export class IntelHUD {
|
|||||||
clearInterval(this._timestampInterval);
|
clearInterval(this._timestampInterval);
|
||||||
clearInterval(this._summaryInterval);
|
clearInterval(this._summaryInterval);
|
||||||
clearInterval(this._summaryTypingInterval);
|
clearInterval(this._summaryTypingInterval);
|
||||||
|
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
|
||||||
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
||||||
this._dataManagerUnsubscribe?.();
|
this._dataManagerUnsubscribe?.();
|
||||||
this._summaryRequest?.abort();
|
this._summaryRequest?.abort();
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// globals exist: Cesium's widget bundle probes for a real `document` at module
|
// globals exist: Cesium's widget bundle probes for a real `document` at module
|
||||||
// scope and a partial stub sends it down the browser path. Hence hook →
|
// scope and a partial stub sends it down the browser path. Hence hook →
|
||||||
// import → install DOM, in that order.
|
// import → install DOM, in that order.
|
||||||
|
//
|
||||||
|
// The same hook is why the live AI-summary test lives here too: this is the
|
||||||
|
// one file that can construct a real IntelHUD.
|
||||||
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';
|
||||||
@@ -33,7 +36,8 @@ registerHooks({
|
|||||||
return {
|
return {
|
||||||
format: 'module',
|
format: 'module',
|
||||||
shortCircuit: true,
|
shortCircuit: true,
|
||||||
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
source:
|
||||||
|
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next(url, context);
|
return next(url, context);
|
||||||
@@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15;
|
|||||||
*/
|
*/
|
||||||
function installHudEnvironment() {
|
function installHudEnvironment() {
|
||||||
const elements = new Map(
|
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: '' }]),
|
'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;
|
const previousDocument = globalThis.document;
|
||||||
|
const cameraListeners = {};
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
getElementById: (id) => elements.get(id) ?? null,
|
getElementById: (id) => elements.get(id) ?? null,
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
@@ -78,12 +92,24 @@ function installHudEnvironment() {
|
|||||||
height: SFO_ELLIPSOIDAL_M,
|
height: SFO_ELLIPSOIDAL_M,
|
||||||
},
|
},
|
||||||
computeViewRectangle: () => undefined,
|
computeViewRectangle: () => undefined,
|
||||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
moveStart: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
cameraListeners.moveStart = listener;
|
||||||
|
},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
|
moveEnd: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
cameraListeners.moveEnd = listener;
|
||||||
|
},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
viewer,
|
viewer,
|
||||||
|
cameraListeners,
|
||||||
restore() {
|
restore() {
|
||||||
if (previousDocument === undefined) delete globalThis.document;
|
if (previousDocument === undefined) delete globalThis.document;
|
||||||
else globalThis.document = previousDocument;
|
else globalThis.document = previousDocument;
|
||||||
@@ -93,7 +119,9 @@ function installHudEnvironment() {
|
|||||||
|
|
||||||
test('hud.js corrects the camera height to MSL through the geoid module', () => {
|
test('hud.js corrects the camera height to MSL through the geoid module', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s),
|
has(
|
||||||
|
/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one',
|
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one',
|
||||||
);
|
);
|
||||||
@@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () =>
|
|||||||
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
|
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/),
|
has(
|
||||||
|
/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'a failed geoid load must leave the readout uncorrected, not unhandled',
|
'a failed geoid load must leave the readout uncorrected, not unhandled',
|
||||||
);
|
);
|
||||||
@@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => {
|
|||||||
// Both are on screen together; a viewer reading "ALT -15M" in one corner and
|
// Both are on screen together; a viewer reading "ALT -15M" in one corner and
|
||||||
// "ALT: 17m" in the other has found a bug, not a distinction.
|
// "ALT: 17m" in the other has found a bug, not a distinction.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/),
|
has(
|
||||||
|
/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'the summary altitude tag must prefer the MSL datum and fall back to the raw height',
|
'the summary altitude tag must prefer the MSL datum and fall back to the raw height',
|
||||||
);
|
);
|
||||||
@@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
|
|||||||
// Tick 1 — cold. This is also the tick that requests the grid.
|
// Tick 1 — cold. This is also the tick that requests the grid.
|
||||||
hud._updateCameraData();
|
hud._updateCameraData();
|
||||||
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`);
|
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`);
|
||||||
assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`);
|
assert.match(
|
||||||
|
summary(),
|
||||||
|
/\| ALT -15M \|/,
|
||||||
|
`cold summary tag, got ${summary()}`,
|
||||||
|
);
|
||||||
|
|
||||||
// The HUD registered its own continuation on this same shared promise
|
// The HUD registered its own continuation on this same shared promise
|
||||||
// during tick 1, and it registered first, so awaiting here means its
|
// during tick 1, and it registered first, so awaiting here means its
|
||||||
@@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
|
|||||||
env.restore();
|
env.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a summary asked for one view is dropped once the camera moves', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
pending.push({
|
||||||
|
context,
|
||||||
|
reply: (summary) =>
|
||||||
|
resolve({ ok: true, status: 200, data: { summary, error: null } }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
let place = 'Yellowstone';
|
||||||
|
hud._summaryContext = async () => ({ place });
|
||||||
|
const summary = () => env.elements.get('hud-summary').textContent;
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// The periodic tick's call (force=false): it asks only for a dirty
|
||||||
|
// summary. Asked over Yellowstone; the camera leaves before the reply
|
||||||
|
// lands.
|
||||||
|
const first = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
pending[0].reply('Canyon Junction Wyoming');
|
||||||
|
await first;
|
||||||
|
assert.doesNotMatch(
|
||||||
|
summary(),
|
||||||
|
/Canyon Junction/,
|
||||||
|
'a reply for the old view is dropped',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The next tick, with the camera still moving (a follow camera or an
|
||||||
|
// orbit emits no moveEnd), asks about where it is now and paints it.
|
||||||
|
place = 'Atlantic';
|
||||||
|
const moving = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 2, 'the move marked the summary dirty');
|
||||||
|
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
|
||||||
|
pending[1].reply('North Atlantic Ocean');
|
||||||
|
await moving;
|
||||||
|
assert.equal(summary(), 'North Atlantic Ocean');
|
||||||
|
} finally {
|
||||||
|
for (const request of pending) request.reply('');
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
// Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`.
|
||||||
|
let ignoreAbort = false;
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({ signal, reject });
|
||||||
|
if (!ignoreAbort)
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'Yellowstone' });
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// The move aborts the request in flight, and the abort paints nothing.
|
||||||
|
let update = hud._updateSummary(false, true);
|
||||||
|
await settle();
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
assert.equal(pending[0].signal.aborted, true, 'the request is aborted');
|
||||||
|
await update;
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
|
||||||
|
// A request that fails after the move (an error, the 5 s timeout) does
|
||||||
|
// not paint the old view's fallback line either.
|
||||||
|
ignoreAbort = true;
|
||||||
|
update = hud._updateSummary(false, true);
|
||||||
|
await settle();
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
pending[1].reject(new Error('network'));
|
||||||
|
await update;
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
|
||||||
|
// Nor does a context build that throws once the camera has moved.
|
||||||
|
hud._summaryContext = async () => {
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
throw new Error('view target unavailable');
|
||||||
|
};
|
||||||
|
element.textContent = 'shown before the move';
|
||||||
|
await hud._updateSummary(false, true);
|
||||||
|
assert.equal(element.textContent, 'shown before the move');
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled summary retries the same context but completed answers stay reusable', async () => {
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({
|
||||||
|
context,
|
||||||
|
reply: (summary) =>
|
||||||
|
resolve({ ok: true, status: 200, data: { summary } }),
|
||||||
|
});
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||||
|
hud._updateCameraData();
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const first = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
// An orbit can leave all semantic place/layer labels unchanged. The next
|
||||||
|
// periodic tick must retry even though its context equals the aborted one.
|
||||||
|
const retry = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(
|
||||||
|
pending.length,
|
||||||
|
2,
|
||||||
|
'the cancelled signature cannot suppress a retry',
|
||||||
|
);
|
||||||
|
assert.deepEqual(pending[1].context, pending[0].context);
|
||||||
|
pending[1].reply('San Francisco Bay Urban Landscape');
|
||||||
|
await retry;
|
||||||
|
assert.equal(
|
||||||
|
env.elements.get('hud-summary').textContent,
|
||||||
|
'San Francisco Bay Urban Landscape',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Movement without an active request must not discard a completed answer.
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(pending.length, 2, 'completed context still deduplicates');
|
||||||
|
} finally {
|
||||||
|
for (const request of pending) request.reply('');
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
const pending = [];
|
||||||
|
const summaryService = {
|
||||||
|
summarize: (context, { signal }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
pending.push({ resolve });
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
// The same place before and after: a slow follow camera, a small pan.
|
||||||
|
hud._summaryContext = async () => ({ place: 'Lisboa' });
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
hud._updateCameraData();
|
||||||
|
|
||||||
|
// A move aborts the request; the next tick over the same place asks again.
|
||||||
|
let update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await update;
|
||||||
|
update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 2, 'asked again after the abort');
|
||||||
|
|
||||||
|
// That one times out at 5 s: retried like any failure.
|
||||||
|
t.mock.timers.tick(5_000);
|
||||||
|
await update;
|
||||||
|
update = hud._updateSummary(false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(pending.length, 3, 'asked again after the timeout');
|
||||||
|
pending[2].resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { summary: 'Lisboa Rua Augusta', error: null },
|
||||||
|
});
|
||||||
|
await update;
|
||||||
|
|
||||||
|
// A reply still typing stops when the camera starts moving.
|
||||||
|
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
|
||||||
|
t.mock.timers.tick(48);
|
||||||
|
const typed = element.textContent;
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
t.mock.timers.tick(2_000);
|
||||||
|
assert.equal(
|
||||||
|
element.textContent,
|
||||||
|
typed,
|
||||||
|
'no more characters after the move',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a summary interrupted while typing can retry unchanged context', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
const env = installHudEnvironment();
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
let calls = 0;
|
||||||
|
const text = 'San Francisco Bay Urban Landscape';
|
||||||
|
const summaryService = {
|
||||||
|
summarize: async () => {
|
||||||
|
calls++;
|
||||||
|
return { ok: true, status: 200, data: { summary: text } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let hud;
|
||||||
|
try {
|
||||||
|
hud = new IntelHUD(env.viewer, { summaryService });
|
||||||
|
hud._summaryContext = async () => ({ place: 'San Francisco' });
|
||||||
|
hud._updateCameraData();
|
||||||
|
await hud._updateSummary(true);
|
||||||
|
t.mock.timers.tick(48);
|
||||||
|
const element = env.elements.get('hud-summary');
|
||||||
|
assert.notEqual(element.textContent, text, 'the answer is still typing');
|
||||||
|
assert.equal(
|
||||||
|
hud._summaryRequest,
|
||||||
|
null,
|
||||||
|
'the network request already finished',
|
||||||
|
);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
assert.equal(hud._summaryTypingInterval, null);
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(
|
||||||
|
calls,
|
||||||
|
2,
|
||||||
|
'a partially displayed answer must not suppress retry',
|
||||||
|
);
|
||||||
|
assert.equal(element.textContent, text);
|
||||||
|
env.cameraListeners.moveStart();
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
assert.equal(calls, 2, 'the fully displayed answer remains reusable');
|
||||||
|
} finally {
|
||||||
|
hud?.destroy();
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
env.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1153,6 +1153,7 @@
|
|||||||
"OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad",
|
"OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad",
|
||||||
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
|
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
|
||||||
"Potential tropical cyclone": "Posible ciclón tropical",
|
"Potential tropical cyclone": "Posible ciclón tropical",
|
||||||
|
"Post-tropical cyclone": "Ciclón postropical",
|
||||||
"Hurricane": "Huracán",
|
"Hurricane": "Huracán",
|
||||||
"Tropical storm": "Tormenta tropical",
|
"Tropical storm": "Tormenta tropical",
|
||||||
"Tropical depression": "Depresión tropical",
|
"Tropical depression": "Depresión tropical",
|
||||||
|
|||||||
@@ -1169,6 +1169,7 @@
|
|||||||
"OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества",
|
"OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества",
|
||||||
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.",
|
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.",
|
||||||
"Potential tropical cyclone": "Потенциальный тропический циклон",
|
"Potential tropical cyclone": "Потенциальный тропический циклон",
|
||||||
|
"Post-tropical cyclone": "Посттропический циклон",
|
||||||
"Hurricane": "Ураган",
|
"Hurricane": "Ураган",
|
||||||
"Tropical storm": "Тропический шторм",
|
"Tropical storm": "Тропический шторм",
|
||||||
"Tropical depression": "Тропическая депрессия",
|
"Tropical depression": "Тропическая депрессия",
|
||||||
|
|||||||
@@ -530,6 +530,7 @@
|
|||||||
"Cyclone advisories unavailable": "气旋公告不可用",
|
"Cyclone advisories unavailable": "气旋公告不可用",
|
||||||
"Cyclone data unavailable": "气旋数据不可用",
|
"Cyclone data unavailable": "气旋数据不可用",
|
||||||
"Potential tropical cyclone": "潜在热带气旋",
|
"Potential tropical cyclone": "潜在热带气旋",
|
||||||
|
"Post-tropical cyclone": "后热带气旋",
|
||||||
"Hurricane": "飓风",
|
"Hurricane": "飓风",
|
||||||
"Tropical storm": "热带风暴",
|
"Tropical storm": "热带风暴",
|
||||||
"Tropical depression": "热带低压",
|
"Tropical depression": "热带低压",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
MARKER_SCALE_BY_DISTANCE,
|
MARKER_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { boxContains } from './model.js';
|
import { boxContains } from './model.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { msg } from '../../i18n/index.js';
|
||||||
import { createAlprFloorResolver } from './floors.js';
|
import { createAlprFloorResolver } from './floors.js';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './overlay.js';
|
||||||
import {
|
import {
|
||||||
@@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
layerName: 'ALPR Cameras',
|
layerName: 'ALPR Cameras',
|
||||||
source:
|
source:
|
||||||
source.attribution?.description || source.label || 'Camera source',
|
source.attribution?.description || source.label || 'Camera source',
|
||||||
label: t('ALPR camera'),
|
label: msg('ALPR camera'),
|
||||||
latitude: record.latitude,
|
latitude: record.latitude,
|
||||||
longitude: record.longitude,
|
longitude: record.longitude,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
|
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
|
||||||
function presentAwarenessDistance(meters) {
|
export function presentAwarenessDistance(meters) {
|
||||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||||
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
|
const wholeMeters = Math.round(meters);
|
||||||
|
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
|
||||||
const digits = meters < 10000 ? 1 : 0;
|
const digits = meters < 10000 ? 1 : 0;
|
||||||
return formatQuantity(meters / 1000, 'km', {
|
return formatQuantity(meters / 1000, 'km', {
|
||||||
minimumFractionDigits: digits,
|
minimumFractionDigits: digits,
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
|
|||||||
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
|
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
|
||||||
const errorText = (error) =>
|
const errorText = (error) =>
|
||||||
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
|
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
|
||||||
|
// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a
|
||||||
|
// potential tropical cyclone, and subtropical systems are STS and STD.
|
||||||
const CLASSIFICATION_NAMES = Object.freeze({
|
const CLASSIFICATION_NAMES = Object.freeze({
|
||||||
PTC: msg('Potential tropical cyclone'),
|
PC: msg('Potential tropical cyclone'),
|
||||||
|
PTC: msg('Post-tropical cyclone'),
|
||||||
HU: msg('Hurricane'),
|
HU: msg('Hurricane'),
|
||||||
TS: msg('Tropical storm'),
|
TS: msg('Tropical storm'),
|
||||||
TD: msg('Tropical depression'),
|
TD: msg('Tropical depression'),
|
||||||
SS: msg('Subtropical storm'),
|
STS: msg('Subtropical storm'),
|
||||||
SD: msg('Subtropical depression'),
|
STD: msg('Subtropical depression'),
|
||||||
});
|
});
|
||||||
const classificationName = (code) =>
|
const classificationName = (code) =>
|
||||||
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
||||||
|
|||||||
@@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr
|
|||||||
});
|
});
|
||||||
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
|
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
|
||||||
for (const [code, label] of [
|
for (const [code, label] of [
|
||||||
['PTC', 'Potential tropical cyclone'],
|
['PC', 'Potential tropical cyclone'],
|
||||||
|
['PTC', 'Post-tropical cyclone'],
|
||||||
['HU', 'Hurricane'],
|
['HU', 'Hurricane'],
|
||||||
['TS', 'Tropical storm'],
|
['TS', 'Tropical storm'],
|
||||||
['TD', 'Tropical depression'],
|
['TD', 'Tropical depression'],
|
||||||
['SS', 'Subtropical storm'],
|
['STS', 'Subtropical storm'],
|
||||||
['SD', 'Subtropical depression'],
|
['STD', 'Subtropical depression'],
|
||||||
['EX', 'EX'],
|
['EX', 'EX'],
|
||||||
['UNKNOWN', 'UNKNOWN'],
|
['UNKNOWN', 'UNKNOWN'],
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export const FLIGHT_PROGRESS_MS = 250;
|
|||||||
export function routeDistanceText(meters) {
|
export function routeDistanceText(meters) {
|
||||||
if (!Number.isFinite(meters) || meters < 0) return '';
|
if (!Number.isFinite(meters) || meters < 0) return '';
|
||||||
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
|
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
|
||||||
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
|
const tens = Math.round(meters / 10) * 10;
|
||||||
|
if (tens < 1000) return formatQuantity(tens, 'm');
|
||||||
const km = meters / 1000;
|
const km = meters / 1000;
|
||||||
return km < 10
|
return km < 10
|
||||||
? formatQuantity(km, 'km', {
|
? formatQuantity(km, 'km', {
|
||||||
@@ -130,7 +131,8 @@ export function routeDistanceText(meters) {
|
|||||||
*/
|
*/
|
||||||
export function routeDurationText(seconds) {
|
export function routeDurationText(seconds) {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||||
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
|
const wholeSeconds = Math.round(seconds);
|
||||||
|
if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's');
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.round(seconds / 60);
|
||||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
satelliteShortName,
|
satelliteShortName,
|
||||||
fireDetectionKey,
|
fireDetectionKey,
|
||||||
accentForSeverity,
|
accentForSeverity,
|
||||||
|
formatFrpMw,
|
||||||
} from '../../data/firmsLabels.js';
|
} from '../../data/firmsLabels.js';
|
||||||
import {
|
import {
|
||||||
VIEW_PADDING,
|
VIEW_PADDING,
|
||||||
@@ -480,14 +481,15 @@ export function createModel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatFrp(frp) {
|
function formatFrp(frp) {
|
||||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
return formatFrpMw(frp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
|
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
|
||||||
|
|
||||||
function formatFrpLabel(frp) {
|
function formatFrpLabel(frp) {
|
||||||
const digits = frp >= 10 ? 0 : 1;
|
const text = formatFrpMw(frp);
|
||||||
return formatNumber(frp, {
|
const digits = text.includes('.') ? 1 : 0;
|
||||||
|
return formatNumber(Number(text), {
|
||||||
minimumFractionDigits: digits,
|
minimumFractionDigits: digits,
|
||||||
maximumFractionDigits: digits,
|
maximumFractionDigits: digits,
|
||||||
useGrouping: false,
|
useGrouping: false,
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ export function createQueries({
|
|||||||
if (!fire) return null;
|
if (!fire) return null;
|
||||||
return {
|
return {
|
||||||
id: fireDetectionKey(fire),
|
id: fireDetectionKey(fire),
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(fire.frp),
|
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||||
}),
|
|
||||||
latitude: fire.lat,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
frp: fire.frp,
|
frp: fire.frp,
|
||||||
@@ -118,9 +117,8 @@ export function createQueries({
|
|||||||
latitude: strongest.lat,
|
latitude: strongest.lat,
|
||||||
longitude: strongest.lon,
|
longitude: strongest.lon,
|
||||||
frp: strongest.frp,
|
frp: strongest.frp,
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(strongest.frp),
|
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -193,9 +193,8 @@ export function createSelection({
|
|||||||
layerName: name,
|
layerName: name,
|
||||||
source: 'NASA FIRMS',
|
source: 'NASA FIRMS',
|
||||||
dataSource: layerState._dataSource,
|
dataSource: layerState._dataSource,
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(fire.frp),
|
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||||
}),
|
|
||||||
latitude: fire.lat,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export function createMotion({
|
|||||||
publishFocusTargetFromCachedPosition,
|
publishFocusTargetFromCachedPosition,
|
||||||
} = services.focus;
|
} = services.focus;
|
||||||
|
|
||||||
|
// Fixes leave the cache with their history; corrected positions invalidate
|
||||||
|
// the local frame even when the existing Cartesian is mutated in place.
|
||||||
|
const fixFrames = new WeakMap();
|
||||||
|
|
||||||
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
||||||
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
||||||
// jitter minted fresh keys every poll → thousands of upstream points per
|
// jitter minted fresh keys every poll → thousands of upstream points per
|
||||||
@@ -124,7 +128,7 @@ export function createMotion({
|
|||||||
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
|
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _deadReckon(icao24, result) {
|
function _deadReckon(icao24, result, sampledRenderTime) {
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
||||||
const position = flightState._billboards.get(icao24)?.position;
|
const position = flightState._billboards.get(icao24)?.position;
|
||||||
@@ -149,11 +153,13 @@ export function createMotion({
|
|||||||
const out = result || new Cesium.Cartesian3();
|
const out = result || new Cesium.Cartesian3();
|
||||||
// Render one poll interval behind real time so we interpolate between
|
// Render one poll interval behind real time so we interpolate between
|
||||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime =
|
||||||
Cesium.JulianDate.now(),
|
sampledRenderTime ??
|
||||||
-RENDER_DELAY_SEC,
|
Cesium.JulianDate.addSeconds(
|
||||||
flightState._scratchRenderTime,
|
Cesium.JulianDate.now(flightState._scratchRenderTime),
|
||||||
);
|
-RENDER_DELAY_SEC,
|
||||||
|
flightState._scratchRenderTime,
|
||||||
|
);
|
||||||
|
|
||||||
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
||||||
for (let i = history.length - 1; i >= 1; i--) {
|
for (let i = history.length - 1; i >= 1; i--) {
|
||||||
@@ -286,12 +292,31 @@ export function createMotion({
|
|||||||
0,
|
0,
|
||||||
flightState._scratchOffset,
|
flightState._scratchOffset,
|
||||||
);
|
);
|
||||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
let frame = fixFrames.get(fix);
|
||||||
fix.position,
|
if (!frame) {
|
||||||
Cesium.Ellipsoid.WGS84,
|
frame = {
|
||||||
flightState._scratchEnu,
|
position: new Cesium.Cartesian3(),
|
||||||
|
matrix: new Cesium.Matrix4(),
|
||||||
|
};
|
||||||
|
fixFrames.set(fix, frame);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!frame.valid ||
|
||||||
|
!Cesium.Cartesian3.equals(frame.position, fix.position)
|
||||||
|
) {
|
||||||
|
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||||
|
fix.position,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
frame.matrix,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.clone(fix.position, frame.position);
|
||||||
|
frame.valid = true;
|
||||||
|
}
|
||||||
|
return Cesium.Matrix4.multiplyByPoint(
|
||||||
|
frame.matrix,
|
||||||
|
flightState._scratchOffset,
|
||||||
|
out,
|
||||||
);
|
);
|
||||||
return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createMotion } from './motion.js';
|
||||||
|
import { arcOffsetEnu } from '../../data/motionModel.js';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const flightState = {
|
||||||
|
records: { data: new Map() },
|
||||||
|
_positionHistory: new Map(),
|
||||||
|
_focusEvidenceIds: new Set(),
|
||||||
|
_scratchArc: {},
|
||||||
|
_scratchOffset: new Cesium.Cartesian3(),
|
||||||
|
_scratchRenderTime: new Cesium.JulianDate(),
|
||||||
|
};
|
||||||
|
const motion = createMotion({
|
||||||
|
flightState,
|
||||||
|
services: { groundFloor: {}, camera: {}, focus: {} },
|
||||||
|
parts: {},
|
||||||
|
});
|
||||||
|
return { flightState, motion };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedPosition(fix, dt, turnRate, transform) {
|
||||||
|
const arc = {};
|
||||||
|
arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc);
|
||||||
|
return Cesium.Matrix4.multiplyByPoint(
|
||||||
|
transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()),
|
||||||
|
new Cesium.Cartesian3(arc.east, arc.north, 0),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(actual, expected) {
|
||||||
|
assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => {
|
||||||
|
const { motion } = fixture();
|
||||||
|
const transform = Cesium.Transforms.eastNorthUpToFixedFrame;
|
||||||
|
const spy = t.mock.method(
|
||||||
|
Cesium.Transforms,
|
||||||
|
'eastNorthUpToFixedFrame',
|
||||||
|
transform,
|
||||||
|
);
|
||||||
|
const fix = {
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(-97, 30, 200),
|
||||||
|
velocity: 90,
|
||||||
|
track: 70,
|
||||||
|
};
|
||||||
|
const out = new Cesium.Cartesian3();
|
||||||
|
for (const dt of [-30, 5, 30, 60]) {
|
||||||
|
motion._extrapolateFix(fix, {}, dt, out, 1.5);
|
||||||
|
close(out, expectedPosition(fix, dt, 1.5, transform));
|
||||||
|
}
|
||||||
|
assert.equal(spy.mock.callCount(), 1);
|
||||||
|
|
||||||
|
// Ground reflooring mutates an existing fix's Cartesian in place.
|
||||||
|
Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position);
|
||||||
|
motion._extrapolateFix(fix, {}, 30, out, 1.5);
|
||||||
|
close(out, expectedPosition(fix, 30, 1.5, transform));
|
||||||
|
assert.equal(spy.mock.callCount(), 2);
|
||||||
|
|
||||||
|
fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000);
|
||||||
|
motion._extrapolateFix(fix, {}, 30, out, -2);
|
||||||
|
close(out, expectedPosition(fix, 30, -2, transform));
|
||||||
|
assert.equal(spy.mock.callCount(), 3);
|
||||||
|
|
||||||
|
fix.velocity = 210;
|
||||||
|
fix.track = 359;
|
||||||
|
motion._extrapolateFix(fix, {}, 15, out, -1);
|
||||||
|
close(out, expectedPosition(fix, 15, -1, transform));
|
||||||
|
assert.equal(spy.mock.callCount(), 3);
|
||||||
|
|
||||||
|
const replacement = { ...fix };
|
||||||
|
motion._extrapolateFix(replacement, {}, 15, out, -1);
|
||||||
|
close(out, expectedPosition(replacement, 15, -1, transform));
|
||||||
|
assert.equal(spy.mock.callCount(), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stationary fixes do not build a frame until motion needs it', (t) => {
|
||||||
|
const { motion } = fixture();
|
||||||
|
const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame');
|
||||||
|
const fix = {
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||||
|
velocity: 0,
|
||||||
|
track: 90,
|
||||||
|
};
|
||||||
|
const out = new Cesium.Cartesian3();
|
||||||
|
close(motion._extrapolateFix(fix, {}, 30, out), fix.position);
|
||||||
|
fix.velocity = 10;
|
||||||
|
close(motion._extrapolateFix(fix, {}, 0, out), fix.position);
|
||||||
|
assert.equal(spy.mock.callCount(), 0);
|
||||||
|
motion._extrapolateFix(fix, {}, 30, out);
|
||||||
|
assert.equal(spy.mock.callCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fleet render time is shared without resampling or mutating the supplied instant', (t) => {
|
||||||
|
const { flightState, motion } = fixture();
|
||||||
|
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||||
|
const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
|
||||||
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
|
start,
|
||||||
|
30,
|
||||||
|
new Cesium.JulianDate(),
|
||||||
|
);
|
||||||
|
const savedTime = Cesium.JulianDate.clone(renderTime);
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
time: start,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
|
||||||
|
track: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: end,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
|
||||||
|
track: 90,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const id of ['one', 'two']) {
|
||||||
|
flightState.records.data.set(id, { velocity: 100, true_track: 90 });
|
||||||
|
flightState._positionHistory.set(id, history);
|
||||||
|
}
|
||||||
|
t.mock.method(Cesium.JulianDate, 'now', () => {
|
||||||
|
throw new Error('unexpected per-contact clock read');
|
||||||
|
});
|
||||||
|
const expected = Cesium.Cartesian3.lerp(
|
||||||
|
history[0].position,
|
||||||
|
history[1].position,
|
||||||
|
0.5,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
for (const id of ['one', 'two'])
|
||||||
|
close(
|
||||||
|
motion._deadReckon(id, new Cesium.Cartesian3(), renderTime),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
|
||||||
|
const { flightState, motion } = fixture();
|
||||||
|
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
time: start,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
|
||||||
|
track: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()),
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
|
||||||
|
track: 90,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
||||||
|
flightState._positionHistory.set('tracked', history);
|
||||||
|
let elapsed = 60;
|
||||||
|
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
|
||||||
|
assert.equal(result, flightState._scratchRenderTime);
|
||||||
|
return Cesium.JulianDate.addSeconds(start, elapsed, result);
|
||||||
|
});
|
||||||
|
const out = new Cesium.Cartesian3();
|
||||||
|
close(
|
||||||
|
motion._deadReckon('tracked', out),
|
||||||
|
Cesium.Cartesian3.lerp(
|
||||||
|
history[0].position,
|
||||||
|
history[1].position,
|
||||||
|
0.5,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
elapsed = 75;
|
||||||
|
close(
|
||||||
|
motion._deadReckon('tracked', out),
|
||||||
|
Cesium.Cartesian3.lerp(
|
||||||
|
history[0].position,
|
||||||
|
history[1].position,
|
||||||
|
0.75,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(spy.mock.callCount(), 2);
|
||||||
|
});
|
||||||
@@ -452,8 +452,8 @@ export function createQueries({
|
|||||||
const isTracked = icao24 === flightState._trackedIcao;
|
const isTracked = icao24 === flightState._trackedIcao;
|
||||||
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
|
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
|
||||||
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
|
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
|
||||||
const model = flightState._models.get(icao24);
|
const model = flightState._models.get(icao24) ?? null;
|
||||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
|
||||||
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
|
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
let object = flightState._detectionObjects.get(icao24);
|
let object = flightState._detectionObjects.get(icao24);
|
||||||
@@ -491,8 +491,19 @@ export function createQueries({
|
|||||||
object.position = pos;
|
object.position = pos;
|
||||||
object.skipLabel = isTracked;
|
object.skipLabel = isTracked;
|
||||||
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
|
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
|
||||||
const id = _contactLabel(icao24, info);
|
const callsign = info?.callsign;
|
||||||
if (object.id !== id) object.id = id;
|
const registration = info?.registration;
|
||||||
|
// Records and enrichment may change in place. Cache by field values,
|
||||||
|
// while keeping the live visual anchor above independent of text.
|
||||||
|
if (
|
||||||
|
object.id === undefined ||
|
||||||
|
object._labelCallsign !== callsign ||
|
||||||
|
object._labelRegistration !== registration
|
||||||
|
) {
|
||||||
|
object.id = _contactLabel(icao24, info);
|
||||||
|
object._labelCallsign = callsign;
|
||||||
|
object._labelRegistration = registration;
|
||||||
|
}
|
||||||
const altitude = info?.altitude;
|
const altitude = info?.altitude;
|
||||||
if (object._altitude !== altitude) {
|
if (object._altitude !== altitude) {
|
||||||
object._altitude = altitude;
|
object._altitude = altitude;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createQueries } from './queries.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const info = {
|
||||||
|
callsign: ' TEST123 ',
|
||||||
|
registration: ' N123 ',
|
||||||
|
altitude: 1000,
|
||||||
|
};
|
||||||
|
const bb = { show: true, position: new Cesium.Cartesian3(1, 2, 3) };
|
||||||
|
const flightState = {
|
||||||
|
feed: {},
|
||||||
|
_billboardCollection: { show: true },
|
||||||
|
_billboards: new Map([['abc123', bb]]),
|
||||||
|
_models: new Map(),
|
||||||
|
_detectionObjects: new Map(),
|
||||||
|
records: { data: new Map([['abc123', info]]) },
|
||||||
|
};
|
||||||
|
let formats = 0;
|
||||||
|
const services = {
|
||||||
|
aircraftPresentation: {},
|
||||||
|
militaryRegistry: {},
|
||||||
|
camera: {},
|
||||||
|
focus: {},
|
||||||
|
recession: {},
|
||||||
|
labels: {
|
||||||
|
formatFlightLevel: (altitude) => {
|
||||||
|
formats++;
|
||||||
|
return String(altitude);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const parts = {
|
||||||
|
testing: {},
|
||||||
|
tracking: { _trackedModelRegimeActive: () => true },
|
||||||
|
motion: { _trackedVisualCached: () => null },
|
||||||
|
};
|
||||||
|
parts.rendering = createRendering({ flightState, services, parts });
|
||||||
|
const queries = createQueries({ flightState, services, parts }).methods;
|
||||||
|
return { flightState, info, bb, parts, queries, formats: () => formats };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('candidate text follows in-place enrichment without freezing identity or positions', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const [first] = f.queries.getDetectableObjects();
|
||||||
|
assert.equal(first.id, 'TEST123');
|
||||||
|
assert.equal(first.sourceId, 'abc123');
|
||||||
|
f.bb.position = new Cesium.Cartesian3(4, 5, 6);
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0], first);
|
||||||
|
assert.equal(first.position, f.bb.position);
|
||||||
|
assert.equal(f.formats(), 1);
|
||||||
|
f.info.callsign = ' NEW456 ';
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].id, 'NEW456');
|
||||||
|
f.info.callsign = ' ';
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].id, 'N123');
|
||||||
|
f.info.registration = ' N456 ';
|
||||||
|
f.info.altitude = 2000;
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].id, 'N456');
|
||||||
|
assert.equal(first.metric, '2000');
|
||||||
|
assert.equal(f.formats(), 2);
|
||||||
|
f.flightState.records.data.set('abc123', { ...f.info });
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0], first);
|
||||||
|
f.flightState.records.data.delete('abc123');
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].id, 'abc123');
|
||||||
|
assert.equal(first.sourceId, 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged candidate text is normalized once across paints', (t) => {
|
||||||
|
const f = fixture();
|
||||||
|
const trim = String.prototype.trim;
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(String.prototype, 'trim', function () {
|
||||||
|
if (String(this) === ' TEST123 ') calls++;
|
||||||
|
return trim.call(this);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 20; i++) f.queries.getDetectableObjects();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('candidate collection resolves models once and keeps handoffs live', (t) => {
|
||||||
|
const f = fixture();
|
||||||
|
const get = t.mock.method(f.flightState._models, 'get');
|
||||||
|
const [candidate] = f.queries.getDetectableObjects();
|
||||||
|
assert.equal(get.mock.callCount(), 1);
|
||||||
|
const model = {
|
||||||
|
ready: false,
|
||||||
|
show: true,
|
||||||
|
modelMatrix: Cesium.Matrix4.fromTranslation(
|
||||||
|
new Cesium.Cartesian3(10, 20, 30),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
f.parts.rendering._modelSpec = () => ({
|
||||||
|
visualCenterNative: Cesium.Cartesian3.ZERO,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
f.flightState._models.set('abc123', model);
|
||||||
|
f.bb.show = false;
|
||||||
|
assert.deepEqual(f.queries.getDetectableObjects(), []);
|
||||||
|
model.ready = true;
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0], candidate);
|
||||||
|
assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30));
|
||||||
|
Cesium.Matrix4.setTranslation(
|
||||||
|
model.modelMatrix,
|
||||||
|
new Cesium.Cartesian3(40, 50, 60),
|
||||||
|
model.modelMatrix,
|
||||||
|
);
|
||||||
|
f.queries.getDetectableObjects();
|
||||||
|
assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
|
||||||
|
model.show = false;
|
||||||
|
assert.deepEqual(f.queries.getDetectableObjects(), []);
|
||||||
|
f.bb.show = true;
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].position, f.bb.position);
|
||||||
|
assert.equal(get.mock.callCount(), 6);
|
||||||
|
model.show = true;
|
||||||
|
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
|
||||||
|
assert.equal(get.mock.callCount(), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked ownership and live anchors remain independent of fleet models', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.flightState._trackedIcao = 'abc123';
|
||||||
|
f.flightState._trackedModel = { ready: true, show: true };
|
||||||
|
f.parts.rendering._modelSpec = () => ({});
|
||||||
|
let anchor = new Cesium.Cartesian3(10, 20, 30);
|
||||||
|
f.parts.motion._trackedVisualCached = () => anchor;
|
||||||
|
const [candidate] = f.queries.getDetectableObjects();
|
||||||
|
assert.equal(candidate.position, anchor);
|
||||||
|
assert.equal(candidate.skipLabel, true);
|
||||||
|
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), true);
|
||||||
|
anchor = new Cesium.Cartesian3(40, 50, 60);
|
||||||
|
assert.equal(f.queries.getDetectableObjects()[0].position, anchor);
|
||||||
|
for (const tracked of [
|
||||||
|
null,
|
||||||
|
{ ready: false, show: true },
|
||||||
|
{ ready: true, show: false },
|
||||||
|
]) {
|
||||||
|
f.flightState._trackedModel = tracked;
|
||||||
|
assert.equal(
|
||||||
|
f.parts.rendering._modelOwnsVisual('abc123', { ready: true, show: true }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
f.flightState._trackedModel = { ready: true, show: true };
|
||||||
|
f.parts.tracking._trackedModelRegimeActive = () => false;
|
||||||
|
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false);
|
||||||
|
});
|
||||||
@@ -32,10 +32,12 @@ import {
|
|||||||
TRACKED_MODEL_MIN_PX,
|
TRACKED_MODEL_MIN_PX,
|
||||||
TRACKED_MODEL_MAX_PX,
|
TRACKED_MODEL_MAX_PX,
|
||||||
FLEET_DR_INTERVAL_MS,
|
FLEET_DR_INTERVAL_MS,
|
||||||
|
RENDER_DELAY_SEC,
|
||||||
COURSE_SLEW_DT_MAX_SEC,
|
COURSE_SLEW_DT_MAX_SEC,
|
||||||
ROTATION_REFRESH_MS,
|
ROTATION_REFRESH_MS,
|
||||||
COURSE_MAX_DPS,
|
COURSE_MAX_DPS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -382,17 +384,20 @@ export function createRendering({
|
|||||||
* null or still-loading tracked model from claiming a visual it is not
|
* null or still-loading tracked model from claiming a visual it is not
|
||||||
* drawing yet.
|
* drawing yet.
|
||||||
* @param {string} icao24
|
* @param {string} icao24
|
||||||
|
* @param {Cesium.Model|null} [model] Resolved fleet model; null means absent.
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _modelOwnsVisual(icao24) {
|
function _modelOwnsVisual(icao24, model) {
|
||||||
if (icao24 === flightState._trackedIcao) {
|
if (icao24 === flightState._trackedIcao) {
|
||||||
return (
|
return (
|
||||||
parts.tracking._trackedModelRegimeActive() &&
|
parts.tracking._trackedModelRegimeActive() &&
|
||||||
_modelIsRendering(flightState._trackedModel)
|
_modelIsRendering(flightState._trackedModel)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _modelIsRendering(flightState._models.get(icao24));
|
return _modelIsRendering(
|
||||||
|
model === undefined ? flightState._models.get(icao24) : model,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A model is the visual only once it actually draws — loaded AND shown.
|
/** A model is the visual only once it actually draws — loaded AND shown.
|
||||||
@@ -503,6 +508,7 @@ export function createRendering({
|
|||||||
model = await Cesium.Model.fromGltfAsync({
|
model = await Cesium.Model.fromGltfAsync({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||||
@@ -709,6 +715,7 @@ export function createRendering({
|
|||||||
Cesium.Model.fromGltfAsync({
|
Cesium.Model.fromGltfAsync({
|
||||||
url: resolveAsset(trackedSpec.url),
|
url: resolveAsset(trackedSpec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||||
scale: trackedSpec.scale,
|
scale: trackedSpec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
||||||
@@ -854,6 +861,12 @@ export function createRendering({
|
|||||||
)
|
)
|
||||||
: 0.08;
|
: 0.08;
|
||||||
flightState._lastFleetTickMs = nowMs;
|
flightState._lastFleetTickMs = nowMs;
|
||||||
|
// Every contact in this pass shares the same delayed display instant.
|
||||||
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
|
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
||||||
|
-RENDER_DELAY_SEC,
|
||||||
|
flightState._scratchFleetRenderTime,
|
||||||
|
);
|
||||||
|
|
||||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||||
if (flightState._cockpitContactMode)
|
if (flightState._cockpitContactMode)
|
||||||
@@ -957,7 +970,11 @@ export function createRendering({
|
|||||||
|
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
|
|
||||||
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
const dr = parts.motion._deadReckon(
|
||||||
|
icao24,
|
||||||
|
flightState._scratchFleetPos,
|
||||||
|
renderTime,
|
||||||
|
);
|
||||||
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
||||||
// height, so a grounded contact's sprite ends up under the mesh it taxied
|
// height, so a grounded contact's sprite ends up under the mesh it taxied
|
||||||
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
|
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
|
||||||
|
|||||||
@@ -242,12 +242,12 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._scratchCarto = new Cesium.Cartographic();
|
flightState._scratchCarto = new Cesium.Cartographic();
|
||||||
|
|
||||||
flightState._scratchEnu = new Cesium.Matrix4();
|
|
||||||
|
|
||||||
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
|
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||||
|
|
||||||
flightState._scratchRenderTime = new Cesium.JulianDate();
|
flightState._scratchRenderTime = new Cesium.JulianDate();
|
||||||
|
|
||||||
|
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
|
||||||
|
|
||||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||||
|
|
||||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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°');
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
MODEL_PROX_ADD_M,
|
MODEL_PROX_ADD_M,
|
||||||
MODEL_PROX_KEEP_M,
|
MODEL_PROX_KEEP_M,
|
||||||
} from '../flights/policy.js';
|
} from '../flights/policy.js';
|
||||||
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
|
|
||||||
const specKey = (spec) => `${spec.url}@${spec.scale}`;
|
const specKey = (spec) => `${spec.url}@${spec.scale}`;
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export function createLocalAdsbModels({
|
|||||||
model = await loadModel({
|
model = await loadModel({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color,
|
color,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
ROTATION_REFRESH_MS,
|
ROTATION_REFRESH_MS,
|
||||||
COURSE_MAX_DPS,
|
COURSE_MAX_DPS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -473,6 +474,7 @@ export function createRendering({
|
|||||||
model = await Cesium.Model.fromGltfAsync({
|
model = await Cesium.Model.fromGltfAsync({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||||
@@ -676,6 +678,7 @@ export function createRendering({
|
|||||||
Cesium.Model.fromGltfAsync({
|
Cesium.Model.fromGltfAsync({
|
||||||
url: resolveAsset(trackedSpec.url),
|
url: resolveAsset(trackedSpec.url),
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||||
scale: trackedSpec.scale,
|
scale: trackedSpec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
|
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
|
||||||
|
|||||||
@@ -105,6 +105,25 @@ function pointsOf(sequence) {
|
|||||||
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
|
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile zoom a provider's `zoomFor` answered, or null. Providers written
|
||||||
|
* before the core went slim return `{zoom, kind}`: its zoom is read, and an
|
||||||
|
* `'overview'` level draws nothing, since nothing is drawn from orbit. Any
|
||||||
|
* other answer that is not a whole zoom draws nothing too, rather than
|
||||||
|
* asking for no tiles without a hint.
|
||||||
|
* @param {unknown} level
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function coverageZoom(level) {
|
||||||
|
const zoom =
|
||||||
|
level && typeof level === 'object'
|
||||||
|
? level.kind === 'overview'
|
||||||
|
? null
|
||||||
|
: level.zoom
|
||||||
|
: level;
|
||||||
|
return Number.isInteger(zoom) && zoom >= 0 ? zoom : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Camera-driven sequence lines from any XYZ line-tile source, near the
|
* Camera-driven sequence lines from any XYZ line-tile source, near the
|
||||||
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
|
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
|
||||||
@@ -115,8 +134,9 @@ function pointsOf(sequence) {
|
|||||||
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
|
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
|
||||||
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
|
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
|
||||||
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
|
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
|
||||||
* @param {(heightM: number|null) => number|null} options.zoomFor
|
* @param {(heightM: number|null) => number|{zoom: number, kind?: string}|null} options.zoomFor
|
||||||
* Tile zoom for a camera height above ground; null draws nothing.
|
* Tile zoom for a camera height above ground; null draws nothing. The
|
||||||
|
* older `{zoom, kind}` shape is still read (see `coverageZoom`).
|
||||||
* @param {{coverage: string, selected: string}} options.colors
|
* @param {{coverage: string, selected: string}} options.colors
|
||||||
* @param {string} options.pickPrefix Pick-id prefix of the lines.
|
* @param {string} options.pickPrefix Pick-id prefix of the lines.
|
||||||
* @param {string} options.renderKey Render-governor reason.
|
* @param {string} options.renderKey Render-governor reason.
|
||||||
@@ -380,7 +400,7 @@ export function createLineCoverage({
|
|||||||
const height = Number.isFinite(cameraHeight)
|
const height = Number.isFinite(cameraHeight)
|
||||||
? cameraHeight - (ground ?? 0)
|
? cameraHeight - (ground ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const zoom = zoomFor(height);
|
const zoom = coverageZoom(zoomFor(height));
|
||||||
if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT };
|
if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT };
|
||||||
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
|
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
|
||||||
// short of the horizon, which would stretch the box to the world.
|
// short of the horizon, which would stretch the box to the world.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
|
coverageZoom,
|
||||||
createLineCoverage,
|
createLineCoverage,
|
||||||
freshCoverage,
|
freshCoverage,
|
||||||
ZOOM_IN_HINT,
|
ZOOM_IN_HINT,
|
||||||
@@ -28,7 +29,7 @@ const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } =
|
|||||||
COVERAGE_TUNING;
|
COVERAGE_TUNING;
|
||||||
|
|
||||||
/** Line coverage as the Mapillary provider builds it, over `source`. */
|
/** Line coverage as the Mapillary provider builds it, over `source`. */
|
||||||
function createCoverage({ state, source }) {
|
function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
|
||||||
return createLineCoverage({
|
return createLineCoverage({
|
||||||
state,
|
state,
|
||||||
fetchTile: async (tile, { signal }) =>
|
fetchTile: async (tile, { signal }) =>
|
||||||
@@ -36,7 +37,7 @@ function createCoverage({ state, source }) {
|
|||||||
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
|
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
|
||||||
tile,
|
tile,
|
||||||
),
|
),
|
||||||
zoomFor: coverageZoomForHeight,
|
zoomFor,
|
||||||
colors: COLORS,
|
colors: COLORS,
|
||||||
pickPrefix: PICK_PREFIX.sequence,
|
pickPrefix: PICK_PREFIX.sequence,
|
||||||
renderKey: 'mapillary-coverage',
|
renderKey: 'mapillary-coverage',
|
||||||
@@ -123,7 +124,7 @@ function deferredSource() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup() {
|
function setup({ zoomFor } = {}) {
|
||||||
const centre = tileCentre(-121.4944, 38.5816);
|
const centre = tileCentre(-121.4944, 38.5816);
|
||||||
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
||||||
const source = deferredSource();
|
const source = deferredSource();
|
||||||
@@ -146,7 +147,7 @@ function setup() {
|
|||||||
},
|
},
|
||||||
sequence: { selectedId: null },
|
sequence: { selectedId: null },
|
||||||
};
|
};
|
||||||
const coverage = createCoverage({ state, source });
|
const coverage = createCoverage({ state, source, zoomFor });
|
||||||
const bytes = encodeCoverageTile(centre.tile, {
|
const bytes = encodeCoverageTile(centre.tile, {
|
||||||
sequences: [
|
sequences: [
|
||||||
{
|
{
|
||||||
@@ -239,6 +240,37 @@ test('panning off a tile that is still loading stops counting it as loading', as
|
|||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () => {
|
||||||
|
// Providers written before the core went slim (#1139's Vegbilder) answer
|
||||||
|
// an object: it must not ask for no tiles and say nothing.
|
||||||
|
const { viewer, source, coverage, bytes } = setup({
|
||||||
|
zoomFor: (height) => {
|
||||||
|
const zoom = coverageZoomForHeight(height);
|
||||||
|
return zoom == null ? null : { zoom, kind: 'sequence' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
coverage.refresh();
|
||||||
|
assert.equal(source.calls.length, 1, 'the tile is asked for');
|
||||||
|
assert.equal(source.calls[0].z, 14);
|
||||||
|
source.calls[0].resolve(bytes);
|
||||||
|
await settle();
|
||||||
|
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn');
|
||||||
|
assert.equal(coverage.stats().hint, '');
|
||||||
|
coverage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a zoom level is read from either shape; an overview or a bad answer draws nothing', () => {
|
||||||
|
assert.equal(coverageZoom(14), 14);
|
||||||
|
assert.equal(coverageZoom({ zoom: 15, kind: 'sequence' }), 15);
|
||||||
|
assert.equal(coverageZoom({ zoom: 12 }), 12);
|
||||||
|
assert.equal(coverageZoom({ zoom: 5, kind: 'overview' }), null, 'orbit');
|
||||||
|
assert.equal(coverageZoom(null), null);
|
||||||
|
assert.equal(coverageZoom(undefined), null);
|
||||||
|
assert.equal(coverageZoom({}), null);
|
||||||
|
assert.equal(coverageZoom(14.5), null);
|
||||||
|
assert.equal(coverageZoom('14'), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
|
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
|
||||||
const { viewer, source, state, coverage, bytes } = setup();
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
|
|||||||
@@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js';
|
|||||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||||
import { decodeFlowTile } from './flowDecode.js';
|
import { decodeFlowTile } from './flowDecode.js';
|
||||||
|
|
||||||
|
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the proxy answered a tile from its cache past the refresh window
|
||||||
|
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
|
||||||
|
* fails), and when the proxy fetched it.
|
||||||
|
*/
|
||||||
|
function tileFreshness(response) {
|
||||||
|
const status = response.headers?.get?.('x-tomtom-cache') || '';
|
||||||
|
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
|
||||||
|
return {
|
||||||
|
stale: status.startsWith('STALE'),
|
||||||
|
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
||||||
export function createFlowTileSource({
|
export function createFlowTileSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
// Freshness travels in response headers, but the tile cache keeps only
|
||||||
|
// decoded segments: hold each response's freshness until its tile decodes,
|
||||||
|
// then key it by the decoded tile, which the cache hands back on a hit.
|
||||||
|
const pending = new Map();
|
||||||
|
const freshness = new WeakMap();
|
||||||
const tiles = createVectorTileSource({
|
const tiles = createVectorTileSource({
|
||||||
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
|
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
|
||||||
allowedOrigin: 'http://localhost',
|
allowedOrigin: 'http://localhost',
|
||||||
decode: (bytes, z, x, y) =>
|
decode: (bytes, z, x, y) => {
|
||||||
decodeFlowTile(bytes, z, x, y, { strict: true })
|
// Take the mark before decoding, so a tile that fails to decode
|
||||||
|
// leaves nothing behind.
|
||||||
|
const path = FLOW_TILE_PATH(z, x, y);
|
||||||
|
const entry = pending.get(path);
|
||||||
|
const mark = entry?.mark;
|
||||||
|
entry?.release();
|
||||||
|
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
||||||
.filter(isDrivableFlowSegment)
|
.filter(isDrivableFlowSegment)
|
||||||
.flatMap((segment) =>
|
.flatMap((segment) =>
|
||||||
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
||||||
...segment,
|
...segment,
|
||||||
coords,
|
coords,
|
||||||
})),
|
})),
|
||||||
),
|
);
|
||||||
fetchImpl,
|
if (mark) freshness.set(segments, mark);
|
||||||
|
return segments;
|
||||||
|
},
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
const response = await fetchImpl(url, init);
|
||||||
|
if (response.ok && !init.signal.aborted) {
|
||||||
|
const path = String(url);
|
||||||
|
const entry = {
|
||||||
|
mark: tileFreshness(response),
|
||||||
|
release() {
|
||||||
|
if (pending.get(path) === entry) pending.delete(path);
|
||||||
|
init.signal.removeEventListener('abort', entry.release);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
pending.get(path)?.release();
|
||||||
|
pending.set(path, entry);
|
||||||
|
// The vector source aborts this signal for failed/cancelled body reads,
|
||||||
|
// including size-limit failures that never reach decode.
|
||||||
|
init.signal.addEventListener('abort', entry.release, { once: true });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
ttlMs: 120_000,
|
ttlMs: 120_000,
|
||||||
maxTiles: 16,
|
maxTiles: 16,
|
||||||
});
|
});
|
||||||
let partial = false;
|
let partial = false;
|
||||||
/**
|
/**
|
||||||
* Flow segments for bounds, with whether any covering tile failed to load
|
* Flow segments for bounds, with whether any covering tile failed to load
|
||||||
* for this request.
|
* for this request, whether any came from the proxy's stale cache, and when
|
||||||
|
* the oldest tile was fetched.
|
||||||
*/
|
*/
|
||||||
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
||||||
const result = await tiles.fetchBounds(bounds, {
|
const result = await tiles.fetchBounds(bounds, {
|
||||||
@@ -44,7 +93,16 @@ export function createFlowTileSource({
|
|||||||
coords,
|
coords,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
return { segments, partial: result.partial === true };
|
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
||||||
|
const times = marks
|
||||||
|
.map((mark) => mark.fetchedAt)
|
||||||
|
.filter((at) => Number.isFinite(at));
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
partial: result.partial === true,
|
||||||
|
stale: marks.some((mark) => mark.stale === true),
|
||||||
|
oldestTileAt: times.length ? Math.min(...times) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchFlowDetail,
|
fetchFlowDetail,
|
||||||
@@ -52,6 +110,9 @@ export function createFlowTileSource({
|
|||||||
return (await fetchFlowDetail(bounds, options)).segments;
|
return (await fetchFlowDetail(bounds, options)).segments;
|
||||||
},
|
},
|
||||||
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
||||||
resetFlowTileCache: () => tiles.clear(),
|
resetFlowTileCache: () => {
|
||||||
|
for (const entry of pending.values()) entry.release();
|
||||||
|
tiles.clear();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { getEventListeners } from 'node:events';
|
||||||
|
import { createFlowTileSource } from './flowSource.js';
|
||||||
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
||||||
import { decodeFlowTile } from './flowDecode.js';
|
import { decodeFlowTile } from './flowDecode.js';
|
||||||
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
|
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
|
||||||
@@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti
|
|||||||
assert.ok(publicUrls.length >= 4);
|
assert.ok(publicUrls.length >= 4);
|
||||||
assert.ok(apiUrls.length >= 4);
|
assert.ok(apiUrls.length >= 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => {
|
||||||
|
const fetchedAt = Date.UTC(2026, 9, 16, 8);
|
||||||
|
const reply = (status, at) => async () =>
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) },
|
||||||
|
});
|
||||||
|
const stale = createTrafficSource({
|
||||||
|
fetchImpl: reply('STALE-BUDGET', fetchedAt),
|
||||||
|
});
|
||||||
|
const first = await stale.fetchFlowDetail(bounds);
|
||||||
|
assert.ok(first.segments.length > 0);
|
||||||
|
assert.equal(first.stale, true);
|
||||||
|
assert.equal(first.oldestTileAt, fetchedAt);
|
||||||
|
// The decoded tile is served from cache now, and keeps its mark.
|
||||||
|
const again = await stale.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(stale.getFlowSessionStats().tilesFetched, 1);
|
||||||
|
assert.equal(again.stale, true);
|
||||||
|
assert.equal(again.oldestTileAt, fetchedAt);
|
||||||
|
|
||||||
|
const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) });
|
||||||
|
const live = await fresh.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(live.stale, false);
|
||||||
|
assert.equal(live.oldestTileAt, fetchedAt);
|
||||||
|
|
||||||
|
const unmarked = createTrafficSource({
|
||||||
|
fetchImpl: async () => new Response(fixture),
|
||||||
|
});
|
||||||
|
const plain = await unmarked.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(plain.stale, false);
|
||||||
|
assert.equal(plain.oldestTileAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture constructor-owned Maps only while constructing the source, so tests
|
||||||
|
// can assert retained metadata without adding a diagnostic API to production.
|
||||||
|
function flowSourceWithMaps(fetchImpl) {
|
||||||
|
const maps = [];
|
||||||
|
const NativeMap = globalThis.Map;
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
globalThis.Map = class extends NativeMap {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
maps.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source = createFlowTileSource({ fetchImpl });
|
||||||
|
} finally {
|
||||||
|
globalThis.Map = NativeMap;
|
||||||
|
}
|
||||||
|
return { source, pending: maps[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('flow freshness is released after body failures, size rejection, decode and reset', async () => {
|
||||||
|
for (const failure of ['body', 'size', 'decode']) {
|
||||||
|
let requestSignal;
|
||||||
|
let fail = true;
|
||||||
|
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
|
||||||
|
requestSignal = signal;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({
|
||||||
|
'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS',
|
||||||
|
'x-tomtom-fetched-at': fail ? '1000' : '2000',
|
||||||
|
...(fail && failure === 'size'
|
||||||
|
? { 'content-length': '99999999' }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
async arrayBuffer() {
|
||||||
|
if (fail && failure === 'body') throw new Error('body disconnected');
|
||||||
|
return fail && failure === 'decode' ? new Uint8Array([255]) : fixture;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await assert.rejects(source.fetchFlowDetail(bounds));
|
||||||
|
assert.equal(pending.size, 0, `${failure} leaves no pending metadata`);
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
|
||||||
|
fail = false;
|
||||||
|
const fresh = await source.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(fresh.stale, false);
|
||||||
|
assert.equal(fresh.oldestTileAt, 2000);
|
||||||
|
assert.equal(pending.size, 0, 'successful decode releases metadata');
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
source.resetFlowTileCache();
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => {
|
||||||
|
let resolveOld;
|
||||||
|
let oldSignal;
|
||||||
|
let calls = 0;
|
||||||
|
const { source, pending } = flowSourceWithMaps((url, { signal }) => {
|
||||||
|
if (++calls === 1) {
|
||||||
|
oldSignal = signal;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveOld = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const old = source.fetchFlowDetail(bounds);
|
||||||
|
const rejected = assert.rejects(old, { name: 'AbortError' });
|
||||||
|
while (!resolveOld) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
source.resetFlowTileCache();
|
||||||
|
const fresh = await source.fetchFlowDetail(bounds);
|
||||||
|
resolveOld(
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: {
|
||||||
|
'x-tomtom-cache': 'STALE-ERROR',
|
||||||
|
'x-tomtom-fetched-at': '1000',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await rejected;
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
assert.equal(getEventListeners(oldSignal, 'abort').length, 0);
|
||||||
|
assert.equal(fresh.stale, false);
|
||||||
|
assert.equal(fresh.oldestTileAt, 2000);
|
||||||
|
assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling a flow body releases its freshness before a new request', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let requestSignal;
|
||||||
|
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
|
||||||
|
requestSignal = signal;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }),
|
||||||
|
async arrayBuffer() {
|
||||||
|
assert.equal(pending.size, 1);
|
||||||
|
controller.abort();
|
||||||
|
return fixture;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
source.fetchFlowDetail(bounds, { signal: controller.signal }),
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
);
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
|
||||||
|
});
|
||||||
@@ -442,10 +442,12 @@ export function buildTransitSelectionCopy(
|
|||||||
? entry.sample.latestReportAgeMs
|
? entry.sample.latestReportAgeMs
|
||||||
: vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
|
: vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
|
||||||
);
|
);
|
||||||
|
// Seconds through 89s, then whole minutes. Rounding 90s up would skip
|
||||||
|
// "1 min" and call a minute-and-a-half-old report two minutes old.
|
||||||
const reported =
|
const reported =
|
||||||
ageS < 90
|
ageS < 90
|
||||||
? t('Reported {seconds} s ago', { seconds: ageS })
|
? t('Reported {seconds} s ago', { seconds: ageS })
|
||||||
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
|
: t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) });
|
||||||
// A time the feed did not give for THIS vehicle is labelled, so the card
|
// A time the feed did not give for THIS vehicle is labelled, so the card
|
||||||
// never presents a borrowed or inferred time as the vehicle's own report.
|
// never presents a borrowed or inferred time as the vehicle's own report.
|
||||||
const ownTime =
|
const ownTime =
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report 90 seconds old reads as 1 minute, not 2', () => {
|
||||||
|
setLocale('en');
|
||||||
|
const line = (ageMs) =>
|
||||||
|
buildTransitSelectionCopy(
|
||||||
|
feed,
|
||||||
|
{
|
||||||
|
id: 'age',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
timestamp: Math.floor((now - ageMs) / 1000),
|
||||||
|
timestampSource: 'vehicle',
|
||||||
|
},
|
||||||
|
'bus',
|
||||||
|
now,
|
||||||
|
).details[1];
|
||||||
|
assert.equal(line(89_000), 'Reported 89 s ago');
|
||||||
|
assert.equal(line(90_000), 'Reported 1 min ago');
|
||||||
|
assert.equal(line(120_000), 'Reported 2 min ago');
|
||||||
|
});
|
||||||
|
|
||||||
for (const locale of ['es', 'ru']) {
|
for (const locale of ['es', 'ru']) {
|
||||||
test(`${locale}: the Transit card is in the UI language`, () => {
|
test(`${locale}: the Transit card is in the UI language`, () => {
|
||||||
setLocale(locale);
|
setLocale(locale);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Let a turned-off layer's memory go.
|
||||||
|
*
|
||||||
|
* Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command
|
||||||
|
* arrays: every frame resets `frustumCommands.indices[pass]` to 0 and
|
||||||
|
* overwrites `commands[pass][i]` from the start. `Scene.js` truncates the
|
||||||
|
* voxel, Gaussian-splat and translucent passes to their counts; the opaque
|
||||||
|
* and other passes keep the slots past the new count. When a layer is turned
|
||||||
|
* off the next frame has fewer commands, so that tail keeps the destroyed
|
||||||
|
* primitives' DrawCommands, and through `_owner` → primitive → entity →
|
||||||
|
* EntityCollection the whole data source stays reachable. The offscreen view
|
||||||
|
* `scene.sampleHeight` / `pickFromRay` render into keeps its last list the
|
||||||
|
* same way, until the next height sample.
|
||||||
|
*
|
||||||
|
* Measured (production build, heap after GC, Data Centers on → off): 116 MiB
|
||||||
|
* kept on main, 13 MiB with this module; heap snapshots traced the entities
|
||||||
|
* through `View.frustumCommandsList` and `Picking._pickOffscreenView`.
|
||||||
|
*
|
||||||
|
* After a layer finishes turning off, this renders one frame, trims the main
|
||||||
|
* view's arrays to that frame's counts, and empties the offscreen pick view's
|
||||||
|
* lists (it rebuilds them in full on every pick). It reads private Cesium
|
||||||
|
* fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed
|
||||||
|
* or if Cesium changes which passes it truncates, and once Cesium clears the
|
||||||
|
* tail itself this module can go.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Drop command references a view no longer draws. */
|
||||||
|
function trimView(view, { keepCurrent }) {
|
||||||
|
for (const frustum of view?.frustumCommandsList || []) {
|
||||||
|
const { commands, indices } = frustum;
|
||||||
|
for (let pass = 0; pass < commands.length; pass += 1) {
|
||||||
|
const keep = keepCurrent ? indices[pass] : 0;
|
||||||
|
if (commands[pass].length > keep) commands[pass].length = keep;
|
||||||
|
if (!keepCurrent) indices[pass] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keepCurrent)
|
||||||
|
for (const extent of view?._commandExtents || [])
|
||||||
|
extent.command = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release stale command references in the scene's main view (keeping what
|
||||||
|
* the last frame drew) and in its offscreen pick view (all of it).
|
||||||
|
* @param {object} scene Cesium Scene, right after a render.
|
||||||
|
*/
|
||||||
|
export function trimStaleSceneCommands(scene) {
|
||||||
|
trimView(scene._defaultView, { keepCurrent: true });
|
||||||
|
trimView(scene._picking?._pickOffscreenView, { keepCurrent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After every layer disable, render one frame and trim once it is drawn.
|
||||||
|
* @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps
|
||||||
|
* @returns {() => void} uninstall
|
||||||
|
*/
|
||||||
|
export function installLayerReleaseTrim({ scene, dataManager, requestRender }) {
|
||||||
|
let removeOneShot = null;
|
||||||
|
const unsubscribe = dataManager.subscribe((change) => {
|
||||||
|
if (change?.type !== 'visibility' || change.enabled !== false) return;
|
||||||
|
if (removeOneShot) return; // a trim is already waiting for its frame
|
||||||
|
removeOneShot = scene.postRender.addEventListener(() => {
|
||||||
|
removeOneShot();
|
||||||
|
removeOneShot = null;
|
||||||
|
trimStaleSceneCommands(scene);
|
||||||
|
});
|
||||||
|
requestRender('layer-release-trim');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
removeOneShot?.();
|
||||||
|
removeOneShot = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// A turned-off layer's draw commands must not stay reachable through Cesium's
|
||||||
|
// reused per-pass command arrays (src/sceneCommandRelease.js).
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import {
|
||||||
|
installLayerReleaseTrim,
|
||||||
|
trimStaleSceneCommands,
|
||||||
|
} from './sceneCommandRelease.js';
|
||||||
|
|
||||||
|
const frustum = (lists, counts) => ({
|
||||||
|
commands: lists.map((list) => [...list]),
|
||||||
|
indices: [...counts],
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the main view keeps what the last frame drew and drops the stale tail', () => {
|
||||||
|
const view = {
|
||||||
|
frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])],
|
||||||
|
};
|
||||||
|
trimStaleSceneCommands({ _defaultView: view });
|
||||||
|
assert.deepEqual(view.frustumCommandsList[0].commands, [
|
||||||
|
['a', 'b'],
|
||||||
|
['c'],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the offscreen pick view is emptied, since every pick rebuilds it', () => {
|
||||||
|
const extents = [{ command: 'x' }, { command: 'y' }];
|
||||||
|
const pick = {
|
||||||
|
frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])],
|
||||||
|
_commandExtents: extents,
|
||||||
|
};
|
||||||
|
trimStaleSceneCommands({
|
||||||
|
_defaultView: { frustumCommandsList: [] },
|
||||||
|
_picking: { _pickOffscreenView: pick },
|
||||||
|
});
|
||||||
|
assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]);
|
||||||
|
assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]);
|
||||||
|
assert.deepEqual(
|
||||||
|
extents.map((e) => e.command),
|
||||||
|
[undefined, undefined],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a layer disable renders one frame and trims after it', () => {
|
||||||
|
const listeners = new Set();
|
||||||
|
const postRender = new Set();
|
||||||
|
const requests = [];
|
||||||
|
const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] };
|
||||||
|
const scene = {
|
||||||
|
_defaultView: view,
|
||||||
|
postRender: {
|
||||||
|
addEventListener(fn) {
|
||||||
|
postRender.add(fn);
|
||||||
|
return () => postRender.delete(fn);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const uninstall = installLayerReleaseTrim({
|
||||||
|
scene,
|
||||||
|
dataManager: {
|
||||||
|
subscribe(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestRender: (reason) => requests.push(reason),
|
||||||
|
});
|
||||||
|
const emit = (change) => listeners.forEach((fn) => fn(change));
|
||||||
|
|
||||||
|
emit({ type: 'visibility', layerId: 'x', enabled: true });
|
||||||
|
assert.equal(requests.length, 0, 'enabling trims nothing');
|
||||||
|
emit({ type: 'visibility', layerId: 'x', enabled: false });
|
||||||
|
emit({ type: 'visibility', layerId: 'y', enabled: false });
|
||||||
|
assert.deepEqual(
|
||||||
|
requests,
|
||||||
|
['layer-release-trim'],
|
||||||
|
'one frame for back-to-back disables',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
view.frustumCommandsList[0].commands[0].length,
|
||||||
|
2,
|
||||||
|
'not before the frame',
|
||||||
|
);
|
||||||
|
postRender.forEach((fn) => fn());
|
||||||
|
assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]);
|
||||||
|
assert.equal(postRender.size, 0, 'the frame listener is one-shot');
|
||||||
|
uninstall();
|
||||||
|
assert.equal(listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The workaround reads private Cesium fields. Pin the shape it relies on, and
|
||||||
|
// fail when Cesium starts clearing the tail itself so the module can go.
|
||||||
|
test('Cesium still reuses per-pass command arrays without clearing their tail', () => {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const enginePkg = require.resolve('@cesium/engine/package.json');
|
||||||
|
const read = (rel) =>
|
||||||
|
readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8');
|
||||||
|
const scene = read('./Source/Scene/Scene.js');
|
||||||
|
const view = read('./Source/Scene/View.js');
|
||||||
|
const frustumCommands = read('./Source/Scene/FrustumCommands.js');
|
||||||
|
const picking = read('./Source/Scene/Picking.js');
|
||||||
|
// Every private field the trim reads. A rename would otherwise turn the
|
||||||
|
// trim into a silent no-op (it reaches them through `?.` and `|| []`).
|
||||||
|
assert.match(scene, /this\._picking = new Picking\(this\);/);
|
||||||
|
assert.match(scene, /this\._defaultView = new View\(/);
|
||||||
|
assert.match(picking, /this\._pickOffscreenView = new View\(/);
|
||||||
|
assert.match(view, /this\.frustumCommandsList = \[\];/);
|
||||||
|
assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/);
|
||||||
|
assert.match(view, /this\._commandExtents = \[\];/);
|
||||||
|
assert.match(
|
||||||
|
frustumCommands,
|
||||||
|
/this\.commands = commands;\s*this\.indices = indices;/,
|
||||||
|
);
|
||||||
|
// Cesium truncates only the voxel, Gaussian-splat and translucent passes,
|
||||||
|
// each in its own draw step; the opaque and other passes keep their tail.
|
||||||
|
// A change here means Cesium's handling moved: re-check whether this
|
||||||
|
// module is still needed.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
view,
|
||||||
|
/\.length\s*=\s*[^;]*indices\[/,
|
||||||
|
'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...scene.matchAll(
|
||||||
|
/commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g,
|
||||||
|
),
|
||||||
|
].map((match) => match[1]),
|
||||||
|
['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'],
|
||||||
|
'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js',
|
||||||
|
);
|
||||||
|
});
|
||||||
+32
-11
@@ -1,4 +1,10 @@
|
|||||||
import { getKeyholeGeometry } from './celestialRing.js';
|
import { getKeyholeGeometry } from './celestialRing.js';
|
||||||
|
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
||||||
|
export {
|
||||||
|
SCOPE_FEATHER_RATIO_MAX,
|
||||||
|
SCOPE_FEATHER_MAX_PCT,
|
||||||
|
clampScopeFeatherPct,
|
||||||
|
} 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.
|
||||||
@@ -43,13 +49,10 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
|||||||
/**
|
/**
|
||||||
* Default edge feather as a fraction of the keyhole radius.
|
* Default edge feather as a fraction of the keyhole radius.
|
||||||
*
|
*
|
||||||
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
|
* 1.0 since 2026-10-25 (owner preference), superseding the 11% default.
|
||||||
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
|
* The slider spans 0..200; this is only where it starts. An explicit feather
|
||||||
* set it to zero: a subtle soft edge rather than either the hard crop or the
|
* from a share link, scene or the operator overrides the default immediately,
|
||||||
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
|
* including the hard-crop path at 0.
|
||||||
* 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
|
* 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
|
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
|
||||||
@@ -59,7 +62,7 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
|||||||
* carries `scf=0` explicitly because the generator always writes the field.
|
* carries `scf=0` explicitly because the generator always writes the field.
|
||||||
* Pinned in reasonableDefaults.test.mjs.
|
* 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 +347,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 +429,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 +444,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 +520,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 +538,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 +559,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 +573,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();
|
||||||
|
|||||||
+51
-5
@@ -18,6 +18,9 @@ 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,
|
||||||
|
SCOPE_FEATHER_RATIO_MAX,
|
||||||
|
clampScopeFeatherPct,
|
||||||
|
getScopeMaskFeather,
|
||||||
SCOPE_OUTSIDE_ALPHA,
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
SCOPE_TERMINUS_ALPHA_NEAR,
|
SCOPE_TERMINUS_ALPHA_NEAR,
|
||||||
SCOPE_TERMINUS_FAR_M,
|
SCOPE_TERMINUS_FAR_M,
|
||||||
@@ -47,16 +50,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 +157,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 +183,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
|
||||||
|
const dom = stubScopeMaskDom();
|
||||||
|
try {
|
||||||
|
setScopeMaskEnabled(false);
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
|
||||||
|
delete dom.documentElement.dataset.scopeMask;
|
||||||
|
installScopeMask({ container: dom.container });
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
|
||||||
|
destroyScopeMask();
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
|
||||||
|
installScopeMask({ container: dom.container });
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
|
||||||
|
setScopeMaskEnabled(true);
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
|
||||||
|
setScopeMaskEnabled(false);
|
||||||
|
_resetScopeMaskForTest();
|
||||||
|
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
|
||||||
|
} finally {
|
||||||
|
destroyScopeMask();
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a DPR change with no resize still repaints the backing store', () => {
|
test('a DPR change with no resize still repaints the backing store', () => {
|
||||||
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
|
||||||
|
export const SCOPE_FEATHER_RATIO_MAX = 2;
|
||||||
|
export 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,15 +47,37 @@ function readComponent(text) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split on a comma, a semicolon, or whitespace — never on anything else. */
|
/**
|
||||||
|
* Split on a comma, a semicolon, or whitespace — never on anything else.
|
||||||
|
* A hemisphere letter stays with the number it names, so `40.7 N 74.0 W`
|
||||||
|
* is two components. Splitting on the space before that letter used to
|
||||||
|
* leave three pieces and the search declined the coordinate.
|
||||||
|
*/
|
||||||
function splitComponents(query) {
|
function splitComponents(query) {
|
||||||
const text = String(query).trim();
|
const text = String(query).trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const parts = /[,;]/.test(text)
|
if (/[,;]/.test(text)) {
|
||||||
? text.split(/[,;]/)
|
const trimmed = text.split(/[,;]/).map((part) => part.trim());
|
||||||
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
||||||
const trimmed = parts.map((part) => part.trim());
|
}
|
||||||
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
const words = text.split(/\s+/);
|
||||||
|
// Each component has at most a number, a degree sign and one hemisphere.
|
||||||
|
if (words.length > 6) return null;
|
||||||
|
// Keep every character: extracting tokens would turn `1e3` or `1.2.3`
|
||||||
|
// into invented coordinates. A lone letter between numbers can name either
|
||||||
|
// component; require an unambiguous split rather than guessing its owner.
|
||||||
|
let result = null;
|
||||||
|
for (let boundary = words.length - 1; boundary > 0; boundary -= 1) {
|
||||||
|
const parts = [
|
||||||
|
words.slice(0, boundary).join(' '),
|
||||||
|
words.slice(boundary).join(' '),
|
||||||
|
];
|
||||||
|
if (parts.every((part) => readComponent(part))) {
|
||||||
|
if (result) return null;
|
||||||
|
result = parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Present a parsed pair the way a coordinate is normally written. */
|
/** Present a parsed pair the way a coordinate is normally written. */
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => {
|
|||||||
label: '40.0000° N, 74.0000° W',
|
label: '40.0000° N, 74.0000° W',
|
||||||
});
|
});
|
||||||
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
|
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
|
||||||
|
// Written the way a coordinate is usually pasted, with no comma.
|
||||||
|
assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), {
|
||||||
|
lat: 40.7128,
|
||||||
|
lon: -74.006,
|
||||||
|
label: '40.7128° N, 74.0060° W',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006);
|
||||||
|
assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), {
|
||||||
|
lat: 40.7128,
|
||||||
|
lon: -74.006,
|
||||||
|
label: '40.7128° N, 74.0060° W',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128);
|
||||||
// One letter is enough: it fixes its own axis, the other takes what is left.
|
// One letter is enough: it fixes its own axis, the other takes what is left.
|
||||||
assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74);
|
assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74);
|
||||||
assert.deepEqual(parseCoordinateQuery('-74, 40 N'), {
|
assert.deepEqual(parseCoordinateQuery('-74, 40 N'), {
|
||||||
@@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => {
|
|||||||
// The reviewer's case: trailing text must not be discarded.
|
// The reviewer's case: trailing text must not be discarded.
|
||||||
'12junk, 34oops',
|
'12junk, 34oops',
|
||||||
'12junk,34',
|
'12junk,34',
|
||||||
|
'12junk 34',
|
||||||
|
'1e3 5',
|
||||||
|
'1e3',
|
||||||
|
'1E3',
|
||||||
|
'1e-3',
|
||||||
|
'1.2.3',
|
||||||
|
'12-34',
|
||||||
|
'40 NW 74',
|
||||||
|
'40 N 74',
|
||||||
|
'40 W 74',
|
||||||
|
'40 E 74',
|
||||||
|
'°40 74',
|
||||||
|
'40°° 74',
|
||||||
|
'40 74°°',
|
||||||
|
'40 26 46 N 79 58 56 W',
|
||||||
'43.1731, -79.0384x',
|
'43.1731, -79.0384x',
|
||||||
'abc, def',
|
'abc, def',
|
||||||
'Paris',
|
'Paris',
|
||||||
@@ -196,3 +224,25 @@ test('malformed or unsupported geo URIs are refused without losing their meaning
|
|||||||
assert.equal(parseCoordinateQuery(query), null, query);
|
assert.equal(parseCoordinateQuery(query), null, query);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('whitespace boundaries preserve degree signs and hemisphere ownership', () => {
|
||||||
|
for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) {
|
||||||
|
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
|
||||||
|
assert.equal(parseCoordinateQuery(query)?.lon, 74, query);
|
||||||
|
}
|
||||||
|
for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) {
|
||||||
|
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
|
||||||
|
assert.equal(parseCoordinateQuery(query)?.lon, -74, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a signed plain component leaves a spaced letter with the other component', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCoordinateQuery('-40 N 74'),
|
||||||
|
parseCoordinateQuery('-40, N 74'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCoordinateQuery('40 N -74'),
|
||||||
|
parseCoordinateQuery('40 N, -74'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
|||||||
+6
-12
@@ -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,9 @@ 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,
|
// Mirrors the first-run mask and slider default. The historical missing-scf
|
||||||
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors
|
// parse fallback below remains 35 because it restores an older authored view.
|
||||||
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value.
|
this._scopeFeatherPct = 100;
|
||||||
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 +227,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 +491,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') {
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ function stubTerrainUpstream(t) {
|
|||||||
return asked;
|
return asked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cache file of the test's own, removed when the test ends. Without one the
|
||||||
|
* proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a
|
||||||
|
* point the app has already cached there would answer a request the test
|
||||||
|
* expects to reach the stubbed upstream (#1242).
|
||||||
|
*/
|
||||||
|
async function isolatedCachePath(t) {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||||
|
return path.join(dir, 'terrain-heights.json');
|
||||||
|
}
|
||||||
|
|
||||||
test('a coordinate off the globe is not a cache key', () => {
|
test('a coordinate off the globe is not a cache key', () => {
|
||||||
// Finite but not on Earth — the old guard accepted every one of these.
|
// Finite but not on Earth — the old guard accepted every one of these.
|
||||||
for (const raw of [
|
for (const raw of [
|
||||||
@@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
|
|||||||
upstreamCalls += 1;
|
upstreamCalls += 1;
|
||||||
return new Response('[]', { status: 200 });
|
return new Response('[]', { status: 200 });
|
||||||
});
|
});
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
|
|
||||||
const refused = await request(route, { url: '/?points=0,91' });
|
const refused = await request(route, { url: '/?points=0,91' });
|
||||||
assert.equal(refused.statusCode, 400);
|
assert.equal(refused.statusCode, 400);
|
||||||
@@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
|
|||||||
|
|
||||||
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
|
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
|
||||||
stubFetch(t, async () => new Response('[]', { status: 200 }));
|
stubFetch(t, async () => new Response('[]', { status: 200 }));
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
const mixed = await request(route, {
|
const mixed = await request(route, {
|
||||||
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
|
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
|
||||||
});
|
});
|
||||||
@@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => {
|
|||||||
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
|
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
|
||||||
const asked = stubTerrainUpstream(t);
|
const asked = stubTerrainUpstream(t);
|
||||||
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
|
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
|
||||||
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
|
const route = install(
|
||||||
'/api/terrain/heights',
|
terrainHeightsProxy({
|
||||||
);
|
cachePath: await isolatedCachePath(t),
|
||||||
|
cacheMaxEntries: 3,
|
||||||
|
}),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
|
|
||||||
const point = (i) => `-97.7${i}000,30.26000`;
|
const point = (i) => `-97.7${i}000,30.26000`;
|
||||||
for (let i = 1; i <= 4; i += 1)
|
for (let i = 1; i <= 4; i += 1)
|
||||||
@@ -165,12 +184,48 @@ test('the point cache stops growing, and an evicted point is simply refetched',
|
|||||||
assert.equal(asked[4], point(1));
|
assert.equal(asked[4], point(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a refetched point counts as new when the cache evicts', async (t) => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), 'terrain-evict-order-'));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const cachePath = path.join(root, 'cache.json');
|
||||||
|
const day = 24 * 3600_000;
|
||||||
|
const now = Date.now();
|
||||||
|
const point = (i) => `-97.7${i}000,30.26000`;
|
||||||
|
const entry = (at) => ({ at, result: { ellipsoid: 150 } });
|
||||||
|
// Written newest first: the file's order is not the age order. Point 1 is
|
||||||
|
// past the 30-day TTL, so the next request for it refetches.
|
||||||
|
await writeFile(
|
||||||
|
cachePath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
points: {
|
||||||
|
[point(3)]: entry(now - 3600_000),
|
||||||
|
[point(2)]: entry(now - day),
|
||||||
|
[point(1)]: entry(now - 31 * day),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const asked = stubTerrainUpstream(t);
|
||||||
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cacheMaxEntries: 3, cachePath }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
|
|
||||||
|
await request(route, { url: `/?points=${point(1)}` });
|
||||||
|
assert.deepEqual(asked, [point(1)], 'the stale point is refetched');
|
||||||
|
await request(route, { url: `/?points=${point(4)}` });
|
||||||
|
assert.equal(asked.length, 2);
|
||||||
|
|
||||||
|
// Point 2 is now the oldest fetch; point 1 was just refreshed.
|
||||||
|
await request(route, { url: `/?points=${point(1)};${point(3)}` });
|
||||||
|
assert.equal(asked.length, 2, 'the refreshed and the recent point stay');
|
||||||
|
await request(route, { url: `/?points=${point(2)}` });
|
||||||
|
assert.deepEqual(asked.slice(2), [point(2)], 'the oldest fetch was evicted');
|
||||||
|
});
|
||||||
|
|
||||||
test('an over-large cache file is not read back into memory', async (t) => {
|
test('an over-large cache file is not read back into memory', async (t) => {
|
||||||
const asked = stubTerrainUpstream(t);
|
const asked = stubTerrainUpstream(t);
|
||||||
const point = '-97.74000,30.26000';
|
const point = '-97.74000,30.26000';
|
||||||
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
|
const cachePath = await isolatedCachePath(t);
|
||||||
const cachePath = path.join(dir, 'terrain-heights.json');
|
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
// A cache file holding the answer, but larger than the ceiling allows.
|
// A cache file holding the answer, but larger than the ceiling allows.
|
||||||
const padded = {
|
const padded = {
|
||||||
@@ -201,7 +256,9 @@ test('an over-large cache file is not read back into memory', async (t) => {
|
|||||||
|
|
||||||
test('the route refuses a flood of requests from one client', async (t) => {
|
test('the route refuses a flood of requests from one client', async (t) => {
|
||||||
stubTerrainUpstream(t);
|
stubTerrainUpstream(t);
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
|
|
||||||
let refusedAt = 0;
|
let refusedAt = 0;
|
||||||
for (let i = 1; i <= 95; i += 1) {
|
for (let i = 1; i <= 95; i += 1) {
|
||||||
@@ -218,7 +275,9 @@ test('the route refuses a flood of requests from one client', async (t) => {
|
|||||||
|
|
||||||
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
|
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
|
||||||
const asked = stubTerrainUpstream(t);
|
const asked = stubTerrainUpstream(t);
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
const host = 'localhost:5173';
|
const host = 'localhost:5173';
|
||||||
const refused = [
|
const refused = [
|
||||||
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
||||||
@@ -253,7 +312,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
|
|||||||
|
|
||||||
test('the app served behind a reverse proxy still gets terrain', async (t) => {
|
test('the app served behind a reverse proxy still gets terrain', async (t) => {
|
||||||
const asked = stubTerrainUpstream(t);
|
const asked = stubTerrainUpstream(t);
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
const host = 'globe.example.lan';
|
const host = 'globe.example.lan';
|
||||||
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
|
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
|
||||||
// cost-bearing routes refuse these; terrain is keyless and needed to render,
|
// cost-bearing routes refuse these; terrain is keyless and needed to render,
|
||||||
@@ -347,7 +408,9 @@ test('a second client keeps its own quota', async (t) => {
|
|||||||
stubFetch(t, async () =>
|
stubFetch(t, async () =>
|
||||||
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
|
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
|
||||||
);
|
);
|
||||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
const route = install(
|
||||||
|
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
|
||||||
|
).get('/api/terrain/heights');
|
||||||
|
|
||||||
for (let i = 0; i < 90; i += 1)
|
for (let i = 0; i < 90; i += 1)
|
||||||
await request(route, {
|
await request(route, {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o
|
|||||||
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
|
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
|
||||||
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
|
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
|
||||||
let now = Date.UTC(2026, 8, 12, 12);
|
let now = Date.UTC(2026, 8, 12, 12);
|
||||||
|
const fetchedAt = String(now);
|
||||||
t.mock.method(Date, 'now', () => now);
|
t.mock.method(Date, 'now', () => now);
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||||
@@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
|
|||||||
assert.equal(json(await request('/status')).dailyCount, 1);
|
assert.equal(json(await request('/status')).dailyCount, 1);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
now += 120001;
|
now += 120001;
|
||||||
assert.equal(
|
const staleTile = await request('/flow/8/1/1.pbf');
|
||||||
(await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],
|
assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET');
|
||||||
'STALE-BUDGET',
|
// The stale copy still says when TomTom produced it.
|
||||||
);
|
assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt);
|
||||||
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
|
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
now += 86400000;
|
now += 86400000;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
CUBE_FACES,
|
CUBE_FACES,
|
||||||
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
|
|||||||
}));
|
}));
|
||||||
for (const face of CUBE_FACES) {
|
for (const face of CUBE_FACES) {
|
||||||
const { data, info } = await sharp(
|
const { data, info } = await sharp(
|
||||||
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
|
fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
|
||||||
)
|
)
|
||||||
.raw()
|
.raw()
|
||||||
.toBuffer({ resolveWithObject: true });
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|||||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user