Compare commits

...
Author SHA1 Message Date
Said TurkseverandClaude Opus 5.5 0f268efb71 Merge branch 'main' into codex/fix-cctv-session-admission
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:17 +01:00
Said Turksever 2da8ced642 Merge pull request #1286 from asturksever/fix/since-note-inline
fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
2026-10-21 14:50:45 +01:00
Said Turksever a397c94892 Merge branch 'main' into fix/since-note-inline 2026-10-21 14:36:24 +01:00
Mazeyar Moeini Feizabadi 5a1598fd9e Merge pull request #1287 from bilawalsidhu/codex/shared-sliders
refactor(ui): unify ordinary slider components and styling
2026-10-21 14:19:55 +02:00
Mazeyar Moeini Feizabadi bf0e1d7c79 Merge current main into shared sliders 2026-10-21 14:10:40 +02:00
Mazeyar Moeini Feizabadi 415edbcfdf Merge pull request #1301 from bilawalsidhu/codex/shared-segmented-selectors
Unify segmented selectors and fix compact control spacing
2026-10-21 13:27:12 +02:00
Mazeyar Moeini Feizabadi 4bfee31fdb Reserve full height for imagery segmented controls 2026-10-21 13:21:23 +02:00
Mazeyar Moeini Feizabadi 7df8b369e4 Add changelog for shared segmented selectors 2026-10-21 13:08:35 +02:00
Mazeyar Moeini Feizabadi 40394ff76e Unify segmented selectors and fix compact panel spacing 2026-10-21 13:07:51 +02:00
Sameh Khamis 713285b554 Merge pull request #1205 from manjunath22466/fix/cctv-tool-routing
fix(voice): report actual cctv toggle results
2026-10-20 10:15:33 -07:00
Sameh Khamis 13b474d302 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 10:05:39 -07:00
Sameh Khamis 23feb16b0f Merge pull request #1235 from manjunath22466/fix/location-resolution
fix(analyst): disambiguate region names and retain boundary provenance
2026-10-20 10:05:27 -07:00
Sameh Khamis 3d75b9e7ee Merge branch 'main' into fix/location-resolution 2026-10-20 09:53:07 -07:00
Sameh Khamis da9beb9324 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 09:49:00 -07:00
Sameh Khamis 803cddd5cb docs(analyst): describe region resolution as current state 2026-10-20 09:48:49 -07:00
Sameh Khamis 5f99cd25ec docs(changelog): credit the GitHub handle for #1205 2026-10-20 09:48:49 -07:00
Said Turksever 064d51f7be Merge pull request #1237 from 419vive/feat/taiwan-freeway-cctv-pack
feat(cctv): add Taiwan Freeway Bureau camera pack
2026-10-20 15:11:05 +01:00
Said Turksever 1ec21c59ce Merge remote-tracking branch 'upstream/main' into fix-1237
# Conflicts:
#	README.md
#	docs/CURRENT-STATE.md
2026-10-20 14:54:57 +01:00
Said TurkseverandClaude Opus 5.5 ce5f69b586 fix(cctv): back off the Taiwan catalog when unreachable; review fixes
- taiwan.js: a failed download serves the last good table (up to 24 h)
  and is not retried for an hour. The table host does not answer from
  some networks outside Taiwan, and each attempt held the shared catalog
  refresh for the ~10 s connect timeout.
- Own server/providers/cctv/taiwan.js in package-boundaries.json
  (check:boundaries failed).
- Decode numeric character references; name falls back to the CCTVID
  when a row has no road details.
- Credit names the dataset and links the OGDL Taiwan licence.
- Document CCTV_TAIWAN_URL; list Taiwan in the CURRENT-STATE source table.
- Tests: isolate catalog env, reset loader state, cover the byte cap,
  failed fetch, backoff, last-good fallback, cap clamping, redirect with
  a valid body, name fallback, numeric entities and UUID camera ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:51:48 +01:00
Said Turksever 3f1a0c0603 Merge pull request #990 from JCY2515/feat/hk-cctv-pack
feat: add Hong Kong Transport Department CCTV pack
2026-10-20 14:47:09 +01:00
Said Turksever 0868bb97f6 Merge remote-tracking branch 'upstream/main' into fix-1237
# Conflicts:
#	.env.example
#	README.md
#	server/providers/cctv/catalog.js
2026-10-20 14:42:08 +01:00
Said TurkseverandClaude Opus 5.5 d5545243c5 chore(cctv-hk): changelog fragment and .env.example entries
Move the Hong Kong CCTV changelog entry into changelog.d/ (main now takes
fragments instead of CHANGELOG.md edits) and document CCTV_HK_ENABLED,
CCTV_HK_MAX_SOURCES and CCTV_HK_CAMERAS_URL beside the other packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:40:01 +01:00
JCY2515andCursor 22c22d1876 feat: add Hong Kong Transport Department CCTV pack
Fetch keyless DATA.GOV.HK camera locations at runtime and pin stills to
tdcctv.data.one.gov.hk with required attribution. Default cap 1,100 covers
the ~1,013-camera TD catalog, prioritized across Hong Kong Island, Kowloon
and the New Territories.

Co-authored-by: Cursor <[email protected]>
2026-10-20 14:40:01 +01:00
Said Turksever f882626612 Merge pull request #1228 from quanticsoul4772/upstream/ontario-511-key
fix(cctv): Ontario 511 cameras take the developer key the API now requires
2026-10-20 14:28:09 +01:00
Said Turksever 72983215fc Merge branch 'main' into upstream/ontario-511-key 2026-10-20 14:21:35 +01:00
Said 5e9c14db83 test: keep headful CCTV gate rendering when occluded 2026-10-20 14:10:29 +01:00
Mazeyar Moeini Feizabadi 64fd9ba1c2 docs: add shared slider changelog fragment 2026-10-20 14:49:06 +02:00
Mazeyar Moeini Feizabadi f1c617ad27 refactor(ui): share ordinary slider components 2026-10-20 14:48:42 +02:00
Said TurkseverandClaude Opus 5.5 f6006358ed fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
The slim-down in #974 dropped --sl-text-small, which .sl-since-note still
used, so with KartaView lit "KartaView: by upload date" rendered on its
own line larger than the value. The note now sits beside the value in the
same mono type and letter spacing, a size smaller (0.5rem), as
"(by upload date)". One source is lit at a time, so the provider name is
dropped. es/ru catalogs, tests, README, CURRENT-STATE and the registry
JSDoc follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:23:44 +01:00
Said Turksever 7164373fb3 Merge pull request #974 from bilawalsidhu/german/street-level-slim
refactor(street-level): one provider, docked panels, memory-only proxy
2026-10-20 13:15:47 +01:00
Said Turksever 24ff5c7ea7 Merge branch 'main' into german/street-level-slim 2026-10-20 13:06:56 +01:00
Mazeyar Moeini Feizabadi acc87a9e22 Merge pull request #1283 from rudycelekli/fix/satellite-cache-provenance-20261017
fix(satellites): retain cached catalog provenance
2026-10-20 13:03:54 +02:00
Mazeyar Moeini Feizabadi 7ca3c1e08f Merge branch 'main' into fix/satellite-cache-provenance-20261017 2026-10-20 12:53:10 +02:00
Mazeyar Moeini Feizabadi 505d2cb586 Merge pull request #1284 from rudycelekli/fix/route-plausibility-segment-20261020
fix(flights): bound route labels to the scheduled leg
2026-10-20 12:30:47 +02:00
German KhokhlovandClaude Opus 5.5 28e0ba7104 fix(street-level): /revmain cleanup after the slim rebuild
- Google 3D: the OPEN button re-measures a ground whose tiles are still
  loading (a few times, a second apart) and on a map change, not only
  after the next camera move.
- Mapillary: two mounts on one host during the first download share one
  build instead of leaking a second WebGL viewer.
- Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are
  dropped from localStorage; dead inline-position resets, the drag JSDoc
  and the dead qa-ui-disposal observer branch are gone.
- Docs: z ladder, panel chrome, position storage and the fixture gate's
  actual coverage; changelog fragment for #974 (old .gev-cache/mapillary
  can be deleted).
- ru/es: the layer is named as everywhere else in the UI.
- Fixtures and test fakes: unused exports removed; a fake credit removal
  of an absent credit no longer drops another one; the tile test reset
  frees upstream slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:02:09 +04:00
Rudy Celekli 140c18c55a docs: add scheduled route plausibility changelog 2026-10-20 03:35:27 -04:00
Rudy Celekli 876cd5e4a5 fix(flights): bound route labels to the scheduled leg 2026-10-20 03:22:18 -04:00
manjunath b48fcc42ae fix(voice): refuse direct cctv toggles outside the shared lifecycle 2026-10-20 10:00:24 +05:30
manjunath b920d5f404 merge main into fix/cctv-tool-routing 2026-10-20 10:00:13 +05:30
Rudy Celekli 57983fb4c2 docs: add satellite cache provenance changelog 2026-10-19 22:16:37 -04:00
Rudy Celekli b43970eecc fix(satellites): retain cached catalog provenance 2026-10-19 21:58:22 -04:00
manjunath b0e0945360 fix(analyst): restore exact region matches and remove search rescue 2026-10-20 06:31:50 +05:30
Said a7160c62be docs: record CCTV session admission fix 2026-10-20 00:11:49 +01:00
Said 167aa947ce fix(cctv): admit and bound HLS leases by peer 2026-10-20 00:11:31 +01:00
Said Turksever de2138e84c Merge pull request #1233 from yaka0007/fix/tarktee-datex-catalog-hardening
fix(cctv): harden the Tarktee DATEX catalog fetches and dedupe before the cap
2026-10-19 23:45:41 +01:00
Said Turksever f0e0ea42ce test(cctv): verify DEM placement in the keyless globe gate 2026-10-19 23:31:52 +01:00
Said Turksever 89ef90af04 Merge remote-tracking branch 'upstream/main' into codex/pr1233-tarktee-cleanup 2026-10-19 23:27:29 +01:00
Said Turksever 5597c81e7c Merge pull request #945 from trygvehhh/feat/cctv-vegvesen-kartverket-heights
feat: Kartverket ground heights for Statens vegvesen cameras
2026-10-19 23:24:35 +01:00
Said Turksever 97630a598a fix(cctv): release Tarktee sibling on fetch rejection 2026-10-19 23:15:43 +01:00
Said Turksever 3744a97f00 Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-19 23:15:41 +01:00
Said TurkseverandClaude Opus 5.5 59646e52f9 Merge upstream main into fix/tarktee-datex-catalog-hardening
Resolve conflicts with #1234: keep redirect: 'manual' on both DATEX
fetches and keep main's 30 s TARKTEE_FETCH_TIMEOUT_MS. Drop this
branch's 15 s constant and its import, which auto-merged into a
duplicate export. Keep both sets of Estonia tests and drop the 15 s
claim from the changelog fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:48:03 +01:00
Said Turksever 38e037d3a7 Merge pull request #1234 from quanticsoul4772/upstream/cctv-tarktee-refresh
fix(cctv): serve the camera catalog while it refreshes, and give Tarktee's slow feeds 30 s
2026-10-19 22:39:54 +01:00
Said Turksever ca71b27791 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 22:24:44 +01:00
Said Turksever b7fe29b5cc Merge pull request #1133 from asturksever/fix/qa-cctv-v2-headful
fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
2026-10-19 22:24:12 +01:00
Said TurkseverandClaude Opus 5.5 12b8fd0c1d fix(qa-cctv-v2): poll waits from Node so a hidden window can't stall them
page.waitForFunction polls on requestAnimationFrame by default. In a
minimised or hidden headful window rAF stops while timers keep running, so
each 120 s slice only checked the predicate when it started: a drain that
finished in the last slice was recorded as a FAIL on the sign-off path.

- waitForPredicate replaces waitForFunctionSliced and the other three
  waitForFunction calls: one short page.evaluate per tick (1 s for the
  drain, 250 ms elsewhere), a monotonic performance.now() deadline, retry
  across a context swap, and a throwing predicate fails the run instead of
  silently spending the budget.
- One in-page window.__qaRenderFrames(count, capMs) helper serves both the
  gizmo-drag and the sibling-owner frame waits.
- The sibling pick counts as a stalled renderer only at 0 of 3 frames, so a
  slow SwiftShader frame still reports a real miss; the record flattens and
  prints the frame count once.
- Review screenshots log and continue when a capture fails.
- Launch with pipe: true (no localhost CDP port) and protocolTimeout
  300_000 like the other harnesses; drop the occlusion flag that puppeteer
  already passes by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Said TurkseverandClaude Opus 5.5 474d44c4db fix(qa-cctv-v2): report a stalled renderer apart from a missed sibling pick
When the sibling-owner pick misses after fewer than 3 rendered frames, the
cause is a stalled renderer, not a pick regression. Record it as a failure
on the headful sign-off path and as inconclusive under headless GL, with
"renderer stalled: N/3 frames" in the detail. A missed pick after 3 frames
still fails in both modes. Also corrects the catalog-size comment (~3,700).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Said TurkseverandClaude Opus 5.5 05f668dfa7 fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
The headful drain budget is uncapped (camCount * 800 + 30000 ms, ~50 min
at N≈3,700), but each page.waitForFunction is one CDP call that puppeteer
aborts after its 180 s default protocolTimeout. The .catch then recorded
"timed out waiting for drain on the real-GPU sign-off path" after ~3 min.

- Poll the drain in <=120 s slices (waitForFunctionSliced) so no single
  CDP call outlives protocolTimeout, whatever the catalog size.
- Launch with protocolTimeout 240 s, headroom above the largest slice and
  the 60 s fixed waits.
- Bound the sibling-owner 3-frame postRender wait at 10 s and report the
  frame count, so a renderer that stops producing frames fails with
  evidence instead of hanging the evaluate.
- List --disable-backgrounding-occluded-windows explicitly. Puppeteer
  25.10 already passes it by default, so this documents the headful
  contract rather than changing behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:07:34 +01:00
Sameh Khamis 93c1f6cd45 Merge pull request #1220 from manjunath22466/codex/openai-typed-send-rejection
fix replies after rejected openai typed commands
2026-10-19 14:06:19 -07:00
Sameh Khamis 3e39bb2cf4 Merge branch 'main' into codex/openai-typed-send-rejection 2026-10-19 13:51:48 -07:00
Sameh Khamis 194a547955 docs(changelog): match fragment style for #1220 2026-10-19 13:51:07 -07:00
German KhokhlovandClaude Opus 5.5 00e8e3b997 Merge origin/main into german/street-level-slim
Conflicts: the Street Level and Gemini voice strings both appended to the
es/ru catalogs, and both sides' modules to package-boundaries.json — kept
both. scripts/qa-panel-resize.mjs stays deleted (floating panels removed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:47:41 +04:00
Sameh Khamis 2f171c7d79 Merge pull request #872 from manjunath22466/feat/gemini-voice
add Gemini Live as an alternative voice provider
2026-10-19 13:10:47 -07:00
Sameh Khamis a1508a386e Merge branch 'main' into feat/gemini-voice 2026-10-19 12:56:36 -07:00
Sameh Khamis 1c727842df fix(voice): refuse unrequested Gemini tool calls and share pointer turns
in push-to-talk, answer a Gemini tool call that arrives with no input, reply
or tool result outstanding with a failure instead of running it, and refuse
every other call in the reply it starts, including one after unprompted
speech; the reply stays tracked so the next input retires it and its late
boundary cannot close the user's turn

in push-to-talk, a caption that arrives after its turn ended no longer opens
a reply, so the next request is not retired with its tool calls discarded
open mic cannot tell either case from a new reply and stays unchanged

check packet ownership after the narration callback before queuing tools,
and raise the per-connection call history to 4096 IDs

move the pointer turn event, context item and announcement into
pointerContext so the OpenAI and Gemini controllers share them, and drop the
Gemini token route's copy of the shared exact-Origin comparison
2026-10-19 12:30:54 -07:00
German KhokhlovandClaude Opus 5.5 fb777c1c93 fix(street-level): four regressions from the merge review
- The selected sequence is drawn past the per-tile caps again, and its
  highlight takes every loaded part, drawn or not: in a dense tile a
  clicked Mapillary or Panoramax line kept its cones but lost its line.
- photoViewer tries :fullscreen and :popover-open one at a time, each in
  its own try: Safari before 16.4 parses neither, and the fallback threw
  on every flat KartaView or Panoramax photo.
- A lit Street View off Google 3D says it needs the Google 3D map again,
  instead of "every provider is switched off".
- Chips carry their provider colour again (--chip-color, as on main).

Each comes with the test that reproduced it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:14:01 +04:00
manjunath bd2342dc81 test(voice): fix resize fixture overlap and trim duplicate coverage 2026-10-19 22:47:25 +05:30
Said Turksever 5c513684f5 Merge pull request #1201 from Danielslay86/cctv-stills-aspect
cctv: draw stills at source aspect
2026-10-19 17:46:05 +01:00
Said TurkseverandClaude Opus 5.5 ba5a1e25ee Merge branch 'main' into cctv-stills-aspect
Resolves the scripts/format-scope.json conflict with #1200 by keeping
both new CCTV test entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:32:04 +01:00
quanticsoul4772 4e3c1628b2 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 09:30:58 -07:00
Said Turksever 7fe95b76e9 Merge pull request #1200 from Danielslay86/cctv-live-icon-color
cctv: color live video cameras apart from stills
2026-10-19 17:29:05 +01:00
German KhokhlovandClaude Opus 5.5 4e2e0d6900 test(street-level): the gate waits for image cones to become pickable
On CI's SwiftShader the ground-clamped cones take a few frames after a
sequence is selected; a single pick search found none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:22:48 +04:00
Said Turksever 2120130997 Merge branch 'main' into cctv-stills-aspect 2026-10-19 17:10:53 +01:00
manjunath 10916227cb refactor(voice): isolate gemini live provider core 2026-10-19 21:40:40 +05:30
Said TurkseverandClaude Opus 5.5 c808bfe609 fix(cctv): harden the Kartverket height lookup for Vegvesen cameras
- Default backoff is 30 min, longer than the 15-minute catalog refresh,
  so a failing Kartverket costs one attempt per backoff, not per refresh.
- A point a 200 answer leaves out is cached as no height (6 h TTL)
  instead of being asked again, and waited on, every refresh.
- Only 408, 429 and 5xx stop the lookup; any other 4xx marks that
  request's points as no height and the lookup carries on.
- Each request has its own 10 s timeout inside the shared 30 s deadline;
  a lookup cut short only by the deadline resumes without a backoff.
- The lane waits only on a cold cache (>10% of cameras missing); a few
  new cameras or expired misses are looked up in the background.
- applyVegvesenGroundHeights skips cameras without finite coordinates,
  so it really never throws.
- clearKartverketHeightCache bumps a generation, so a lookup started
  before a clear cannot write to the cache, backoff or pending slot.
- Credit and DATA_SOURCES note the modification (water set to 0 m,
  rounded to 0.1 m) as CC BY 4.0 asks; docs match the stop rules.
- Tests for the deadline being shared, per-request timeouts, 503,
  oversized and non-JSON bodies, 4xx, left-out points, Retry-After end
  to end, overlapping refreshes, the warm-cache wait, bad coordinates,
  the cache-clear generation and the loader's wait budget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:03:50 +01:00
Said TurkseverandClaude Opus 5.5 b66d02df7c fix(cctv): space out failed background refreshes; test the stale paths
- A background catalog refresh that rejects now keeps the old catalog for
  another TTL, as an empty refresh already did, so a refresh that keeps
  failing is retried once per TTL instead of on every request.
- Trim notes are keyed by the source list they describe, and /sources asks
  for the list it served, so a request answered during a refresh never pairs
  the old cameras with the new list's notes.
- Tests: a failed background refresh (one warning, no unhandled rejection,
  no retry before the TTL, trim notes stay with their list) and an empty
  background refresh (old catalog kept, no upstream fetch before the TTL).
  The race assertion's message now names the failure; its timer is cleared.
- The Tarktee test no longer imports the new constant, so on main it fails
  on its own assertion instead of taking the whole file down.
- precompute-cctv-heights waits 60 s for a cold catalog, past Tarktee's 30 s.
- CURRENT-STATE notes the 30 s bound and the retry spacing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:02:06 +01:00
Said TurkseverandClaude Opus 5.5 0561277dcb test(cctv): pin Ontario 511 key handling against regressions
- Assert the key never reaches the camera records sent to the browser or
  the "Loaded" log line.
- Count fetches in the keyless test instead of relying on assert.fail
  inside the mock, which the loader's catch would swallow; a
  whitespace-only key is treated as missing.
- Cover the key hint for 400/401/403 and its absence for 429/500/503,
  and log the failure as one string without a trailing empty argument.
- Add _resetOntarioKeyWarningForTest so the logged-once check no longer
  depends on test order.
- Read CCTV_ONTARIO_MAX_SOURCES from the same env as the key, and keep
  console output out of the suite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:59:52 +01:00
Said Turksever e9a234094b Merge remote-tracking branch 'upstream/main' into pr-1228-fix
# Conflicts:
#	README.md
2026-10-19 16:56:57 +01:00
German KhokhlovandClaude Opus 5.5 a536e7879c Merge origin/main into german/street-level-slim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:56:09 +04:00
Said TurkseverandClaude Opus 5.5 29fb756a5c test(cctv): cover the focus de-emphasis icon colors
refreshCctvFocusStyles passes cameraColorFor as baseColorFor, but only
refreshCoverageStyles was tested. The new rendering.test.mjs runs the
focus pass through createRendering with stubbed focus services and
checks that an idle video camera fades from violet, a still from cyan
and the active camera from amber. It fails if the focus callback goes
back to the old active/idle check. Also adds the missing semicolon on
the policy.js import in cctv.test.mjs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:50:21 +01:00
Said TurkseverandClaude Opus 5.5 35c612c5f6 test(cctv): lock the still draw path and fit-aware dedupe
Drive drawProjectionFrame with a fake runtime and a recording canvas:
a 640x480 still draws at its fitted rectangle over the matte, the same
pixels at the same size skip the redraw, and the same pixels at a new
aspect redraw at the new fit. The last case fails on the pixel-only
signature; the first fails if drawImage stretches to the canvas again.
Expected rectangles now read the canvas size from policy.js.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:50:21 +01:00
manjunath a6f229d870 Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into fix/cctv-tool-routing 2026-10-19 21:12:55 +05:30
manjunath 1eded0468f Merge upstream main into fix/location-resolution 2026-10-19 21:12:47 +05:30
manjunath bd087d6096 Merge upstream main for typed-send compatibility 2026-10-19 21:12:47 +05:30
Said Turksever 86eca5abf3 Merge branch 'main' into cctv-live-icon-color 2026-10-19 16:26:45 +01:00
Said Turksever 0f51eb21ba Merge pull request #816 from kvnloo/fix/context-radio-owner-invariant
fix(ui): dismiss compact Radio on Context rail restoration
2026-10-19 16:23:26 +01:00
Said Turksever 9f6e0ef60f Merge branch 'main' into fix/context-radio-owner-invariant 2026-10-19 16:15:15 +01:00
manjunath 5fd5d688dc Merge upstream main for typed-send compatibility 2026-10-19 20:39:51 +05:30
Said TurkseverandClaude Opus 5.5 d80683c44b docs(changelog): end the #1200 fragment with a newline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:05:38 +01:00
Said Turksever 56c5d03b65 Merge upstream/main into cctv-stills-aspect 2026-10-19 16:05:15 +01:00
Said Turksever b8bfeb7aa2 Merge upstream/main into cctv-live-icon-color 2026-10-19 16:04:58 +01:00
manjunath 3b3895f82e Merge upstream main into fix/location-resolution 2026-10-19 20:30:51 +05:30
Said Turksever 7214f71878 Merge pull request #880 from kvnloo/feat/world-packet-boundary-clean-20260929
feat(agent): extract model-free World Packet boundary
2026-10-19 16:00:23 +01:00
manjunath d09d1ddad1 Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into fix/cctv-tool-routing 2026-10-19 20:28:18 +05:30
Daniel Slay 8e635c9a3a test(cctv): pin the camera icon colors; say video feed, not live 2026-10-19 10:50:22 -04:00
Daniel Slay 1959b33c48 fix(cctv): test the still fit, redraw on size change, share the matte color 2026-10-19 10:39:41 -04:00
Said TurkseverandClaude Opus 5.5 daaaa348b7 test(agent): pin World Packet to the pre-extraction payload
The parity test compared getWorldPacket with voice, which now calls
getWorldPacket. Compare both against a fixed payload captured from main's
get_current_view_state before the extraction, key order included, and
cover a non-finite now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:19:07 +01:00
Said TurkseverandClaude Opus 5.5 0f237a32d4 fix(agent): age World Packet feeds against the clock for non-finite now
A default parameter only covers undefined, so now: null aged every feed
against the epoch and reported stale data as just now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:19:07 +01:00
Said Turksever 4ecbe54997 Merge bilawalsidhu/gods-eye-view main into feat/world-packet-boundary-clean-20260929 2026-10-19 15:19:04 +01:00
Trygve Hemminghytt 4999f6dc60 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-19 16:16:37 +02:00
Said Turksever 3d8e1ef00f Merge pull request #605 from Danielslay86/cctv-florida-stills
feat(cctv): FL511 Florida statewide camera pack (stills)
2026-10-19 15:11:43 +01:00
Said Turksever 326c41df9c Merge branch 'main' into cctv-florida-stills 2026-10-19 14:56:33 +01:00
Said Turksever beca41c676 Merge pull request #1056 from quanticsoul4772/feat/tools-area-breakdowns
feat(tools): count and summarize every match in the area tools
2026-10-19 14:53:34 +01:00
Said TurkseverandClaude Opus 5.5 a29d5a6874 docs(changelog): credit kvnloo in the #816 fragment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:47:13 +01:00
Said Turksever 839442b20b Merge branch 'main' into feat/tools-area-breakdowns 2026-10-19 14:46:41 +01:00
Said TurkseverandClaude Opus 5.5 c174742e2c fix(cctv): FL511 written-consent wording and robust catalog paging
- State FL511's terms the same way everywhere: individual, personal use
  only, and any other use (a public deployment included) needs FDOT's
  express written consent. .env.example, README, dataCredits, the source
  licence string, the catalog comment and the changelog fragment said only
  "individual, non-commercial use".
- Page the ArcGIS catalog on exceededTransferLimit alone and advance the
  offset by the rows each page returned, so a lower server-side max
  record count no longer cuts the catalog short at the first short page.
- Order pages by OBJECTID_1 (this layer's object id; a plain OBJECTID is
  rejected with HTTP 400) so offset paging is stable.
- Raise the page ceiling to 40 and warn when it is hit.
- Tests: short pages with exceededTransferLimit keep paging, orderByFields
  is sent, and a service that ignores resultOffset stops at the ceiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:32:58 +01:00
Said TurkseverandClaude Opus 5.5 4c383214f7 fix(tools): average aircraft speed over airborne aircraft only
aircraft_in_area stats summarized altitude over airborne aircraft but
speed over every match, so parked aircraft pulled the mean speed toward
zero. Both figures now use the same airborne subset, and the on-ground
fixture is stationary so the test can tell the difference.

Reserved AIS ship-type codes 100 to 255 now read as unknown, as type 0
does, instead of showing up in the breakdown as their own type.

Adds tests for an area where every aircraft is on the ground and for
find_vessel type and destination normalization, and notes in the
changelog fragment that every vessel answer is now normalized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:29:11 +01:00
Said Turksever 5d480a5fc2 Merge upstream/main into fix/context-radio-owner-invariant 2026-10-19 14:23:54 +01:00
Said Turksever 7712ecf6ed Merge pull request #975 from Sunil56224972/fix/terrain-same-site-gate-on-686
fix(server): refuse cross-site requests on the terrain-heights route (stacked on #686)
2026-10-19 14:14:54 +01:00
Said TurkseverandClaude Opus 5.5 256d7c273e Merge #686 head 558b0275 so its entry lives in changelog.d
#686 moved its changelog entry from CHANGELOG.md into
changelog.d/686-terrain-heights-bounds.md. Take #686's side so this
branch no longer edits CHANGELOG.md directly and no longer conflicts
with #686.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:56:15 +01:00
Said Turksever d921a1b801 Merge branch 'main' into fix/terrain-same-site-gate-on-686 2026-10-19 13:51:37 +01:00
quanticsoul4772 cd7e29c635 Merge branch 'main' into upstream/cctv-tarktee-refresh 2026-10-19 05:34:17 -07:00
Rudy Celekli ada7d8852f docs(changelog): record launch cache provenance PR 1199 2026-10-19 13:30:27 +01:00
Rudy Celekli 7844e765b4 fix(launches): retain stale-cache provenance in layer status 2026-10-19 13:30:27 +01:00
quanticsoul4772andClaude Opus 5.5 5b12f51e98 fix(cctv): warn once per failed background refresh; test the single flight
Attach the background-refresh warning where the refresh starts, so a
failed refresh logs once instead of once per request served meanwhile.
The test now checks that requests during a refresh share it, and turns
the hang off before releasing the fetches so a retrying loader can't
stall it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:28:56 -07:00
Rudy Celekli f747c4828e docs: add Launch Library changelog for PR 1197 2026-10-19 13:11:58 +01:00
Rudy Celekli a310cbc2f0 fix(space): align Launch Library requests with provider guidance 2026-10-19 13:11:58 +01:00
German KhokhlovandClaude Opus 5.5 d983ef23d8 fix(street-level): credit every imagery source in the attribution lightbox
Mapillary was never in DATA_CREDITS (since #768), and Google Street View,
KartaView and Panoramax came without entries. HAL-530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:53:46 +04:00
Jerry LaiandClaude Opus 5.5 e324e2231c docs(changelog): name the Taiwan CCTV fragment for #1237
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:53:27 +08:00
Jerry LaiandClaude Opus 5.5 73336b297e feat(cctv): add Taiwan Freeway Bureau camera pack
Read ~1,800 national freeway cameras at runtime from the Freeway Bureau's
keyless MOTC 2.0 CCTV.xml table. Each published MJPEG stream maps to the
same camera server's single-JPEG path; only those exact HTTPS pairs on the
four freeway.gov.tw camera servers are registered. On by default,
CCTV_TAIWAN_ENABLED=0 skips it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:52:47 +08:00
German KhokhlovandClaude Opus 5.5 6390a3346f Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:

- lineCoverage.js is this branch's coverage.js made provider-neutral
  (draped lines near the ground only, the selection drawn over them, key
  and rate-limit blocking); sequenceCones.js is sequences.js the same way.
  Mapillary, KartaView and Panoramax all build on them; Mapillary moves
  back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
  orbit overview points (Panoramax's density dots included), stale-tile
  swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
  attachMapStackController (Street View on Google 3D only), ground clicks,
  openNearest with an optional provider nearestImage, openImage {frame},
  reportError, the view-cone marker (now an SVG glyph), filters /
  coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
  or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
  KartaView and Panoramax; catalogs, docs, boundaries and format scope
  follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:51:45 +04:00
manjunath 1763143337 docs(changelog): record location resolution fix 2026-10-19 11:02:30 +05:30
manjunath a331616a9d fix(location): preserve geocoded places and region provenance 2026-10-19 11:01:18 +05:30
quanticsoul4772andClaude Opus 5.5 ca9dbea060 docs(changelog): fragment for #1234
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:13:36 -07:00
yaka0007 9b400622af docs(changelog): add fragment for #1233 2026-10-19 01:13:30 -04:00
yaka0007 3792d7377e fix(cctv): harden Tarktee DATEX catalog fetches and dedupe before cap 2026-10-19 01:12:18 -04:00
quanticsoul4772andClaude Opus 5.5 1b66d284c4 docs(cctv): CURRENT-STATE describes the background catalog refresh and Tarktee's 30 s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:12:13 -07:00
quanticsoul4772andClaude Opus 5.5 f26586bb3e fix(cctv): serve the camera catalog while it refreshes
An expired catalog made every /api/cctv request (camera list and frames)
wait for the full refresh, which lasts as long as the slowest of the live
packs: Tarktee's DATEX feeds took 2 to 16.6 s across ten requests. With
Tarktee's timeout raised to 30 s, that wait could reach 30 s at each
15-minute refresh. The cached list is now returned while a single-flight
refresh replaces it in the background; only a first load, with nothing
to serve, waits. A background refresh that throws is logged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:08:24 -07:00
quanticsoul4772andClaude Opus 5.5 6b3f7a3ec1 fix(cctv): give Tarktee's slow DATEX feeds 30 s instead of the shared 15 s
The Estonian Transpordiamet feeds answered in 2 to 16.6 s across ten
requests (120 and 160 KB), and both must arrive for any camera to load,
so the shared 15 s catalog timeout dropped the whole pack on about a
third of loads ("Tarktee DATEX download error: The operation was aborted
due to timeout"), for the 15 minutes until the next catalog refresh.
TARKTEE_FETCH_TIMEOUT_MS gives the two requests 30 s; other packs keep
15 s.

Live: 175 cameras in 3.3 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:04:52 -07:00
quanticsoul4772andClaude Opus 5.5 83b9940733 docs(cctv): Ontario 511 docs match the key requirement and the developer agreement
Review fixes for #1228: CURRENT-STATE.md and the constants comment no
longer call Ontario 511 keyless. DATA_SOURCES.md notes Ontario 511's
Developer's Access Agreement (511on.ca/developers/resources): the data
stays under the Open Government Licence - Ontario, key requests are
reviewed case by case, attribution by link is encouraged and the logo
rules don't apply because the app shows none, and the "as is" disclaimer
should be passed on, which the credit now does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:48:02 -07:00
quanticsoul4772andClaude Opus 5.5 91461381ee fix(cctv): point a rejected Ontario key at ONTARIO_511_API_KEY; README notes the key
A wrong or revoked key logged only "download failed: 400"; a 400, 401 or
403 now adds "(check ONTARIO_511_API_KEY)". The README's CCTV row listed
Ontario among the default cameras with no mention of the key it now
needs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:43:15 -07:00
quanticsoul4772andClaude Opus 5.5 cd0956eef7 docs(changelog): fragment for #1228
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:46:13 -07:00
quanticsoul4772andClaude Opus 5.5 fa085cbd64 docs(cctv): point to the 511on.ca pages that exist for the Ontario key
511on.ca/developers is a 404. Sign up at 511on.ca/my511/register, then
request the key while logged in at 511on.ca/developers/doc (both checked).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:40:22 -07:00
quanticsoul4772andClaude Opus 5.5 48c8a64c97 fix(cctv): Ontario 511 cameras take the developer key the API now requires
The Ontario 511 camera list answered 400 "Invalid Key" on every catalog load
("[CCTV] Ontario 511 camera download failed: 400"), so the pack loaded
nothing. 511on.ca's developer docs now say the API "Requires a developer
key" (free, from 511on.ca/developers; 10 calls a minute). The loader reads
ONTARIO_511_API_KEY and sends it as `key`; without it the pack is skipped
with one log line instead of a failed request on every load. The key is
never logged. .env.example and DATA_SOURCES no longer call the endpoint
keyless.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:40:22 -07:00
Trygve Hemminghytt 59db4ac227 docs(cctv): state the measured cold lookup time and what a slow first run leaves 2026-10-18 20:22:21 +02:00
Trygve Hemminghytt 15208b5c40 docs: move the changelog entry to changelog.d 2026-10-18 20:03:24 +02:00
Trygve Hemminghytt 80a0954030 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 20:02:40 +02:00
manjunath 8d3c929740 docs: add numbered changelog for typed send rejection 2026-10-18 16:16:39 +05:30
manjunath 7688e53424 fix(voice): skip replies for rejected typed sends 2026-10-18 16:15:06 +05:30
daikaginza 558b0275a4 docs(changelog): move the entry into a changelog.d fragment
Main now takes pending entries as changelog.d/<pr>-<slug>.md
files (#787), so the entry leaves CHANGELOG.md for changelog.d/686-terrain-heights-bounds.md.
2026-10-18 13:36:06 +07:00
daikaginza 50559723a5 Merge upstream/main 93ea088f4 2026-10-18 13:36:06 +07:00
manjunath e20e858ba8 docs(changelog): record cctv routing fix 2026-10-18 11:15:09 +05:30
manjunath 9b1967a59e fix(voice): report actual cctv toggle results 2026-10-18 11:13:30 +05:30
quanticsoul4772andClaude Opus 5.5 d39256e700 fix(tools): ship types read as their family; letterless destinations are unknown
AIS sends ship types as ITU-R M.1371 codes, so vessels_in_area grouped
"70", "74" and "79" as three types, and type: "tanker" matched nothing on
feeds that send codes. Codes now read as the family the vessel cards already
show (CARGO, TANKER, TUG), for rows, group_by and the type filter. The code
table moves to src/data/vesselTypes.js, which has no i18n import, so the
server-side tools can use it; vesselLabels re-exports it.

A destination with no letters ("0", "---") now counts as unknown.

Live, Singapore: "most by type: CARGO (3), TANKER (3), PASSENGER (1)"; "how
many tankers near Singapore" found 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:49:02 -07:00
Sunil56224972 97816a35de Merge remote-tracking branch 'upstream/main' into fix/terrain-same-site-gate-on-686 2026-10-18 03:36:52 +05:30
quanticsoul4772 d06e485c12 Merge branch 'main' into feat/tools-area-breakdowns 2026-10-17 14:49:25 -07:00
quanticsoul4772andClaude Opus 5.5 7bf268ec42 fix(tools): read an airline only from a callsign of seven characters or fewer
ICAO flight-plan Item 7 caps aircraft identification at seven characters, so
the flight number after the designator is one to four characters, not five.
Philippine RP-C1234 and Cuban CU-T1240 registrations read like a designator
plus a flight number once the hyphen is dropped; they now count as unknown.
Cubana's CUB123 still counts as CUB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:37:07 -07:00
quanticsoul4772 661e1d7fa3 Merge remote-tracking branch 'origin/main' into feat/tools-area-breakdowns 2026-10-17 14:32:52 -07:00
quanticsoul4772andClaude Opus 5.5 aec4f67ca9 feat(tools): count aircraft in an area by airline
OpenSky, the default civil feed, reports no operator, so group_by: operator
said "no operator reported". aircraft_in_area now also takes
group_by: 'airline', read from the ICAO designator in each callsign: three
letters followed by a flight number that starts with a digit (SWA1234,
UAL12A). Registrations used as callsigns (N123AB) count as unknown. Rows are
unchanged; the designator is derived for the breakdown only.

Live, Texas: "785 aircraft ...; most by airline: AAL (89), SWA (65), UAL (52);
283 unknown."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:32:46 -07:00
Daniel Slay 5a87fb3b15 docs(changelog): credit the stills aspect fix 2026-10-17 15:15:09 -04:00
Daniel Slay 548e7f23d1 docs(changelog): credit the live camera color 2026-10-17 15:10:49 -04:00
Kevin Rajan daff74fe04 test(navigation): read trackable nouns from canonical packet owner
World Packet #880 removes the duplicate tracking table from gevActions; the Cockpit refusal source-scan test should inspect the actual exported source of truth instead of assuming the previous file position. Fix failing Node 24 exact-head CI without duplicating runtime definitions.
2026-10-17 13:17:43 -05:00
Daniel Slay a1bd7ff37c feat(cctv): draw stills at source aspect, pillarboxed 2026-10-17 14:14:55 -04:00
Daniel Slay 672191497a feat(cctv): color live video cameras apart from stills 2026-10-17 14:14:55 -04:00
Kevin Rajan 1c4c903ac6 fix(agent): align World Packet with reviewer feedback and voice parity
Reuse one context/contacts/tracking owner in voice and World Packet. Compare voice outputs after stripping presentation fields; remove the unconsumed fingerprint API pending an actual caller. Document the exported packet boundary and reject null options safely.

Review guidance: asturksever on origin #880; Windows parity diagnosis by quanticsoul4772. Existing source ownership retained.
2026-10-17 13:12:46 -05:00
Daniel Slay c09865e582 feat(cctv): FL511 Florida camera pack (stills, opt-in) 2026-10-17 13:50:33 -04:00
Kevin Rajan 83fc895442 fix(agent): own portable i18n marker in World Packet boundary 2026-10-17 05:14:43 -05:00
Kevin Rajan e4f526415d style(agent): apply pinned Prettier output and remove temporary probe
Restore the main branch formatter unchanged and use byte-exact Prettier 3.9.6 formatting on the World Packet parity regressions.
2026-10-17 05:12:15 -05:00
Kevin Rajan f4a4df5044 test(agent): retrieve exact pinned formatter output for #880 (temporary) 2026-10-17 05:10:37 -05:00
Kevin Rajan 3e23b4cd39 style(agent): wrap long test declarations to Prettier policy 2026-10-17 05:08:15 -05:00
Kevin Rajan cefbad3c2a refactor(agent): preserve compact voice contract in World Packet extraction
Reconcile #880 with current main. Delegate the view-state builder while keeping enabled-only layers, disabledLayerCount, provenance, context semantics, and existing voice behavior. Add parity and roster-read regressions; retain package export, boundaries and changelog fragment.

Preserve original GEV voice, layer and packet contributor credit.
2026-10-17 05:04:55 -05:00
Kevin Rajan c1dd4171db fix(ui): verify real PanelChrome rail ownership of compact Radio
Carry reviewed #816 onto current main without conflicts; preserve i18n updates, replace obsolete changelog edit with a fragment, and add a constructor-wiring regression alongside the owner and rail tests.

Credit: raysung-2026 (report), asturksever (review), quanticsoul4772 (Windows diagnosis).
2026-10-17 04:56:07 -05:00
Sunilanddaikaginza c7ff33ac2e fix(server): admit reverse-proxied same-origin terrain requests
Review follow-up on #975. The shared same-site gate refuses any forwarding header, which is right for routes that spend a key, but terrain is keyless and needed for rendering: behind the reverse proxy the README documents, every /api/terrain/heights chunk got 403 and fell back to the geoid. Terrain now uses admitSameSiteKeyless, which keeps the Sec-Fetch-Site and exact-Origin checks (comparing Origin against a single X-Forwarded-Proto scheme) but does not refuse forwarding headers. The shared admitSameSite gate is unchanged for every other route. The changelog entry moves to changelog.d/975-terrain-cross-site-gate.md.

Co-authored-by: daikaginza <[email protected]>
2026-10-17 14:36:24 +05:30
Sunil56224972 77768e9fff Merge remote-tracking branch 'upstream/main' into fix/terrain-same-site-gate-on-686
# Conflicts:
#	CHANGELOG.md
#	docs/CURRENT-STATE.md
#	scripts/format-scope.json
2026-10-17 14:32:33 +05:30
Sunil56224972 5d0c367315 Merge remote-tracking branch 'upstream/pr-686' into fix/terrain-same-site-gate-on-686
# Conflicts:
#	CHANGELOG.md
2026-10-17 14:30:09 +05:30
quanticsoul4772 db73c6908e Merge branch 'main' into feat/tools-area-breakdowns 2026-10-16 17:18:17 -07:00
quanticsoul4772 2a6ba1180d Merge branch 'main' into feat/tools-area-breakdowns 2026-10-16 17:09:08 -07:00
quanticsoul4772 c6706a5a52 Merge branch 'main' into feat/tools-area-breakdowns 2026-10-16 16:04:40 -07:00
quanticsoul4772 c869b248b7 Merge branch 'main' into feat/tools-area-breakdowns 2026-10-16 15:59:46 -07:00
quanticsoul4772 2b5a5128fe Merge branch 'main' into feat/tools-area-breakdowns 2026-10-16 15:56:24 -07:00
quanticsoul4772 d604b97a4d Merge remote-tracking branch 'origin/main' into fix-1056
# Conflicts:
#	src/tools/queries/aviation.test.mjs
2026-10-16 15:50:13 -07:00
quanticsoul4772andClaude Opus 5.5 8f6671b6c2 Merge upstream main into feat/tools-area-breakdowns
Move the changelog entry into changelog.d/1056-area-tool-breakdowns.md
(upstream's one-file-per-PR rule, #787) and leave CHANGELOG.md as on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:22:39 -07:00
quanticsoul4772 5acf254f8b Merge remote-tracking branch 'origin/main' into upd-1056 2026-10-16 14:22:29 -07:00
quanticsoul4772andClaude Opus 5.5 60444cca53 style: format the AIS type 0 regression test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 12:06:24 -07:00
quanticsoul4772 1d418fd5af Merge remote-tracking branch 'origin/main' into fix-1056-ais0
# Conflicts:
#	CHANGELOG.md
2026-10-16 12:06:22 -07:00
quanticsoul4772andClaude Opus 5.5 52863a9f42 fix(tools): count AIS ship type 0 as unknown in vessel breakdowns
AIS type 0 means "not available" (ITU-R M.1371), but the source keeps it
as '0', so group_by: 'type' listed "0" as a type and understated
unknown_count. vessels_in_area rows now report it as null, and a
regression fixture mixes type 0 with Cargo and Tanker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 12:06:10 -07:00
manjunath 8abc99aa0a fix(voice): preserve request ownership across callbacks
keep typed and tool-follow-up responses attached to their original requests
reject duplicate events before they affect newer work
preserve newer input and Radio handoffs during old callback cleanup
integrate the tested main snapshot without dropping existing history
2026-10-16 22:29:00 +05:30
manjunath 4fde920665 feat(voice): harden Gemini Live provider integration 2026-10-16 17:09:03 +05:30
Trygve Hemminghytt 2bec3d521b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/CURRENT-STATE.md
#	server/providers/cctv/sources.js
2026-10-16 07:21:30 +02:00
daikaginza 202a310c7a Merge remote-tracking branch 'upstream/main' into fix/terrain-heights-bounds
main now rejects out-of-range coordinates at the proxy itself (19fcc00b), the
same check this branch added. main's version is kept, and the changelog entry
no longer claims that part; the cache cap, file ceiling and rate limit remain.
2026-10-16 09:25:51 +07:00
quanticsoul4772andClaude Opus 5.5 acd75efc7c Merge upstream main into feat/tools-area-breakdowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:33:20 -07:00
Trygve Hemminghytt e2dacd92ea Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 21:15:57 +02:00
quanticsoul4772andClaude Opus 5.5 38b95fb3b1 Merge upstream main into feat/tools-area-breakdowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:00:37 -07:00
quanticsoul4772 9688f15af7 Merge branch 'main' into feat/tools-area-breakdowns 2026-10-15 08:20:45 -07:00
quanticsoul4772andClaude Opus 5.5 edf0392248 Merge upstream main into feat/tools-area-breakdowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:28:45 -07:00
Kevin Rajan c90e212049 chore(ui): reconcile compact Radio ownership fix with current main
Preserve upstream changes, restore the reviewed Radio ownership regression,
and retain reporter raysung-2026 credit and #804 provenance.

Reconciles upstream main 81cf437 and PR #816 head 702ecc3; no force push.
2026-10-14 18:17:57 -05:00
quanticsoul4772andClaude Opus 5.5 e312a007ab Merge upstream main into feat/tools-area-breakdowns
Keep this branch's CHANGELOG entry above upstream's; upstream main no longer lists the GBFS station-name entry an earlier merge carried here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:09:11 -07:00
trygvehhh 2aa0ae851f Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-14 21:18:04 +02:00
Kevin Rajan 702ecc38e2 chore: reconcile #816 with merged right-rail fix #918 2026-10-14 13:43:55 -05:00
Kevin Rajan f11a532139 chore: reconcile Context Radio ownership fix with upstream main 2026-10-14 11:58:52 -05:00
Trygve Hemminghytt 6f054408da Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	DATA_SOURCES.md
#	docs/CURRENT-STATE.md
#	server/providers/cctv/constants.js
#	server/providers/cctv/sources.js
2026-10-14 16:00:43 +02:00
quanticsoul4772andClaude Opus 5.5 166190b710 Merge origin/main into feat/tools-area-breakdowns
Resolve the CHANGELOG.md conflict by keeping both new entries: this
branch's group_by/stats entry above main's GBFS v3 station-name entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 06:01:04 -07:00
German KhokhlovandClaude Fable 5.1 392141bfc5 test(street-level): the gate waits for the photo line to be pickable
Draped lines build in a worker after their tile decoded: on a slow CI runner
the single pick scan ran before the line existed and the gate failed at
"a sequence line across the middle of the view". The scan now retries for up
to 30 s.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 15:02:02 +04:00
German KhokhlovandClaude Fable 5.1 e2f9952867 fix(street-level): a click picks in a 9 px window, so a thin line is hit a few pixels off
Coverage lines are 2.5 px wide and there is no hover cue any more: with the
default 3 px pick window a click just beside a line selected nothing.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 13:53:54 +04:00
German KhokhlovandClaude Fable 5.1 361f1c0fb8 fix(street-level): refuse tile misses during a 429 wait; parse HTTP-date Retry-After; cap a cached tile's size
Review of #974 (asturksever):

- After a 429 the other tiles of the view, and the fetches queued behind the
  first, still went to Mapillary. The proxy now answers 429 itself until the
  wait is over, scoped to the token that was refused.
- A Retry-After sent as an HTTP date read as 60 s; it is now the seconds
  left, with the same 10-minute cap.
- A body that failed to trim (up to the 48 MiB cap) could fill the 120-entry
  cache; anything over 4 MiB is served but not kept.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 13:27:41 +04:00
German KhokhlovandClaude Fable 5.1 c29ccd55d6 Merge origin/main: keep the provider registry, slim it
main grew the Street Level provider registry while this branch removed it
(#982–#985: injected providers, an empty composition, the `r` switch field).
The registry, params, uiState and pick router come back as main has them and
the slimmed layer sits behind the provider contract:

- The core (index.js) drives providers as on main, without surface modes,
  ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key
  status is asked with a 10 s timeout and asked again on a switch-on while no
  key is known.
- Mapillary is mapillaryProvider.js over the slim coverage, sequences and
  viewer modules, which read the filter and the active flag from the provider
  context.
- The viewer host mounts one provider adapter at a time, no prewarm.
- The panel has a chip per provider again (the pill switches the layer);
  no legend, EXPAND stays the Fullscreen API.
- The contract drops what no provider implements: groundCaster, meshSampler,
  getSurface/setSurface, nearestImage, prewarm, reportError, the legend and
  surface fields of the UI snapshot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 13:24:25 +04:00
Kevin Rajan 19372875e2 merge: resolve PR #816 against upstream main (8b8ccc9a)
Retain upstream changes to the panel controller, formatting scope, changelog and docs. Preserve the #804 ownership fix and its rail restoration regression (original report: @raysung-2026).
2026-10-12 10:43:10 -05:00
Kevin Rajan d9e2c8a7e7 fix(ui): enforce compact Radio ownership on right-rail restores
Close the overlay when adaptive layout restores an auto-collapsed Context directly. Regression covers the reachable rail reflow path; changelog credits the original report by raysung-2026 (#804).
2026-10-12 10:38:19 -05:00
trygvehhh a15747ca61 Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-11 13:23:17 +02:00
quanticsoul4772andClaude Opus 5.5 1371a86713 docs: say the area breakdowns reach MCP clients only
Voice leaves aircraft_in_area and vessels_in_area out, so the changelog no
longer suggests spoken questions gain breakdowns. It also stops leading with
operators, which the default civil feed never reports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:11:47 -07:00
quanticsoul4772andClaude Opus 5.5 603f428397 fix(tools): say when a breakdown's field is missing from the feed
OpenSky, the default civil feed, reports no operator or type code, so a
breakdown by either put every aircraft under unknown while the summary said
nothing. The summary now says "no operator reported" in that case, and
otherwise appends the unknown count after the largest groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 20:35:42 -07:00
quanticsoul4772andClaude Opus 5.5 2110d06e36 feat(tools): count and summarize every match in the area tools
aircraft_in_area and vessels_in_area return at most 200 rows, so a client
asked which operators dominate an area, or the average altitude there, could
only estimate from a truncated sample. Both tools now take an optional
group_by (aircraft: operator, origin_country, type_code; ships: type,
destination) and stats (airborne altitude and speed for aircraft, speed for
ships). Each is computed over every match before rows are capped. Breakdowns
list the ten largest groups plus other and unknown counts, and the summary
names the three largest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 20:32:45 -07:00
Sunil e38672b45d docs: document terrain same-site gate 2026-10-09 14:38:41 +05:30
Sunil d19e9f8b93 Merge remote-tracking branch 'upstream/main' into fix/terrain-same-site-gate-on-686
# Conflicts:
#	CHANGELOG.md
2026-10-09 14:37:03 +05:30
Sunilanddaikaginza ff799d1520 fix(server): refuse cross-site requests on the terrain-heights route
Follow-up to #686 requested on #923. The route's per-client limiter keys on the socket address, which for a cross-site <img> is the visitor's own browser, so any web page could fill the bounded cache with junk keys and spend the visitor's 90/min quota, leaving the app's own terrain lookups on 429. Gate the route with admitSameSite before the limiter, like the Places routes.

Co-authored-by: daikaginza <[email protected]>
2026-10-08 21:13:16 +05:30
German KhokhlovandClaude Fable 5.1 e214129e45 refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory
- No pointer cursor over coverage lines (no other layer has one); a click
  test replaces the hover tests.
- No legend: one source, one colour.
- The on-globe credit lives in index.js; credits.js is gone.
- Billboard glyphs are SVG data URLs with the same geometry, so tests need
  no canvas stub.
- The tile cache keeps the 120 most recently used tiles, same 24 h TTL,
  instead of a byte budget; listTileLayers was test-only.
- providers/mapillary/ is flattened into the layer and its policy merged
  into one policy.js; the source's method list is a literal in the catalog.
- One fakeCesiumViewer for the layer tests.
- The fixture gate keeps the steps the unit tests do not cover: coverage and
  credit, a rejected key, line -> cone -> photo, close, a keyless page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 19:17:45 +04:00
German KhokhlovandClaude Opus 5.5 34d43c7e3f refactor(ui): drop the unused panel drag and position code
No markup carries a drag handle, so nothing called it: panel position
controls keep only collapsed state and the layout-reset notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:25:16 +04:00
German KhokhlovandClaude Opus 5.5 8e5e3cdd3b fix(street-level): re-check an unknown key, Esc leaves full screen, say why a view is empty
- A failed or missing key status is asked again on the next switch-on, and
  the check gives up after 10 s instead of holding LOADING forever.
- Waiting for the key status reads as LOADING while the layer is on.
- Esc shrinks the photo in full screen too, before the panel's own Esc.
- Destroying the panel with the iPhone popover open leaves no popover behind.
- An empty view says whether the filter emptied it.
- A freed upstream tile slot passes straight to the oldest waiter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:25:16 +04:00
German KhokhlovandClaude Opus 5.5 d313b6e62c chore: ignore local .claude settings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:44:32 +04:00
German KhokhlovandClaude Opus 5.5 48b889590d docs(street-level): shorten comments and docs, drop audit references
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:44:32 +04:00
German KhokhlovandClaude Opus 5.5 d59b4d05bb refactor(street-level): trim dead paths, duplicated labels and test-only API
- visibleBbox: no view-rectangle fallback or grid option; its one caller
  always ranges the rays
- tilesForBbox returns the tiles; normalizeBbox takes arrays only
- viewerHost: no try/catch around adapter calls that catch for themselves
- coverage: counts come from the drawn sequences; no sequenceCount export
- one keyStatusLabel for the pill and the layer list
- no constructor source check, double-init guard or selectSequence /
  clearSequence on the layer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:04:05 +04:00
German KhokhlovandClaude Opus 5.5 777aa62586 fix(street-level): a tile fetch every client has left is cancelled, or never started
A client drops the tiles a camera move leaves behind; their fetches no
longer run on or hold the six upstream slots ahead of the tiles in view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:44:28 +04:00
German KhokhlovandClaude Opus 5.5 99264e80d6 docs(street-level): drop stale provider and stepping wording; the gate fails when it cannot park
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:22:18 +04:00
German KhokhlovandClaude Opus 5.5 83e6acc34d fix(street-level): Esc shrinks the iPhone popover photo instead of collapsing the panel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:21:49 +04:00
German KhokhlovandClaude Opus 5.5 4682757e71 fix(street-level): a new sequence load drops the last one's error; framing survives a detached navigation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:21:13 +04:00
German KhokhlovandClaude Opus 5.5 bd57cf5f43 docs(street-level): the proxy adds the token to tile requests and refuses redirects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:20:50 +04:00
German KhokhlovandClaude Opus 5.5 db9b61dc55 fix(street-level): hover skips every drag and re-picks when the camera rests
A release with a modifier held left the LEFT_DOWN/LEFT_UP flag stuck, and
right, middle or modified drags picked every frame. Track pointer buttons
instead, as main did, and pick a still pointer again on moveEnd.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:20:50 +04:00
German KhokhlovandClaude Opus 5.5 c18af6ad1c fix(street-level): run at most six Mapillary tile fetches at once
Tiles a camera move leaves behind keep downloading after the client drops
them; the cap bounds their memory and quota, as main's proxy did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:20:50 +04:00
German KhokhlovandClaude Opus 5.5 35bfe203a6 Merge remote-tracking branch 'origin/main' into german/street-level-slim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:04:02 +04:00
German KhokhlovandClaude Opus 5.5 36b675b928 fix(street-level): reconnect the panel once the data manager attaches, so a switch-on opens it
The panel connected before the shell had its data manager, so it never
heard switch-on requests and a user switch-on left it collapsed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:22:36 +04:00
German KhokhlovandClaude Opus 5.5 09e2fd96a4 refactor(street-level): no sequence image cache; one pose listener
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:16:29 +04:00
German KhokhlovandClaude Opus 5.5 1db3290e0b chore(street-level): format; drop nearest.js from tool configs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:07:32 +04:00
German KhokhlovandClaude Opus 5.5 affa2164e0 docs(street-level): reflow the proxy paragraph
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:06:55 +04:00
German KhokhlovandClaude Opus 5.5 3808f1fa7e refactor(street-level): refuse tile redirects instead of following them by hand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:06:50 +04:00
German KhokhlovandClaude Opus 5.5 200844998b refactor(street-level): draw the selected sequence as its own line; drop old-zoom tiles at once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:06:17 +04:00
German KhokhlovandClaude Opus 5.5 fd2a21c0c6 refactor(street-level): hover picks once a frame instead of a throttled timer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:04:23 +04:00
German KhokhlovandClaude Opus 5.5 c78c872f92 refactor(street-level): drop unused tile and pose fields; keep the sequence error on the sequence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:03:37 +04:00
German KhokhlovandClaude Opus 5.5 7411ebaf1c refactor(street-level): static legend in markup; drop empty panel wrappers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:02:44 +04:00
German KhokhlovandClaude Opus 5.5 209dda5507 refactor(street-level): open the panel from the switch-on request's origin only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:01:42 +04:00
German KhokhlovandClaude Opus 5.5 94049c5667 refactor(street-level): drop openNearest; QA opens a photo by clicking a line then a cone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 15:01:42 +04:00
German KhokhlovandClaude Opus 5.5 07a239fd82 fix(street-level): expanded photo leaves with panel-hiding modes; EXPAND on iPhone; pill when Mapillary is off
Three regressions from the slimming, found in review:

- Clean View, recording and the cockpit hid the old expanded viewer with
  CSS; native fullscreen sits above all of that, so the photo stayed over the
  globe (and in recordings). While expanded, the panel now watches the
  <body> classes of those modes and leaves full screen when one starts.
- iPhone Safari has no element fullscreen, so EXPAND had disappeared there.
  Where requestFullscreen is missing but popovers exist, EXPAND opens the
  viewer as a top-layer popover sized to the viewport (it stays in the
  panel's DOM; the popover attribute is set only while open, since a closed
  popover is hidden). Checked in Chrome: the popover fills the viewport over
  the panel's backdrop filter.
- Without the provider chips, a layer that was on with Mapillary switched off
  by a share link or tool (`0.m.0`) showed ON and drew nothing. The pill now
  reads OFF with a hint, and pressing it brings Mapillary back without
  switching the layer off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:57:06 +04:00
German KhokhlovandClaude Opus 5.5 8706cafdd1 fix(street-level): ground under the camera on Google 3D; tilted views load what they look at
Two regressions from the slimming, found in review:

- Google 3D hides the globe, so groundUnderCamera returned null once the
  ground caster was gone: in high cities (Denver ~1,600 m) the camera read
  kilometres too high, the coverage zoom came out coarse and the visible box
  slid kilometres ahead. With the globe hidden (or not yet loaded) the
  ground now comes from the rendered surface (scene.sampleHeight), checked
  for plausibility and ignored when above the camera. The plausibility check
  is shared with photo framing (plausibleSurfaceHeight, sampledSurfaceHeight).
- Tiles were ranked from the camera's ground point alone, so a tilted view
  from a few kilometres up (z13) could miss the tile at the centre of the
  screen. They are ranked from viewFocus: halfway between that point and the
  ground at the screen centre (or the nadir when the centre ray misses).
  A test pins the 3 km / 70° case: the nadir ranking leaves the centre tile
  out, the focus ranking loads it.

Also ignore .playwright-mcp/: its browser console logs can carry tokens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:50:44 +04:00
Trygve Hemminghytt 939264d30e Merge remote-tracking branch 'origin/main' into feat/cctv-vegvesen-kartverket-heights 2026-10-07 21:37:41 +02:00
German KhokhlovandClaude Opus 5.5 346245e8e7 Merge origin/main into german/street-level-slim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:03:55 +04:00
German KhokhlovandClaude Opus 5.5 ad58a0bdc4 refactor(street-level): trim QA, tests, styles and docs
The browser gate is one pass over the remaining flows: rail strip and
expand, coverage with fixtures, the ON/OFF pill, 360° and SINCE filters,
KEY REJECTED, nearest photo with caption, × close, phone fit, keyless
KEY REQUIRED on a second page and no page errors. Panel controls are
pressed through the DOM; pick-point search, line/cone clicks, the
framing-cancel, reload/share and status-route probes, the second
viewport and pointer-settling retries are gone (unit tests cover them).

Tests that pinned constants, copy, CSS or source text, or repeated
another test, are removed or merged. CSS loses an unused variable and
duplicate rules. Docs describe the slimmed feature; unused exports and
the layer's dead setCoverageFilter/refreshCoverage surface are removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:41:29 +04:00
German KhokhlovandClaude Opus 5.5 b5a78826f8 refactor(street-level): slim Mapillary tile proxy — memory cache only, no server holds
Drop the disk tile cache (.gev-cache/mapillary/tiles, background sweep,
size cap, legacy retrim), the server-side 401/403/429 holds, the upstream
concurrency semaphore and per-waiter abort bookkeeping. The client already
pauses on 429 and stops on a rejected key.

Keep: server-side token, {configured}-only status, strict coverage path
validation (z11-14), image-layer trimming, same-site gate and per-IP limit,
HTTPS redirect pinning to the tile origin, the streamed size cap, the error
mapping the client reads, a 24 h byte-budget LRU memory cache, in-flight
coalescing via the shared coalesceProxyRequest, and browser Cache-Control.

Routes and constants fold into mapillary.js and mapillary/tiles.js.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:27:34 +04:00
German KhokhlovandClaude Opus 5.5 37a96072fa refactor(street-level): one provider, no orbit overview, no prewarm, simpler tile choice
The layer now owns its Mapillary parts directly: the provider registry,
pick router, multi-provider UI summary, params codec helper and provider
context are gone. createStreetLevelLayer({ source }) builds coverage,
sequences, the nearest-image lookup and the MapillaryJS viewer itself; a
single providerOn switch keeps the share link's `mapillary` option (off
draws nothing), and getParams/setParams keep { mapillary, pano, sinceDays }.

No z0-5 overview points: above 60 km nothing is drawn and the panel says to
zoom in; the proxy now serves only coverage z11-14. Tiles come from the
visible ground within range of the camera, ranked from the ground under it
(no line-of-sight ranking or near-range padding). MapillaryJS loads on the
first photo; viewer prewarm is removed. Key status, a rejected key and a
rate limit share one `blocked` state on coverage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:19:24 +04:00
German KhokhlovandClaude Opus 5.5 0f41fb04cd refactor(street-level): drop FOLLOW and Google 3D ground casting
FOLLOW is gone: no setFollow, follow state, map-stack availability or
handoff listener. Opening a photo still frames it with one flight through
the application's deferred navigation (begin/reassert), so a slow photo
never overrides newer navigation and tracking is released as before.

Ground casting is gone: groundCast.js, meshSampler.js, the surface mode,
the draped-to-cast swap and remesh. Coverage lines are always draped
GroundPolylinePrimitives (ClassificationType.BOTH); cones and the marker
clamp to the ground and keep the horizon cull. The terrain/meshFloor/ground
services and meshFloor.visibleTilesetLoaded are no longer needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:59:15 +04:00
German KhokhlovandClaude Opus 5.5 7dec7883aa refactor(street-level): simpler panel — native fullscreen, no FOLLOW button, no provider chips
EXPAND now uses the browser's Fullscreen API on the viewer element, which
stays in the panel; the hand-built modal (re-parenting to <body>, inert
siblings, focus trap, document-level Esc/Tab, Clean View/recording/cockpit
hide rules) is gone. The button is hidden where the API is missing.

The FOLLOW button and the PROVIDERS chip row are removed. The header pill is
the only layer switch and re-enables a provider switched off elsewhere; under
KEY REQUIRED the error line says which key to add. Chip colour support in
chipGroup, dead CSS and tests pinning the removed behaviour go with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:50:11 +04:00
German KhokhlovandClaude Opus 5.5 fbbc4cda5c refactor(ui): remove floating, resizable rail panels
PR #768 bundled a portable-panel windowing system with the Street Level
layer. Remove it so CCTV and Street Level are ordinary docked right-rail
panels again, as on main before the merge.

Removed:
- header drag to float, 8-way edge/corner resize, double-press snap-back,
  dockOnCollapse, command-dock clearance, float hint toast, floating z floor,
  persisted floating geometry and ResizeObserver re-clamping
  (panelPositionControls.js restored to its pre-#768 version)
- src/ui/panelResize.js and its tests, panelDock.test.mjs
- onPanelResized/dockPanel wiring in PanelChrome, the application shell and
  StreetLevelControls (EXPAND/SHRINK no longer docks)
- :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS
- qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the
  CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level
  gate's float/resize/dock steps
- floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES

Kept: the Street Level panel as a docked rail panel (share/cockpit/rail
lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the
independent right-rail alignment fix (settledLeftRailTop, 9dde7b4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:39:33 +04:00
Trygve Hemminghytt 1e1ddfca0f Merge remote-tracking branch 'origin/main' into feat/cctv-vegvesen-kartverket-heights
# Conflicts:
#	CHANGELOG.md
2026-10-07 15:44:26 +02:00
Trygve HemminghyttandClaude Opus 5.5 34d53edc91 fix: address review on Kartverket ground heights
- Send 20 points per request, not 50: above ~20 Kartverket answers the
  extra points from contour lines (hoydekurver) or not at all. At 20,
  nearly every camera resolves from dtm1, so the null-retry pass is gone.
  Eight requests in flight keep a cold lookup ~6 s.
- Cache misses: positions with no height are not asked again for 6 h.
- Transport failures: the first 429, 5xx or network error stops the
  lookup with no retries, and no lookup starts until the backoff ends
  (Retry-After when sent, else 10 min; bounded to 1 min-1 h).
- Budget the 12 s wait from the start of the loader, catalog fetch
  included.
- Record answers only for the points each request asked about.
- Send the CCTV User-Agent; link CC BY 4.0 in the credit.
- Docs: bridge cameras sit at the water surface, below the deck.
- Tests: batching, 8-in-flight cap, request count on failure, the shared
  deadline, Retry-After bounds, null TTL, backoff, the -20 m bound and
  each water marker on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 19:21:27 +02:00
Trygve HemminghyttandClaude Opus 5.5 c43f58dc3f feat: Kartverket ground heights for Statens vegvesen cameras
The Vegvesen camera feed is 2D, so every camera shared one flat 150 m
ground prior and mountain-pass cameras could sit hundreds of metres below
the terrain where neither the point-height prior nor the ground snap
resolves. Each camera now gets its terrain height from Kartverket's
keyless point-height API (CC BY 4.0): ~840 of ~845 cameras.

- 50 points per request, six in flight; points a full batch answers null
  for are retried once in batches of 10 (the API drops points from large
  batches).
- Sea-floor depths (dybdekurver / Havflate) under bridges and quays
  count as the water surface, 0 m.
- Heights are cached by position, so the 15-minute refresh only asks
  about new or moved cameras. A catalog load waits at most 12 s; a slower
  lookup keeps filling the cache for the next refresh.
- CCTV_VEGVESEN_HEIGHTS=0 turns it off; Kartverket is credited beside
  Statens vegvesen in the attribution popover.

Follow-up to #929.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:46:20 +02:00
daikaginza bea76df454 Merge remote-tracking branch 'upstream/main' into q686
# Conflicts:
#	CHANGELOG.md
#	scripts/format-scope.json
2026-10-05 17:32:32 +07:00
Kevin Rajan 2b7f832f45 chore: remove one-shot downstream format fixer 2026-10-01 00:52:50 -05:00
Kevin Rajan a194da21dd chore: add one-shot downstream format fixer 2026-10-01 00:52:08 -05:00
Kevin Rajan abea3f2feb chore: remove one-shot branch formatter 2026-09-30 20:59:48 -05:00
github-actions[bot] 914df05346 style: apply adopted formatting 2026-10-01 01:59:00 +00:00
Kevin Rajan cbd3b13afb chore: add one-shot branch formatter 2026-09-30 20:58:20 -05:00
Kevin Rajan 8acb526d1f fix: include World Packet feed-state dependencies 2026-09-30 20:57:17 -05:00
Kevin Rajan 3c53d63def fix: declare World Packet in application boundary 2026-09-30 20:55:09 -05:00
Kevin Rajan 6a6703e0c6 chore: remove one-shot branch formatter 2026-09-30 20:52:54 -05:00
github-actions[bot] 58b7d2d95e style: apply adopted formatting 2026-10-01 01:52:28 +00:00
Kevin Rajan 05b13e9a78 chore: add one-shot branch formatter 2026-09-30 20:51:48 -05:00
Kevin Rajan c55d4d7c25 build: register World Packet package boundary 2026-09-29 03:02:14 -05:00
Kevin Rajan 6439575d15 feat(agent): export World Packet boundary 2026-09-29 03:02:11 -05:00
Kevin Rajan 3c163d09a1 refactor(voice): delegate world state to World Packet builder 2026-09-29 03:02:08 -05:00
Kevin Rajan 5c044a3d8d test(agent): prove World Packet parity and fingerprint semantics 2026-09-29 03:02:05 -05:00
Kevin Rajan 4fc813e175 feat(agent): extract model-free World Packet boundary 2026-09-29 03:02:02 -05:00
Kevin Rajan b22e6d519a chore(format): adopt panel ownership test 2026-09-28 18:33:11 -05:00
Kevin Rajan b79ab84a1a test(ui): pin Context and compact Radio ownership 2026-09-28 18:33:09 -05:00
Kevin Rajan a4ce7381d8 fix(ui): enforce Context ownership before disclosure no-op 2026-09-28 18:32:50 -05:00
daikaginza f0e28a83cb Merge remote-tracking branch 'upstream/main' into fix/terrain-heights-bounds 2026-09-28 08:48:49 +07:00
daikaginza be7187cea7 fix(server): bound the terrain-heights cache and its request rate
Every distinct 5-decimal point was a permanent in-memory entry, a
permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a
live upstream call. Nothing limited the rate, and coordinates were
checked for being finite rather than for being on Earth, so an
out-of-range pair was still a valid cache key.

Four bounds, each following a pattern this repo already uses:

- WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no
  later stage has to. Poles and the antimeridian still parse.
- The point cache holds at most 50,000 entries and evicts oldest-first,
  the shape `trackBackfillProxies` uses. Sized from measurement: this
  checkout's cache file held 6,189 entries in 1.0 MB of real use, so the
  ceiling is eight times that working set. Terrain does not move, so an
  evicted point costs one refetch and never a wrong answer.
- A cache file above 24 MB is not read back, mirroring
  `rocketLaunchesProxy`. An unmeasurable file is not treated as an
  oversized one: without stat, readFile decides, exactly as before.
- The route carries an always-on limiter of 90/min per client, 270
  global — the `/api/overpass` band, and an order of magnitude above
  measured demand (a full test:track run drove 10 requests, peak 8/min).

The ceilings are constructor options so they can be exercised as
behaviour rather than asserted as constants; production passes none.
They are destructured in the body because `proxyErrorResponses.test.mjs`
slices these proxies to the first column-zero closing brace.
2026-09-27 22:10:43 +07:00
298 changed files with 31404 additions and 18025 deletions

No files matched your search

+18 -2
View File
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
# keep them while debugging; delete the log when you are done.
GEV_VOICE_LOG_CONTENT=
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
GEMINI_API_KEY=
GEMINI_LIVE_MODEL=gemini-3.8-live
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
# GEV_RATELIMIT_GEMINI_PER_MIN=30
# Per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
@@ -118,8 +124,8 @@ OPENSKY_AUTH_MODE=oauth
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# Optional: higher Launch Library 2 request allowance. Public access works
# without a token.
# Launch Library 2: leave blank for standard public access (no key needed).
# Optional paid Patreon tokens raise request limits; there is no free API key.
LL2_API_TOKEN=
# Optional: OpenSky credentials JSON file path (alternative to above)
@@ -201,6 +207,7 @@ AISSTREAM_API_KEY=
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
@@ -221,6 +228,9 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
@@ -228,6 +238,7 @@ AISSTREAM_API_KEY=
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead)
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
@@ -237,6 +248,11 @@ AISSTREAM_API_KEY=
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
-6
View File
@@ -67,8 +67,6 @@ jobs:
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -104,10 +102,6 @@ jobs:
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
- name: Portable panel gate
if: success() || failure()
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Preview server log
if: failure()
run: cat preview.log
+4
View File
@@ -16,6 +16,10 @@ output/
.gev-cache/
# Browser QA gate evidence (screenshots, console and network dumps).
qa-artifacts/
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
.playwright-mcp/
# Local Claude Code settings and preview configs.
.claude/
.DS_Store
.gstack/
3d-models/
+14 -20
View File
@@ -317,9 +317,8 @@
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
(orange; off by default, one provider at a time): below about 60 km the
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
line opens that sequence's picture nearest the click without moving the
camera, flat or 360°, with cones along the sequence around it and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
@@ -377,10 +376,10 @@
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps and FOLLOW. Share links carry it in the provider switches
to Google Maps. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
are all 360°, so the IMAGERY and SINCE filters apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
@@ -419,21 +418,16 @@
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Street Level: street-level imagery from providers, Mapillary first (free
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
360°/flat and captured-since filters and an embedded viewer with EXPAND and
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
Google 3D tiles; opening a photo frames it with one camera flight. Share
links carry the provider switches, the filters and the panel's collapsed
state. A server proxy adds the token to Mapillary coverage tiles, trims and
caches them for 24 h, refuses cross-site requests and redirects, and limits
load per IP and upstream. The panel reads KEY REQUIRED without a token and
KEY REJECTED when Mapillary refuses it.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
+6 -8
View File
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
`qa:transit`.
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -66,11 +65,10 @@ gate for the feature you changed.
> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
> production build, plus a Windows onboarding job, and against the built preview
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
> server and a browser. Include the applicable local run in your PR's
> validation evidence.
> production build, plus a Windows onboarding job. It runs neither
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
> gate (`street-level-browser` job) — they need a live dev server and a
> browser. Include the applicable local run in your PR's validation evidence.
## Share-link layer tokens
+10 -3
View File
@@ -20,10 +20,10 @@ How to read this:
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -48,6 +48,7 @@ How to read this:
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
@@ -62,7 +63,9 @@ How to read this:
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
@@ -110,6 +113,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
@@ -152,7 +156,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
@@ -162,10 +166,13 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
+12 -6
View File
@@ -309,6 +309,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
## 🎙️ Talk to It
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
@@ -377,15 +381,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
@@ -421,9 +425,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
@@ -527,9 +531,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
| | Key | Why | Get it |
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
Add these if you need higher polling allowances.
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
paid Patreon upgrades for higher limits; there is no free API-key signup.
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
where each configured provider was found without printing credential values.
+2 -2
View File
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+12
View File
@@ -0,0 +1,12 @@
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
MCP client can ask which airlines, countries, aircraft types or ship types
dominate an area, or the average altitude or speed, over every match instead
of estimating from the returned rows. Airlines come from the ICAO designator
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
that report no operator; operator and type are counted only where the feed
reports them. Ship types count by family, so AIS codes 70 to 79 are all
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
unknown. Altitude and speed stats cover airborne aircraft only, so parked
aircraft do not pull the means down. Every vessel answer, `find_vessel`
included, now reads AIS ship-type codes as their family and a destination
with no letters as unknown (quanticsoul4772, #1056).
@@ -0,0 +1,5 @@
- Share Launch Library request cache keys across installations refreshing in the
same UTC minute, identify the app to the provider, and explain that standard
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
upgrades; existing local cache and server-only token handling are unchanged
(rudycelekli, #1197).
@@ -0,0 +1,3 @@
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
launch data, and return to NOMINAL after a fresh response without discarding
retained missions (rudycelekli, #1199).
+5
View File
@@ -0,0 +1,5 @@
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
video camera could only be found by opening it. Stills stay cyan and the
active camera stays amber, and focus de-emphasis fades the violet the same
way (Danielslay86, #1200).
+4
View File
@@ -0,0 +1,4 @@
- Draw CCTV stills at their source aspect on the monitor plane instead of
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
every still was widened; they are now centered and pillarboxed on the dark
the placeholder already ends on (Danielslay86, #1201).
+3
View File
@@ -0,0 +1,3 @@
- Report actual outcomes when voice enables or disables CCTV through the shared
layer lifecycle, including failed and cancelled changes (manjunath22466,
#1205).
+3
View File
@@ -0,0 +1,3 @@
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
command, while preserving an earlier accepted deferred input
(Manjunath, #1220).
+4
View File
@@ -0,0 +1,4 @@
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
otherwise skipped with one log line saying where to get a key
(quanticsoul4772, #1228).
@@ -0,0 +1,4 @@
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
cancel both response streams when either response fails or overflows the 4 MB
byte cap, and deduplicate camera IDs before per-pack prioritization
(yaka0007, #1233).
@@ -0,0 +1,5 @@
- Serve the camera catalog while it refreshes. An expired catalog made every
camera request wait for the slowest pack to reload; it is now answered at
once while one background refresh runs, so only the first load waits. Estonia's
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
on about a third of loads (quanticsoul4772, #1234).
+3
View File
@@ -0,0 +1,3 @@
- Require a geocoded containment anchor for ambiguous analyst region names,
preserve boundary provenance and cancellation safety, and retain exact-name
precedence for natural outlines (manjunath22466, #1235).
+4
View File
@@ -0,0 +1,4 @@
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
with on-demand snapshots and attribution. No API key is required; set
`CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237).
@@ -0,0 +1,3 @@
- Gate CCTV HLS media requests before session work, bind leases to the direct
peer, and bound per-peer leases across shared sessions (asturksever, #1267).
- Keep headful CCTV QA rendering active when its window is occluded (asturksever, #1267).
@@ -0,0 +1,3 @@
- Report cached CelesTrak satellite records as stale in the Data Layers row,
preserving separate core and dense catalog provenance through refresh,
removal and lifecycle cleanup (rudycelekli, #1283).
@@ -0,0 +1,3 @@
- Keep aircraft route labels on the scheduled airport leg instead of accepting
its distant great-circle extensions, preserving generous endpoint tolerance
across the signed-angle branch cut (rudycelekli, #1284).
+4
View File
@@ -0,0 +1,4 @@
- The Street Level SINCE note sits on the value's line, in its type and a
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
had lost its size variable in the Street Level slim-down and rendered
larger than the value, on a line of its own (asturksever, #1286).
+3
View File
@@ -0,0 +1,3 @@
- Give ordinary sliders a shared native control and consistent track, thumb,
value, focus and disabled styling while preserving feature behavior and
specialized controls (mazy1998, #1287).
@@ -0,0 +1,5 @@
- Unify segmented selectors for model coverage, detection allocation, drawing
shape, Street View and imagery comparison while preserving their existing
behavior. Keep compact labels such as FILL and FLAT on one line, and let
collapsed Weather release space to the open rail panel. Reserve the full mode
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
+9
View File
@@ -0,0 +1,9 @@
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
the statewide ArcGIS catalog, read a page at a time under a byte cap with
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
FL511's terms limit its content to individual, personal use, and any other
use (a public deployment included) needs FDOT's express written consent, so
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
catalog's direction codes, and camera ids come from the image channel, since
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
sets the cap (Danielslay86, #605).
@@ -0,0 +1,9 @@
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
5-decimal point was a permanent in-memory entry, a permanent line in an
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
nothing limiting the rate. The point cache now holds at most 50,000 entries
and evicts oldest-first, a cache file above
24 MB is not read back, and the route is limited to 90 requests/minute per
client (270 global). Evicting a point costs one refetch, never a wrong
answer, since terrain does not move. Ordinary use is unaffected: a full
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
@@ -0,0 +1,7 @@
- Close the compact Radio disclosure when an adaptive tactical right-rail
restore directly expands Context, before Space Missions can be covered.
A repeated Context-open request also clears stale disclosure state.
Real PanelChrome constructor wiring and the rail reflow path are covered
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
#804 report, asturksever's review, and quanticsoul4772's independent
Windows diagnosis.
+4
View File
@@ -0,0 +1,4 @@
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
server-held API key, microphone streaming, shared map actions and captions.
Switching providers ends the old conversation and preserves the input mode
(Manjunath, #872).
+6
View File
@@ -0,0 +1,6 @@
- Extract a reusable model-free World Packet from the existing voice
`get_current_view_state` owner without restoring disabled layers to the
model context. Keep the enabled-only payload, disabled-layer count, source
provenance, action result and view-state contract; read the layer roster once
per packet; voice retains the speech/card envelope at its own boundary.
Credits: original GEV voice/layer contributors and #880 implementation.
@@ -0,0 +1,17 @@
- Give each Statens vegvesen camera its own ground height from Kartverket's
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
mountain-pass cameras no longer sit hundreds of metres below the terrain
where neither the point-height prior nor the ground snap resolves. About 850
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
points, the most the API answers from its 1 m terrain model, eight at a
time; a cold lookup takes about 6–13 s. Cameras over water use the water
surface rather than the sea-floor depth the API returns there; for bridge
cameras that is still below the deck. Heights are cached for good and misses
for 6 h, so a refresh only asks about new or moved cameras. After a
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
an unreadable body) the lookup stops and backs off (Retry-After, else
30 min, longer than the refresh). The lane waits at most 12 s from its
start, and only on a cold cache; a slower first lookup leaves the cameras it
had not reached on the flat prior until the next 15-minute refresh, and
keeps filling the cache meanwhile.
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
+6
View File
@@ -0,0 +1,6 @@
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
@@ -0,0 +1,8 @@
- Cross-site pages can no longer fill the terrain-height cache or spend the
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
the cost-bearing routes it still answers same-origin requests that arrive
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
#975).
+7
View File
@@ -0,0 +1,7 @@
- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV
source pack: the public DATA.GOV.HK English locations XML, frames pinned to
`tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By
default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads,
prioritized across Hong Kong Island, Kowloon and the New Territories. Headings
use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets
the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990).
+2 -2
View File
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
+218 -80
View File
@@ -1,5 +1,21 @@
# God's Eye View Current State
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
the same name contain that anchor, the lookup stays unresolved rather than
selecting the largest entry. Natural outlines try exact names before aliases and
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery.
CCTV live-media requests are same-site gated before catalog or media work, including
HLS renewal, segment reads and DELETE cleanup. HLS leases belong to their socket
peer (IPv4-mapped IPv6 is normalized), with eight leases per peer across the two
global sessions. Peers behind the same NAT share that budget; this is resource
admission, not authentication. Native same-origin video and range requests remain
supported.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
@@ -112,7 +128,14 @@ perimeters, terrain height, military installations and map features, plus a
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
opens the app over an area. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. Voice offers the same queries next to its app
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
count every match by a field (`group_by`: airline, read from the ICAO
designator in the callsign, or operator, country or type for aircraft; type or
destination for ships; the ten largest groups, then other and unknown counts;
the summary says when a feed reports none of the field) and summarize airborne
altitude and speed (`stats`: count, min, median, mean, max).
Both cover all matches, not just the rows returned; voice leaves these two
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Aircraft altitude-band queries require a reported barometric or geometric altitude
@@ -489,6 +512,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
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
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
The model-free `gods-eye-view/agent/world-packet` export exposes
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
non-voice consumers. It reads the same enabled-only state and feed
provenance before voice adds speech/card fields, with no new network requests
or mutations.
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
steps and the result. `resultDisplay.js` is the one adapter from a result to
the card: builder results show as built, and analyst results show a title
@@ -606,10 +634,20 @@ Reference feed construction is exported through `sources/reference`; the cable s
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
layer visibility lifecycle. Results distinguish successful changes from
manager-reported failures or cancellation and report enabled state and lifecycle
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
preserves an already committed change. Missing-layer errors and other CCTV
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
enable/disable requests and directs callers to `set_layer_visibility`.
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
@@ -637,7 +675,7 @@ The UI facade composes separate owners for navigation authority, share restorati
visual settings and panel chrome. Navigation owns request generations and follow
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
visual settings own style/detection overrides and sensor cleanup; panel chrome
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
owns disclosure and Cockpit panel snapshots. Each takes explicit
operations and reads instead of the complete shell. Existing controls, share
formats and one-application-per-page behavior are preserved.
@@ -803,7 +841,11 @@ Viewport limits, saturation retry, placement, query caps and controls are retain
Satellite and mission layers expose separate factories through `./layers/satellites`
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
and teardown. Source adapters retain the existing CelesTrak and Launch Library
and teardown. Satellite core and dense catalogs retain upstream stale-cache
provenance only for admitted records; duplicate or invalid entries cannot taint
a fresh winning record. Dense removal clears only its own provenance, and a
fresh core rebuild or lifecycle reset replaces the corresponding ownership.
Source adapters retain the existing CelesTrak and Launch Library
endpoints; standalone entries provide scene services and the satellite dependency.
The mission factory separates paths, replay, camera, panel, overlays and placement.
Launch Library pad coordinates retain unknown values: missing fields use the
@@ -812,7 +854,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
stage coordinates, downrange distance and flight numbers remain unknown; the
mission panel omits the flight-number label when no number was reported.
Pending mission requests abort on disable/destruction and malformed launch
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
provenance. Hiding retained missions does not relabel them as fresh, and teardown
clears the instance state. Injected getLaunches-only sources remain supported.
Catalog groups, propagation cadence,
tracking intent, replay timing, controls and source attribution remain unchanged.
The fire layer now exposes `./layers/firms`: an instance factory with separate
@@ -959,10 +1005,9 @@ start with current state, isolate listener failures and stop on disposal.
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, cancels an unfinished drag without saving it, restores the
Context restoration, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
@@ -1075,6 +1120,26 @@ The images are periodically updated road-condition photographs, not continuous
video; camera poses use the existing low-confidence heading prior.
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
## Taiwan CCTV source pack
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
allocation. A failed download serves the last good table (up to a day old),
or an empty pack, and is not retried for an hour: the catalog host does not
answer from some networks outside Taiwan, and without the backoff every refresh
there would wait out the connect timeout. The table
publishes MJPEG stream URLs; each is mapped to the same camera server's
single-JPEG path for the same camera number, and only those exact HTTPS pairs
on the four `freeway.gov.tw` camera servers are registered with the frame
proxy. Camera keys come from the server and camera number, because published
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
`CCTV_TAIWAN_URL` overrides the table URL.
Gate: `src/data/cctvTaiwan.test.mjs`.
## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
@@ -1166,6 +1231,32 @@ The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
Generated controls use `createSlider`; static templates apply the same class.
Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior.
## Segmented selectors
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
their feature-owned values, actions and persistence. Painting a restored value
does not emit a change or move focus. Existing keyboard policies remain explicit:
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
its controlled selection, and Display keeps its existing native button behavior.
Pure value navigation is separate from DOM rendering for reuse by a future view.
Segments keep their word minimum width: compact inline groups such as FIT/FILL
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
the same exclusive-rail hiding rule as the other readouts.
The imagery mode row reserves 26px for the 24px buttons and group borders so
the complete button and inset keyboard-focus outline remain visible.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
## Application shortcuts and shader parameter controls
`ui/input` supplies the bubbling application shortcut listener and generated
@@ -1192,6 +1283,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
@@ -1441,6 +1536,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
## Terrain height cache bound
The `/api/terrain/heights` route refuses cross-site browser requests before
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
for such a request is the visitor's own browser, so without the gate any page
could spend the visitor's 90/min quota and leave the app's own lookups on
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
and a `403` there would send every chunk to the geoid fallback for an instance
served behind the documented reverse proxy. Same-origin app requests,
non-browser local callers and proxied same-origin requests keep working.
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
latitudes outside [-90, 90] for every client before upstream resolution. One
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
@@ -1478,6 +1585,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
`sources/space` supplies fixed upstream URL builders with no I/O or environment
access. Callers retain validation, transport and response policy.
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
window, so installations refreshing in the same minute share an upstream cache
key. The upper bound trails the current time by less than one minute. Requests
identify the app and repository through User-Agent; the existing 15-minute local
cache, stale fallback and optional server-only Authorization remain unchanged.
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
standard public access needs no key, and its field can remain blank.
The shared TLE text parser skips incomplete named sets and resumes at the next
complete set. A missing orbital line does not discard the later satellites in
the catalog. A set whose two lines carry different catalog numbers, or a
@@ -2664,6 +2779,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> tunings. Ambient overlap includes each sprite's own rendered extent. The
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
> settled dim contact always completes its release after tracking ends.
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
> upstream call, four limits bound what a caller can grow. Coordinates outside
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
> — terrain does not move, so an evicted point costs one refetch and never a
> wrong answer; a cache file above 24 MB is not read back into memory (the
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
> order of magnitude above measured demand — a full `npm run test:track` run
> drove 10 requests, peaking at 8/min.
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
> 5-decimal points individually, reconstructs reordered/overlapping batches
> in exact request order, and refreshes only missing or stale points. Upstream
@@ -2933,6 +3059,13 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> position getter reads only `_trackedDisplayCached`, the same authoritative
> Cartesian already consumed by the tracked visual and camera, and never
> advances dead reckoning from the host frame.
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
> context reject the distant backwards, forwards and antipodal extensions of
> an airport pair's great circle. The existing 130 km endpoint admission and
> 200 km cross-track tolerance remain; a projected fix outside the finite
> origin-to-destination minor arc also has a generous 200 km extension at
> either end. Missing coordinates and coincident or antipodal endpoint pairs
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
> and military label surface resolves **callsign → registration → icao24**
> (`_contactLabel()` in `flights.js`; the same chain inline in
@@ -3395,7 +3528,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
@@ -3404,7 +3537,7 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
@@ -3436,8 +3569,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider,
@@ -3449,31 +3582,30 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Mapillary coverage ranks unique tile addresses before applying the 16-tile
overview budget. Overlapping dateline halves in a polar view therefore do not
waste overview slots on the same tile; loading already skips duplicate keys.
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 camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
the mesh hides what is behind buildings and trees. A tile is drawn draped until
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
steep streets and roads under trees. It uses the mesh floor's rules (the
visible tileset has finished streaming, a real bare-earth prior, the shared
mesh window) and probes a cell again once the camera is half as far from it,
down to 40 m, so coarse early samples are replaced as finer tiles load. A
failed probe keeps the last good sample and distance and is retried after the
miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The server proxy (`server/providers/mapillary.js`) adds the token to
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
API), serves only z11–14 coverage, refuses upstream redirects, strips the
unused `image` layer, shares one fetch per tile, runs at most six at once
(cancelling those every client has left) and keeps tiles in memory for 24 h
(a body too big to be a trimmed tile is served but not kept). While Mapillary
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
asking.
The header pill is the layer switch. Lighting a chip swaps the source;
clicking the lit chip turns the layer off. A chip without a usable key
@@ -3484,19 +3616,18 @@ their keys but a switched-off one would draw with a key already set (no
Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's
navigation (`attachNavigation`), which releases aircraft and satellite tracking
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
when it starts opening and frames only if nothing newer took the camera while
it loaded. SINCE is a stepped slider whose readout names the
cut-off date. The panel is portable (`street-level-panel` spec, minimum
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
docks it. At phone width (≤720 px) the viewer height is derived from the rail
band so the whole photo fits.
viewer sits under the header with EXPAND, FIT/FILL and close above the
image. SINCE is a stepped slider whose readout names the cut-off date.
MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` /
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen. At phone width the
viewer height comes from the rail band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
@@ -3505,9 +3636,10 @@ add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js` and add its modules to
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
XYZ tiles near the ground, draped, the selected sequence drawn again over
them) and its cones
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
layer's `requiresKeyId` is null and each chip reports its own key.
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
@@ -3516,7 +3648,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D it does not appear at all (no chip, legend swatch, globe
off Google 3D it does not appear at all (no chip, globe
credit, layer source or button, and Google's script is not loaded), lookups
and opens skip it, and leaving Google 3D closes its panorama; the share
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
@@ -3538,9 +3670,8 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
of GEV's amber pin. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
@@ -3567,8 +3698,7 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
photos and carry no sequence, date or projection, so `decode.js` chains
segments that meet end to start (about four times fewer lines; linear time,
at most 20,000 segments a tile and 5,000 points a line), they are
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
(`highlightsSequence: false`, so the legend has no "Selected"), and the
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
streets in view · click a line for its nearest photo"). KartaView filters
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
@@ -3577,8 +3707,7 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
until the new ones are in), keeps the JSON segments, and caches masks per
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
filter and tile. A dense filtered PNG can take KartaView over a minute the
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
asked again 4 s later, four tries in all; then the panel says it did not
@@ -3589,8 +3718,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
`fieldOfView`) and upload date and checks each answer the same way (when
photos are near but none passes, the panel says so), and the cones pass on
each photo's projection and upload date. The provider declares
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
@@ -3605,7 +3734,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
sequence draws as cones from the 250-photo page holding it
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
(`countActivePhotos`, asked once per sequence). The camera direction (the
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
marker, cones and a panorama's centre) is the direction of travel
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
imagery, while its `heading` is the device compass, which reads 0 for whole
drives without one and can sit a steady 110° off inside a car. Until the
@@ -3662,23 +3791,18 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
view draw their longest lines within 90,000 points (`maxPoints`,
`keepOrder`), shared out once loading settles so a sparse tile keeps all
it has and a dense one (a city centre) takes the rest; the selected
sequence is always drawn. Every point becomes ~8 ground-line vertices:
`keepOrder`), split evenly between the tiles in view (10,000 points
each). Every point becomes ~8 ground-line vertices:
unsimplified, the lines hung the tab. Decoded tiles are kept while the
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
and above
60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds
~70,000 cells) and not counted as sequences: the panel says to zoom in.
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
Above 60 km nothing is drawn and the panel says to zoom in.
A tile decodes in slices of about 8 ms, the page getting a turn between
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
tiles back for its Retry-After, which the browser cannot read here (the API
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
answers; an answer may take as long as it keeps arriving (silent for 15 s
is given up), so a slow link still gets its megabyte tiles. Both
filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a
filters apply: lines carry their day and camera type. A click on a
line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
@@ -3727,8 +3851,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
needs no network. It opens and closes a photo from Mapillary, KartaView and
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
@@ -4366,7 +4491,7 @@ silently demoting every later lookup for the session.
- **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.
- **CCTV v2 foundation:** a pitched
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. 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 —
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
@@ -4384,12 +4509,14 @@ silently demoting every later lookup for the session.
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes
(cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
(#1228). All sources are RAW PRIOR
poses, and the layer is stills-first.
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
@@ -4397,7 +4524,18 @@ silently demoting every later lookup for the session.
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
across packs, so a lower global cap thins every region instead of starving the last pack.
~1,100 cameras total, all RAW PRIOR poses, stills-first.
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
pack, so only the very first load waits for the slowest pack (a live first load took about
22 s, and is bounded by Tarktee's 30 s). A background refresh that fails or comes back
empty keeps the old catalog and is retried after another 15 minutes, not on the next
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
limit, enforced against declared length and streamed bytes. A rejected fetch,
unsuccessful response, or body-read failure releases both available response
bodies; read failures also cancel an active sibling reader. Cameras sharing
an image number are deduplicated before the Tarktee pack cap is applied.
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
validated 2026-07-21): the COVERAGE toggle is a
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
@@ -4714,7 +4852,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
@@ -5021,7 +5159,7 @@ pins and share links already supply complete boxes.
Pixel-mode pinch deltas receive bounded amplification before reaching the
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
unchanged. The application scene owns and removes the relay listener.
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
@@ -5232,8 +5370,8 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
+2 -3
View File
@@ -57,9 +57,8 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
writes none.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
+2 -2
View File
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
+2 -2
View File
@@ -49,7 +49,6 @@
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs",
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
@@ -277,6 +276,7 @@
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js"
"./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js"
}
}
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
* request the provider and MapillaryJS make while opening and closing a
* photo, so the gate needs no network and no real token.
*
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30;
const PHOTO_SPACING_M = 30;
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
+1 -1
View File
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
+39 -33
View File
@@ -46,9 +46,6 @@
"scripts/qa-voice-auth-focus.mjs",
"src/codexOauthRealtime.test.mjs",
"src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-panel-resize.mjs",
"scripts/qa-panelDrag.mjs",
"src/tooling/panelResizeQa.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
@@ -96,6 +93,7 @@
"server/providers/space/launch-library.js",
"src/data/spaceProviderRequests.js",
"src/tooling/spaceProviders.test.mjs",
"src/tooling/ll2Integration.test.mjs",
"server/providers/terrain.js",
"server/providers/traffic.js",
"server/providers/firms.js",
@@ -114,6 +112,8 @@
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.test.mjs",
"server/providers/cctv/taiwan.js",
"src/data/cctvTaiwan.test.mjs",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -181,7 +181,6 @@
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
@@ -191,7 +190,10 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
@@ -284,6 +286,7 @@
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/panelPositionControls.test.mjs",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -301,6 +304,7 @@
"src/ui/styles/status.css",
"src/ui/styles/layers.css",
"src/ui/styles/controls.css",
"src/ui/styles/slider.css",
"src/ui/styles/cctv.css",
"src/ui/styles/foundation.css",
"src/ui/styles/first-run.css",
@@ -445,7 +449,9 @@
"src/data/satellites.js",
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
@@ -550,11 +556,14 @@
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
"src/layers/cctv/frames.test.mjs",
"src/layers/cctv/rendering.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/data/dataCredits.test.mjs",
@@ -635,6 +644,7 @@
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
@@ -690,6 +700,7 @@
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
@@ -906,27 +917,32 @@
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/data/idSwitches.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/providerSwitches.test.mjs",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/tileMath.js",
@@ -939,7 +955,6 @@
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
@@ -949,8 +964,6 @@
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
@@ -979,8 +992,6 @@
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"src/layers/streetLevel/tileMath.test.mjs",
@@ -993,23 +1004,15 @@
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/groundCast.test.mjs",
"src/layers/streetLevel/meshSampler.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/cameraFollow.test.mjs",
"src/layers/streetLevel/cameraFraming.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/ui/panelDock.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/sequenceCones.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
@@ -1026,6 +1029,7 @@
"scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/results.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
@@ -1062,5 +1066,7 @@
"src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs"
"src/data/gbfsProxyRateLimit.test.mjs",
"src/data/routeSegment.test.mjs",
"src/terrainHeightsBounds.test.mjs"
]
+80 -49
View File
@@ -114,7 +114,11 @@
"exports": ["./server/providers/terrain"],
"modules": [
"server/providers/terrain.js",
"src/data/terrainHeightsProxy.js"
"src/data/terrainHeightsProxy.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
],
"external": []
},
@@ -183,10 +187,13 @@
"exports": ["./server/providers/cctv"],
"modules": [
"server/providers/cctv.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/taiwan.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -210,8 +217,6 @@
"exports": ["./server/providers/mapillary"],
"modules": [
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"server/providers/common/same-site.js",
@@ -419,6 +424,7 @@
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
@@ -428,7 +434,7 @@
},
"display-controls": {
"exports": ["./ui/display"],
"modules": ["src/ui/displayControls.js"],
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
"external": []
},
"visual-effects": {
@@ -477,6 +483,7 @@
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
"src/ui/railCardBlocks.js",
"src/ui/slider.js",
"src/ui/chipGroup.js",
"src/ui/rowList.js",
"src/ui/railTimeline.js",
@@ -698,6 +705,7 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -720,7 +728,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -737,6 +744,7 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -753,6 +761,7 @@
"src/ui/shellFacade.js",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js",
@@ -1040,6 +1049,7 @@
"src/layers/vessels/tracking.js",
"src/data/iconOrientation.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
@@ -1378,15 +1388,13 @@
],
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
@@ -1394,9 +1402,8 @@
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -1423,14 +1430,13 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -1628,7 +1634,15 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/sources/httpBody.js",
"src/sources/requestDeadline.js",
"src/i18n/marker.js"
],
"external": []
},
@@ -1841,6 +1855,7 @@
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
"src/hud.js",
"src/hudLocality.js",
@@ -2167,6 +2182,7 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -2189,7 +2205,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -2206,6 +2221,7 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -2215,6 +2231,7 @@
"src/voice/commands.js",
"src/voice/control.js",
"src/voice/gevActions.js",
"src/agent/worldPacket.js",
"src/voice/speech.js",
"src/ui/layerNames.js",
"src/voice/earcon.js",
@@ -2408,24 +2425,22 @@
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -2452,8 +2467,6 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2466,7 +2479,6 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -2490,7 +2502,13 @@
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js"
],
"external": [
"hls.js",
@@ -2629,6 +2647,7 @@
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/keylessGeocoder.js",
"src/layers/aircraft/classification.js",
"src/layers/alpr/index.js",
@@ -2984,24 +3003,22 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -3028,8 +3045,6 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -3042,7 +3057,6 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -3060,7 +3074,8 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/ui/layerNames.js"
"src/ui/layerNames.js",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": [
"hls.js",
@@ -3200,11 +3215,13 @@
"src/ui/applicationShortcuts.js",
"src/ui/effects.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/ui/segmentedControl.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
@@ -3224,7 +3241,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/rightPanelRail.js",
@@ -3377,7 +3393,8 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/voice/providerCapabilities.js"
],
"external": []
},
@@ -3782,6 +3799,7 @@
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3943,5 +3961,18 @@
"src/i18n/marker.js"
],
"external": []
},
"agent-world-packet": {
"exports": ["./agent/world-packet"],
"modules": [
"src/agent/worldPacket.js",
"src/data/layerSnapshot.js",
"src/data/feedState.js",
"src/contextModePolicy.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/i18n/marker.js"
],
"external": ["cesium"]
}
}
+3 -1
View File
@@ -326,8 +326,10 @@ async function main() {
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
);
let sidecar = await loadSidecar(output);
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
// more than that.
const response = await fetch(`${base}/api/cctv/sources`, {
signal: AbortSignal.timeout(30_000),
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
const catalog = await response.json();
+2 -2
View File
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
import path from 'node:path';
/** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200;
const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
/** Mask tokens in a URL or message before it is printed or saved. */
export function redact(text) {
+242 -107
View File
@@ -11,8 +11,10 @@
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
* never again in steady state; shared mesh-floor sampling fires at most
* once per coarse cell during the staggered geometry-load drain, and
* actually COMPLETES once tiles load (>=1 real sample — the
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
* — the ceiling alone would pass trivially at Δ=0 if the snap never
* finished). Globe mode instead proves the rendered mount uses a real
* terrain height with zero mesh samples. Sampling
* stays flat while idle, and stays flat for non-positional pose edits.
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
* exist for the active camera, the plane's corners coincide with the
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
// 180s). Waits never come close: they poll from Node (below). The headroom is
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
// on a real GPU, and matches the other qa-*.mjs harnesses.
const PROTOCOL_TIMEOUT_MS = 300_000;
/**
* Polls an in-page predicate from Node, one short page.evaluate per tick,
* until it holds or the budget runs out. Used instead of page.waitForFunction
* for two reasons: its default rAF polling stops in a minimised or hidden
* headful window (timers keep running, rAF does not), and its result is one
* CDP call that protocolTimeout cuts off, while the headful drain budget
* reaches ~50 minutes. A predicate that throws fails the run instead of
* silently spending the budget.
* @param {import('puppeteer').Page} page
* @param {Function} predicate - In-page predicate.
* @param {object} opts
* @param {number} opts.timeoutMs - Overall budget.
* @param {number} [opts.intervalMs=250] - Delay between checks.
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
* The predicate must return a boolean: its result crosses CDP by value.
* @returns {Promise<boolean>} True if the predicate held within the budget.
*/
async function waitForPredicate(
page,
predicate,
{ timeoutMs, intervalMs = 250, args = [] },
) {
// performance.now() is monotonic and doesn't advance while the machine
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
const deadline = performance.now() + timeoutMs;
for (;;) {
try {
if (await page.evaluate(predicate, ...args)) return true;
} catch (err) {
// A navigation or Vite reload replaces the execution context: poll again.
if (
!/Execution context was destroyed|Cannot find context/.test(
err?.message ?? '',
)
) {
throw err;
}
}
const remaining = deadline - performance.now();
if (remaining <= 0) return false;
await sleep(Math.min(intervalMs, remaining));
}
}
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
gev.dataManager.layers
.get('cctv')
.module.setParams({ calibrationMode: true });
const scene = gev.viewer.scene;
let rendered = 0;
return new Promise((resolve) => {
let settled = false;
const finish = (complete) => {
if (settled) return;
settled = true;
clearTimeout(timer);
remove();
resolve(complete);
};
const remove = scene.postRender.addEventListener(() => {
rendered += 1;
if (rendered >= count) {
finish(true);
return;
}
return (await window.__qaRenderFrames(count, 5000)) >= count;
}, frameCount);
}
/**
* Saves a screenshot for human review. These are never a gate, so a capture
* that fails (for example when the renderer stalls in a hidden headful window)
* is logged instead of ending the run.
* @param {import('puppeteer').Page} page
* @param {string} file
* @param {string} label
*/
async function saveReviewShot(page, file, label) {
try {
await page.screenshot({ path: file });
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
} catch (err) {
console.warn(
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
);
}
}
/**
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
* until `count` frames have passed scene.postRender or capMs runs out, and
* resolves with the number of frames rendered. The cap keeps a stalled
* renderer (headful window asleep or hidden) from hanging the CDP call.
* @param {import('puppeteer').Page} page
*/
function installRenderFramesHelper(page) {
return page.evaluate(() => {
window.__qaRenderFrames = (count, capMs) =>
new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let frames = 0;
const finish = () => {
clearTimeout(timer);
remove();
resolve(frames);
};
const remove = scene.postRender.addEventListener(() => {
if (++frames >= count) {
finish();
return;
}
scene.requestRender();
});
const timer = setTimeout(finish, capMs);
scene.requestRender();
});
const timer = setTimeout(() => finish(false), 5000);
scene.requestRender();
});
}, frameCount);
});
}
// In-page helper (stringified and re-defined inside each evaluate that needs
@@ -280,23 +361,21 @@ const BEARING_SHIFT_SRC = `
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
*/
function waitForTilesLoaded(page, timeoutMs = 15000) {
return page
.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
return waitForPredicate(
page,
() => {
const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
return true; // no tileset in the scene — nothing to wait for
},
{ timeout: timeoutMs },
)
.then(() => true)
.catch(() => false);
}
return true; // no tileset in the scene — nothing to wait for
},
{ timeoutMs },
);
}
/**
@@ -364,6 +443,10 @@ async function main() {
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: PROTOCOL_TIMEOUT_MS,
// Talk CDP over a pipe, so no debugging port stays open on localhost for
// the length of a headful run.
pipe: true,
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
@@ -371,6 +454,7 @@ async function main() {
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
@@ -397,13 +481,18 @@ async function main() {
});
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
const appReady = await waitForPredicate(
page,
() =>
window.__godsEyeView &&
window.__godsEyeView.viewer &&
window.__godsEyeView.dataManager,
{ timeout: 60000 },
Boolean(
window.__godsEyeView &&
window.__godsEyeView.viewer &&
window.__godsEyeView.dataManager,
),
{ timeoutMs: 60000 },
);
if (!appReady) throw new Error('app did not initialise within 60s');
await installRenderFramesHelper(page);
// Let the initial fly-to Austin and first tiles settle.
await sleep(4000);
@@ -475,18 +564,15 @@ async function main() {
console.log(
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
);
const drained = await page
.waitForFunction(
() => {
const mod =
window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState();
return ui.loading && ui.loading.active === false;
},
{ timeout: drainBudgetMs },
)
.then(() => true)
.catch(() => false);
const drained = await waitForPredicate(
page,
() => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState();
return Boolean(ui.loading) && ui.loading.active === false;
},
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
);
record(
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
drained ? true : HEADFUL ? false : null,
@@ -575,27 +661,87 @@ async function main() {
// (existence, not just ceiling). The wait below tolerates the update-tick
// latency of the completion pass (update interval is 10s).
// -----------------------------------------------------------------------
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded) {
sampleFloorOk = await page
.waitForFunction(
(base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeout: 30000 },
c0.sampleHeight,
)
.then(() => true)
.catch(() => false);
}
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded ? sampleFloorOk : null,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
const globeMode = await page.evaluate(
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
);
if (globeMode) {
// Globe stacks intentionally perform no scene.sampleHeight query.
// Require independently fetched DEM heights and the actual rendered
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
console.log(
'Checking terrain-globe completion against live DEM heights...',
);
const terrain = await page.evaluate(async () => {
const camera = window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
const response = await fetch(
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
{ signal: AbortSignal.timeout(30000) },
);
const body = await response.json();
return { ok: response.ok, results: body.results };
});
const heights = terrain.results?.map((result) => result?.ellipsoid);
const terrainReady =
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
const mountResolved =
terrainReady &&
(await waitForPredicate(
page,
(expected, base) => {
const { viewer, dataManager } = window.__godsEyeView;
const camera = dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
const mount = ray?.polyline?.positions.getValue(
viewer.clock.currentTime,
)?.[0];
const height =
mount &&
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
.height;
return (
Number.isFinite(camera.groundPriorM) &&
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
Number.isFinite(height) &&
expected.some(
(ground) =>
Math.abs(height - ground - camera.mountHeightM) < 0.05,
) &&
window.__qaCounters.sampleHeight === base
);
},
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
));
record(
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
Boolean(mountResolved),
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
);
} else {
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded) {
sampleFloorOk = await waitForPredicate(
page,
(base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeoutMs: 30000, args: [c0.sampleHeight] },
);
}
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded ? sampleFloorOk : null,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
);
}
// Let the completion pass finish before the steady-state window: require
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
@@ -1358,18 +1504,7 @@ async function main() {
},
});
window.__qaCctvSiblingOwner = owner;
await new Promise((resolve) => {
let frames = 0;
const remove = scene.postRender.addEventListener(() => {
if (++frames < 3) {
scene.requestRender();
return;
}
remove();
resolve();
});
scene.requestRender();
});
const framesRendered = await window.__qaRenderFrames(3, 10000);
const rect = scene.canvas.getBoundingClientRect();
const picked = scene.pick(canvasPoint);
const pickedId = picked?.id?.id ?? picked?.id ?? null;
@@ -1382,26 +1517,33 @@ async function main() {
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
ownerId: owner.id,
ownsExactObject: picked?.id === owner,
framesRendered,
projectionDelta: Math.hypot(
projected.x - canvasPoint.x,
projected.y - canvasPoint.y,
),
};
}, emptyClickPoint);
const siblingOwnsPick =
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject;
// No frame at all within the 10s cap points at a stalled renderer rather
// than at picking, though the harness can't rule out both. Fail it on the
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
// SwiftShader frame (1-2 of 3) still counts as a real miss.
const siblingRendererStalled =
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
record(
'sibling test object owns its canvas pick',
!siblingTarget
? null
: siblingOwnsPick ||
(siblingRendererStalled && !HEADFUL ? null : false),
siblingTarget
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
: null,
siblingTarget
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
: 'no previously verified empty canvas ray was available',
);
if (
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
) {
if (siblingOwnsPick) {
await page.mouse.click(siblingTarget.x, siblingTarget.y);
await sleep(250);
}
@@ -1426,8 +1568,7 @@ async function main() {
});
record(
'sibling canvas click passes through without CCTV selection or deselection',
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject
siblingOwnsPick
? siblingClick.activeId === clickEvidenceSetup.activeId &&
siblingClick.transitions === siblingClick.baselineTransitions &&
siblingClick.focusEvents === 0 &&
@@ -1626,10 +1767,7 @@ async function main() {
);
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
await page.screenshot({ path: viewshedShot });
console.log(
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
);
await saveReviewShot(page, viewshedShot, 'visual review only');
// =========================================================================
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
@@ -1963,10 +2101,7 @@ async function main() {
// -----------------------------------------------------------------------
await sleep(1000);
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
await page.screenshot({ path: shot });
console.log(
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
);
await saveReviewShot(page, shot, 'visual review only, not a gate');
// No console errors during the whole exercise.
record(
-481
View File
@@ -1,481 +0,0 @@
#!/usr/bin/env node
/**
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
nextFrames,
} from './qa-panelDrag.mjs';
import {
hookRenderErrors,
readRenderErrors,
saveFailureArtifacts,
watchPage,
} from './qa-browserEvidence.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const monitors = [];
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
monitors.push(watchPage(page, { name: 'panel', errors }));
await hookRenderErrors(page);
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await nextFrames(page);
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/**
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height]
.map(Math.round)
.join(',');
const now = performance.now();
if (window.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
// The rail lets go of the lifted panel on its next layout pass.
await page
.waitForFunction(
(id) =>
document
.getElementById(id)
.style.getPropertyValue('--right-panel-allocated-height') === '',
{ timeout: 5_000 },
PANEL_ID,
)
.catch(() => {});
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
}
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// A collapsed window keeps the height chosen for it, through a drag of
// its header strip and a reload.
const setCollapsed = async (collapsed) => {
await page.evaluate(
(id, want) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed') !== want)
document
.querySelector(
`.panel-collapse-btn[data-collapse-target="${id}"]`,
)
.click();
},
PANEL_ID,
collapsed,
);
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('collapsed') === want,
{},
PANEL_ID,
collapsed,
);
await nextFrames(page);
};
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
await drag(await headerPoint(), 40, 30);
const movedStrip = await readPanel();
assert.equal(movedStrip.floating, true, 'the strip stays a window');
near(
JSON.parse(movedStrip.stored).height,
chosen.height,
2,
'stored height while collapsed',
);
await boot();
await setCollapsed(false);
near(
(await readPanel()).height,
chosen.height,
2,
'height after expanding',
);
console.log(
'PASS: a collapsed window keeps its height through a drag and a reload',
);
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
const renderErrors = await readRenderErrors(page);
assert.deepEqual(
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
[],
'no page errors and no Cesium render-loop errors',
);
console.log('PASS: no page errors and no render-loop errors');
} catch (error) {
const dir = await saveFailureArtifacts({
dir: ARTIFACT_DIR,
browser,
monitors,
error,
}).catch(() => null);
if (dir) console.error(`failure evidence saved in ${dir}/`);
throw error;
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
-319
View File
@@ -1,319 +0,0 @@
/**
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Retries after the first attempt, and the pause before each. */
export const PRESS_RETRIES = 2;
const RETRY_PAUSE_MS = 1000;
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
export function failOnRetryDefault(
env = process.env,
argv = process.argv.slice(2),
) {
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
}
/**
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
}
function panelBox(page, panelId) {
return page.evaluate((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, panelId);
}
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, panelId);
}
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
const node = document.elementFromPoint(x, y);
if (!node) return { target: 'nothing', inHeader: false };
return {
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
};
},
{ ...point, id: panelId },
);
}
/**
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
panelId,
{ tries = 20, intervalMs = 500 } = {},
) {
let point = await headerPoint(page, panelId);
let hit = await hitAt(page, point, panelId);
for (let i = 0; i < tries && !hit.inHeader; i++) {
await delay(intervalMs);
point = await headerPoint(page, panelId);
hit = await hitAt(page, point, panelId);
}
const box = await panelBox(page, panelId);
return { point, hit, timedOut: !hit.inHeader, box };
}
/**
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
panelId,
{ stillMs = 300, timeout = 5000 } = {},
) {
try {
await page.waitForFunction(
(id, ms) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
const now = performance.now();
if (window.__qaStillBox !== box) {
window.__qaStillBox = box;
window.__qaStillSince = now;
}
return now - window.__qaStillSince >= ms;
},
{ polling: 100, timeout },
panelId,
stillMs,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
window.addEventListener(
'pointerdown',
(event) => {
const r = document.getElementById(id).getBoundingClientRect();
const target = event.target;
window.__qaPressBox = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
},
{ capture: true, once: true },
);
}, panelId);
}
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
export async function waitForFloating(
page,
panelId,
floating,
{ timeout = 3000 } = {},
) {
try {
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('panel-floating') ===
want,
{ polling: 50, timeout },
panelId,
floating,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
await page.mouse.move(from.x + dx, from.y + dy, { steps });
await page.mouse.up();
await nextFrames(page);
}
export function describeRetry(
panelId,
attempt,
{ round, retries, extra = '' },
) {
const { hit, pressed, timedOut, still, box } = attempt;
return [
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
`stillness ${still === false ? 'timed out' : 'held'}`,
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
]
.concat(extra ? [extra] : [])
.join('; ');
}
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
press,
{
retries,
log,
note,
pauseMs = RETRY_PAUSE_MS,
failOnRetry = failOnRetryDefault(),
},
) {
for (let round = 0; ; round++) {
const still = await waitForStill(page, panelId);
await recordNextPress(page, panelId);
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
const done = await press(point);
const attempt = {
point,
hit,
timedOut,
still,
box,
done,
pressed: await readPress(page),
retries: round,
};
if (round >= retries || !pressMissed(attempt)) return attempt;
const why = describeRetry(panelId, attempt, {
round,
retries,
extra: note ? await note() : '',
});
if (failOnRetry && !panelMoved(box, attempt.pressed))
throw new Error(
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
);
log(`note: ${why}; retrying`);
log(`::warning title=Panel press retried::${why}`);
await delay(pauseMs);
}
}
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
*/
export function liftPanelByHeader(
page,
panelId,
{
dx,
dy,
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await dragPointer(page, point, dx, dy);
return waitForFloating(page, panelId, true);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
{
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
return waitForFloating(page, panelId, false);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
if (!pressed)
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
const n = attempt.retries || 0;
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
}
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env node
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
const errors = [];
const failures = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed, detail = '') => {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures.push(name);
};
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
fs.mkdirSync(shots, { recursive: true });
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
return {
groups: groups.length,
buttons: buttons.length,
shared:
groups.every((g) => g.classList.contains('gev-segmented')) &&
buttons.every((b) => {
const style = getComputedStyle(b);
return (
b.classList.contains('gev-segment') &&
style.fontSize === '9px' &&
style.minHeight === '24px' &&
style.borderRadius === '0px'
);
}),
};
});
check(
'all six one-of-many groups use the shared surface',
surface.groups === 6 && surface.buttons === 15 && surface.shared,
JSON.stringify(surface),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
check(
'model selection reaches both aircraft layers',
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
return (
['flights', 'military'].every(
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
) &&
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true'
);
}),
);
await page.focus('#models3d-mode-all');
await page.keyboard.press('ArrowLeft');
check(
'Display retains its click-only arrow policy',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true',
),
);
await page.focus('#models3d-mode-proximity');
await page.keyboard.press('Space');
check(
'native Space selects model coverage',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-proximity')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.getElementById('detection-allocation-weighted').click(),
);
check(
'allocation selection still updates application tuning',
await page.evaluate(
() =>
window.__godsEyeView.styleManager._detectionAllocationPreference ===
'WEIGHTED' &&
document
.getElementById('detection-allocation-weighted')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('[data-shape="pin"]').click(),
);
check(
'drawing shape still uses its existing feature action',
await page.evaluate(
() =>
window.__gevDrawTool?.shape === 'pin' &&
document
.querySelector('[data-shape="pin"]')
.getAttribute('aria-checked') === 'true',
),
);
// Off-layer controls still own params; no catalog/provider request is needed.
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').click(),
);
check(
'panorama choice reaches the Street Level layer',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'flat',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home',
bubbles: true,
cancelable: true,
}),
),
);
check(
'Street Level Home retains selection and roving tab state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'all' &&
document
.querySelector('[data-sl-pano="all"]')
.getAttribute('tabindex') === '0',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-render="fill"]').click(),
);
check(
'fit/fill keeps its stored render preference',
await page.evaluate(
() =>
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
document
.querySelector('[data-sl-render="fill"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
);
check(
'imagery mode reaches its controlled layer state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'ab',
),
);
await page.evaluate(() =>
document.getElementById('ri-mode').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
}),
),
);
check(
'imagery arrows wrap from the current selected value',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'image' &&
document
.querySelector('.ri-mode-btn[data-mode="image"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._setDetectionAllocation('ELASTIC', {
syncShare: false,
persist: false,
});
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' });
});
await page.screenshot({ path: `${shots}/desktop.png` });
await page.setViewport({ width: 620, height: 900 });
await page.evaluate(() =>
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' }),
);
await page.screenshot({ path: `${shots}/narrow.png` });
check(
'narrow selectors stay within their panel',
await page.evaluate(() =>
[...document.querySelectorAll('.pp-mode-seg')]
.filter((g) => g.getClientRects().length)
.every((group) => group.scrollWidth <= group.clientWidth + 1),
),
);
const cyber = await page.evaluate(() => {
const root = document.documentElement;
const previous = root.getAttribute('data-ui-theme');
root.setAttribute('data-ui-theme', 'cyber');
const selected = [
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
];
// Check the final theme colors, independent of software-GL frame cadence.
for (const button of selected)
for (const animation of button.getAnimations()) animation.finish();
const backgrounds = selected.map(
(button) => getComputedStyle(button).backgroundColor,
);
const radii = [...document.querySelectorAll('.gev-segmented')].map(
(group) => getComputedStyle(group).borderRadius,
);
const consistent =
selected.length === 6 &&
backgrounds.every((color) => color === backgrounds[0]) &&
radii.every((radius) => radius === '1px');
if (previous === null) root.removeAttribute('data-ui-theme');
else root.setAttribute('data-ui-theme', previous);
return { consistent, count: selected.length, backgrounds, radii };
});
check(
'Cyber theme styles every selected segment consistently',
cyber.consistent,
JSON.stringify(cyber),
);
await page.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
await dataManager.setEnabled('recent-imagery', true);
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
persist: false,
syncShare: false,
});
});
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
await page.focus('.ri-mode-btn[aria-checked="true"]');
const modeLayout = await page.evaluate(() => {
const group = document.getElementById('ri-mode');
const bounds = group.getBoundingClientRect();
const style = getComputedStyle(group);
const top = bounds.top + parseFloat(style.borderTopWidth);
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
const buttons = [...group.querySelectorAll('[role="radio"]')];
return {
visible: bounds.width > 0 && bounds.height > 0,
contained: buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
}),
groupHeight: bounds.height,
buttonHeights: buttons.map(
(button) => button.getBoundingClientRect().height,
),
};
});
check(
`imagery mode buttons and focus outline fit their row at ${width}px`,
modeLayout.visible && modeLayout.contained,
JSON.stringify(modeLayout),
);
}
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
} catch (error) {
console.error('Browser errors:', errors);
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
throw error;
} finally {
await browser.close();
}
console.log(`RESULT: ${failures.length} failures`);
process.exitCode = failures.length ? 1 : 0;
+543 -2770
View File
File diff suppressed because it is too large. Load diff
+2 -12
View File
@@ -43,11 +43,7 @@ try {
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
const observer = ui._panelLayout[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -59,7 +55,6 @@ try {
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
@@ -139,7 +134,6 @@ try {
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
ui._panelLayout.destroyed &&
ui._panelPosition.destroyed &&
ui._feedback.destroyed &&
ui._recording.destroyed &&
cockpit.destroyed &&
@@ -194,11 +188,7 @@ try {
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
+32
View File
@@ -183,6 +183,38 @@ try {
return manager.bloomEnabled !== before;
}),
);
const sliders = await page.evaluate(() => {
const ordinary = [
...document.querySelectorAll('input[type="range"]'),
].filter(
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
);
return {
count: ordinary.length,
shared: ordinary.every((input) =>
input.classList.contains('gev-quantitative-slider'),
),
surface: ordinary.every((input) => {
const style = getComputedStyle(input);
return (
style.height === '18px' &&
style.appearance === 'none' &&
style.padding === '0px' &&
style.borderWidth === '0px' &&
(!input.disabled ||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
);
}),
tunerSeparate: !document
.getElementById('radio-tuner-slider')
.classList.contains('gev-quantitative-slider'),
};
});
check(
'ordinary sliders share one native surface',
sliders.count > 10 && sliders.shared && sliders.surface,
);
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
+5 -3
View File
@@ -1745,8 +1745,9 @@ async function runBehaviorLayer() {
'behavior: analyst follow-up re-filters the remembered set',
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
);
// A marine region resolves from the bundled Natural Earth pack in the page,
// without the slower geocode fallback.
// Require the bundled boundary source and the existing whole-query limit.
// This elapsed time includes record acquisition and filtering; the region
// resolver has its own separate fallback budget.
const gulfStarted = Date.now();
r = await run('analyst_query', {
layers: ['flights'],
@@ -1757,9 +1758,10 @@ async function runBehaviorLayer() {
report(
r?.ok === true &&
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
r?.coverage?.scopeSource === 'natural-earth' &&
gulfMs < 3000,
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
);
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
+20 -5
View File
@@ -17,6 +17,7 @@ import {
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { admitSameSite } from './common/same-site.js';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
@@ -135,15 +136,22 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
});
server.middlewares.use('/api/cctv', async (req, res) => {
try {
const url = new URL(req.url || '/', 'http://localhost');
// Refuse before catalog I/O, session creation, renewal or release.
if (url.pathname.startsWith('/media/') && admitSameSite(req, res))
return;
const clientId = String(req.socket?.remoteAddress || 'unknown').replace(
/^::ffff:/,
'',
);
const sources = await getCctvSources();
const sourceById = new Map(
sources.map((source) => [source.id, source]),
);
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname === '/sources') {
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
sources: sources.map((source) => ({
id: source.id,
pack: source.pack,
@@ -238,7 +246,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
return;
}
if (req.method === 'DELETE') {
if (leaseId) puller.release(cameraId, leaseId);
if (leaseId) puller.release(cameraId, leaseId, clientId);
res.writeHead(204);
res.end();
return;
@@ -268,6 +276,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
url.searchParams.get('session'),
Number(match[3]),
leaseId,
clientId,
);
res.writeHead(body ? 200 : 404, {
'Content-Type': 'video/mp2t',
@@ -279,10 +288,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const downstream = watchDownstreamClose(res);
let entry;
const cancelPending = () => {
if (entry) puller.release(cameraId, leaseId);
if (entry) puller.release(cameraId, leaseId, clientId);
};
try {
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
entry = await puller.ensure(
cameraId,
mediaUrl,
leaseId,
clientId,
);
if (downstream.closed) {
cancelPending();
return;
@@ -296,6 +310,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
entry,
cameraId,
leaseId,
clientId,
);
if (downstream.closed) return;
if (!playlist) throw new Error('Stream unavailable');
+59 -12
View File
@@ -5,6 +5,7 @@ import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import { loadLithuaniaSources } from './lithuania.js';
import { loadTaiwanSources } from './taiwan.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -19,12 +20,14 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadHkSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
loadWsdotSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -105,6 +108,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'hk',
enabled: () => envEnabled('CCTV_HK_ENABLED'),
load: loadHkSourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
@@ -144,6 +152,19 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
load: loadLithuaniaSources,
},
{
// FL511's terms limit its content to individual, personal use (other use
// needs FDOT's written consent), so the pack is off unless the operator
// opts in (see DATA_SOURCES.md).
name: 'florida',
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
load: loadFloridaSourcesFromOpenData,
},
{
name: 'taiwan',
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
@@ -195,16 +216,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
* Packs that serve fewer cameras than they offer, keyed by the source list
* they describe, so a request served the old list during a background
* refresh never pairs it with the new list's trim notes. */
const _cctvTrimmedPacks = new WeakMap();
/**
* Assemble and cache the merged CCTV source list.
*
* Merges every source pack (live open-data packs, local file, env
* variable), deduplicates by ID, shares the catalog cap fairly across
* packs, and caches for CCTV_SOURCE_CACHE_MS.
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
* returned while a background refresh replaces it; only a first load waits.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
@@ -219,11 +243,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
// instead of each launching the full multi-provider refetch. The `.finally`
// clears the ref so the next post-TTL cycle starts fresh.
if (_cctvSourceInflight) return _cctvSourceInflight;
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
return _cctvSourceInflight;
if (!_cctvSourceInflight) {
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
// A background refresh has no caller waiting on it: report its failure
// here, once per refresh rather than once per request it serves. Keep
// the old catalog for another TTL, as an empty refresh does, so a refresh
// that keeps failing is retried once per TTL, not back to back.
if (_cctvSourceCache.length) {
_cctvSourceInflight.catch((error) => {
_cctvSourceCacheAt = Date.now();
console.warn(
'[CCTV] background catalog refresh failed; serving',
_cctvSourceCache.length,
'cameras from the last catalog:',
error?.message || error,
);
});
}
}
// With a catalog in hand, answer with it while the refresh runs: the
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
// to its 30 s timeout), and every /api/cctv request would otherwise wait
// for it. Only the first load, with nothing to serve yet, waits.
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
}
/**
@@ -313,7 +357,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
_cctvTrimmedPacks.set(capped, trimmedPacks);
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
@@ -327,7 +371,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
/** Packs serving fewer cameras than they offer in `sources` (by default the
* current catalog). Pass the list a request was served to keep the two in
* step while a background refresh swaps the catalog. */
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
_cctvTrimmedPacks.get(sources) ?? [];
return getCctvSources;
}
+101 -1
View File
@@ -113,13 +113,34 @@ export const CALTRANS_ANCHORS = [
{ lat: 32.7157, lon: -117.1611 }, // San Diego
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
];
export const FLORIDA_CCTV_URL =
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
export const FLORIDA_IMAGE_HOSTS = [
'images-dis.divas.cloud',
'snapshots.divas.cloud',
];
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
export const FLORIDA_MAX_CATALOG_PAGES = 40;
export const FLORIDA_ANCHORS = [
{ lat: 25.7617, lon: -80.1918 }, // Miami
{ lat: 28.5384, lon: -81.3789 }, // Orlando
{ lat: 27.9506, lon: -82.4572 }, // Tampa
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
];
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
export const TFL_IMAGE_ORIGIN =
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
export const DEFAULT_TFL_MAX_SOURCES = 250;
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */
export const ONTARIO_511_CAMERAS_URL =
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
@@ -188,6 +209,8 @@ export const TARKTEE_IMAGES_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
export const TARKTEE_ANCHORS = [
{ lat: 59.437, lon: 24.753 }, // Tallinn
{ lat: 58.378, lon: 26.729 }, // Tartu
@@ -286,6 +309,36 @@ export const CATALONIA_ANCHORS = [
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
export const DEFAULT_HK_CAMERAS_URL =
'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml';
/** The only origin HK camera frames may come from. Verified 2026-10-11: still
* URLs answer 200 on this host with no redirect hop. */
export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/';
/** Above the full TD English catalog (~1,013 cameras) so a default install
* keeps nearly all of them. Catalog-wide round-robin still applies when the
* sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */
export const DEFAULT_HK_MAX_SOURCES = 1100;
/** Prioritization anchors across Hong Kong Island, Kowloon and the New
* Territories so a lowered cap keeps territory-wide coverage rather than
* only the harbour corridor. */
export const HK_ANCHORS = [
{ lat: 22.2819, lon: 114.158 }, // Central
{ lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui
{ lat: 22.3827, lon: 114.19 }, // Sha Tin
{ lat: 22.3701, lon: 114.114 }, // Tsuen Wan
{ lat: 22.4503, lon: 114.1688 }, // Tai Po
{ lat: 22.493, lon: 114.139 }, // Fanling
{ lat: 22.391, lon: 113.977 }, // Tuen Mun
{ lat: 22.4445, lon: 114.022 }, // Yuen Long
{ lat: 22.307, lon: 114.26 }, // Tseung Kwan O
];
/** Hard ceiling on the locations XML body. The live English list is ~400 KB
* for ~1,000 cameras; this only exists so an unbounded upstream cannot be
* buffered without limit. */
export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
* country (~900 cameras). The raw DATEX node needs an account; this view does
@@ -317,6 +370,49 @@ export const NORWAY_ANCHORS = [
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
];
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
* camera feed's points are 2D. */
export const KARTVERKET_HEIGHT_URL =
'https://ws.geonorge.no/hoydedata/v1/punkt';
/** Points per request. The API takes up to 50, but above ~20 it answers the
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
export const KARTVERKET_MAX_POINTS = 20;
/** Requests in flight at once. The whole country is ~36 requests of
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
export const KARTVERKET_CONCURRENCY = 8;
/** Deadline for one complete lookup, shared by every request. A lookup cut
* short by it is resumed on the next refresh, without a backoff. */
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
/** One request that takes longer than this counts as a failing service. */
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
* timeout so this pack is never the catalog's slowest lane. A lookup still
* running after it keeps filling the cache for the next refresh. */
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
/** The lane only waits when more than this share of its cameras has no
* answer yet (a cold cache). A few new cameras or expired misses are looked
* up in the background instead of holding the refresh. */
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
/** A point Kartverket has no height for (some ferry quays and bridges) is not
* asked about again for this long. */
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
/** After a failing request (408, 429, 5xx, a network error, a request
* timeout or an unreadable body), no lookup starts for this long, or for the
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
* costs one attempt per backoff, not one per refresh. */
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
* camera sits above ~1,500 m. Anything outside is treated as no height. */
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
/** Same identity the other CCTV upstream requests send. */
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
* five boroughs (~980 cameras). Frames are stills synthesized from each
* camera UUID on the city's webcam host — never taken from the payload. */
@@ -439,6 +535,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
* pending forever — a hung fetch aborts, the loader returns [], and
* serve-stale/other packs take over. */
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
* pack on about a third of loads. Both must arrive for any camera to load. */
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
/** Individual CCTV image fetches must settle before the active 10-second
* client refresh cadence. A bounded miss can fall through to Street View or
* the synthetic frame instead of leaving the browser preview pending. */
+22
View File
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
);
}
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
export function isLikelyFloridaCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 24.3 &&
lat <= 31.1 &&
lon >= -87.7 &&
lon <= -79.8
);
}
/** New York City's five boroughs, with slack for the harbor and city line. */
export function isLikelyNycCoordinate(lat, lon) {
return (
@@ -388,6 +399,17 @@ export function isLikelyNycCoordinate(lat, lon) {
);
}
/** Hong Kong SAR extent, with slack for the outlying islands. */
export function isLikelyHongKongCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 22.15 &&
lat <= 22.6 &&
lon >= 113.8 &&
lon <= 114.45
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
_resetOntarioKeyWarningForTest,
loadOntarioSourcesFromOpenData,
} from './sources.js';
const ROW = {
Id: '42',
Latitude: 43.6532,
Longitude: -79.3832,
Location: 'Hwy 401 near Yonge St',
Direction: 'Eastbound',
Views: [
{
Status: 'Enabled',
Url: 'https://511on.ca/map/Cctv/4201',
Description: 'Eastbound',
},
],
};
function captureConsole(t) {
const lines = [];
const capture = (...args) => lines.push(args.join(' '));
t.mock.method(console, 'warn', capture);
t.mock.method(console, 'log', capture);
return lines;
}
test.beforeEach(() => _resetOntarioKeyWarningForTest());
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
const lines = captureConsole(t);
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches += 1;
return Response.json([ROW]);
});
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
[],
);
assert.equal(fetches, 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /ONTARIO_511_API_KEY/);
});
test('with a key the camera list is requested with it and parsed', async (t) => {
const lines = captureConsole(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([ROW]);
});
const cameras = await loadOntarioSourcesFromOpenData(
{ sourceRoot: '/ignored' },
{ ONTARIO_511_API_KEY: ' test-key ' },
);
assert.equal(requested.length, 1);
const url = new URL(requested[0]);
assert.equal(url.host, '511on.ca');
assert.equal(url.searchParams.get('key'), 'test-key');
assert.equal(url.searchParams.get('format'), 'json');
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'on-42');
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
// The records go to the browser through /api/cctv; the key must stay on
// the server-side catalog request.
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
assert.doesNotMatch(lines.join('\n'), /test-key/);
});
test('the source cap is read from the same environment as the key', async (t) => {
captureConsole(t);
const rows = Array.from({ length: 12 }, (_, i) => ({
...ROW,
Id: String(100 + i),
Latitude: ROW.Latitude + i * 0.01,
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
}));
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
);
assert.equal(cameras.length, 8);
});
for (const status of [400, 401, 403]) {
test(`a ${status} points at the key by name, never by value`, async (t) => {
const lines = captureConsole(t);
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ Message: 'Invalid Key' }, { status }),
);
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'secret-key-value' },
);
assert.deepEqual(cameras, []);
assert.match(
lines.join('\n'),
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
);
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
});
}
for (const status of [429, 500, 503]) {
test(`a ${status} is reported without blaming the key`, async (t) => {
const lines = captureConsole(t);
t.mock.method(globalThis, 'fetch', async () =>
Response.json({}, { status }),
);
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: 'secret-key-value' },
);
assert.deepEqual(cameras, []);
assert.equal(lines.length, 1);
assert.equal(
lines[0],
`[CCTV] Ontario 511 camera download failed: ${status}`,
);
assert.doesNotMatch(lines[0], /secret-key-value/);
});
}
File diff suppressed because it is too large. Load diff
+46 -13
View File
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
export const HLS_LIMITS = Object.freeze({
sessions: 2,
leasesPerSession: 8,
leasesPerClient: 8,
playlistBytes: 256 * 1024,
segmentBytes: 4 * 1024 * 1024,
sessionBytes: 24 * 1024 * 1024,
@@ -138,25 +139,37 @@ export function createHlsPuller({
entry.upstream.clear();
entry.bytes = 0;
};
const release = (cameraId, leaseId) => {
const release = (cameraId, leaseId, clientId = 'legacy') => {
const entry = active.get(cameraId);
const lease = entry?.leases.get(leaseId);
if (!lease) return;
if (!lease || lease.clientId !== clientId) return;
clearTimeout(lease.timer);
entry.leases.delete(leaseId);
if (!entry.leases.size) stop(cameraId, entry.token);
};
const touch = (entry, leaseId, create = false) => {
const touch = (entry, leaseId, create = false, clientId = 'legacy') => {
let lease = entry.leases.get(leaseId);
if (lease && lease.clientId !== clientId)
throw new Error('HLS lease belongs to another client');
if (!lease) {
const clientLeases = [...active.values()].reduce(
(count, session) =>
count +
[...session.leases.values()].filter(
(item) => item.clientId === clientId,
).length,
0,
);
if (clientLeases >= limits.leasesPerClient)
throw new Error('HLS client lease capacity reached');
if (!create || entry.leases.size >= limits.leasesPerSession)
throw new Error('HLS lease capacity reached');
lease = {};
lease = { clientId };
entry.leases.set(leaseId, lease);
}
clearTimeout(lease.timer);
lease.timer = setTimeout(
() => release(entry.cameraId, leaseId),
() => release(entry.cameraId, leaseId, clientId),
limits.idleMs,
);
lease.timer.unref?.();
@@ -248,7 +261,12 @@ export function createHlsPuller({
}
}
};
const ensure = async (cameraId, url, leaseId = 'legacy') => {
const ensure = async (
cameraId,
url,
leaseId = 'legacy',
clientId = 'legacy',
) => {
if (closed) throw new Error('HLS service closed');
sameOriginHlsUrl(url, url);
let entry = active.get(cameraId);
@@ -257,7 +275,7 @@ export function createHlsPuller({
entry = null;
}
if (entry) {
touch(entry, leaseId, true);
touch(entry, leaseId, true, clientId);
return entry;
}
// Reserve synchronously, before any await: concurrent creation is singleflight.
@@ -279,8 +297,8 @@ export function createHlsPuller({
pendingDiscontinuity: false,
discontinuitiesRemoved: 0,
};
touch(entry, leaseId, true, clientId);
active.set(cameraId, entry);
touch(entry, leaseId, true);
entry.polling = poll(entry);
return entry;
};
@@ -292,9 +310,14 @@ export function createHlsPuller({
}
return false;
};
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
const buildPlaylist = async (
entry,
cameraId,
leaseId = 'legacy',
clientId = 'legacy',
) => {
if (entry.stopping || entry.segments.size < 2) return null;
touch(entry, leaseId);
touch(entry, leaseId, false, clientId);
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
const lines = [
'#EXTM3U',
@@ -318,12 +341,22 @@ export function createHlsPuller({
}
return lines.join('\n') + '\n';
};
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
const getSegment = (
cameraId,
token,
seq,
leaseId = 'legacy',
clientId = 'legacy',
) => {
const entry = active.get(cameraId);
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
if (
!entry ||
entry.token !== token ||
entry.leases.get(leaseId)?.clientId !== clientId
)
return null;
const body = entry.segments.get(seq)?.body;
if (body) touch(entry, leaseId);
if (body) touch(entry, leaseId, false, clientId);
return body || null;
};
const shutdown = async () => {
+303
View File
@@ -0,0 +1,303 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
prioritizeSources,
} from './normalize.js';
import { readResponseTextCapped } from '../common/http.js';
/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML
* standard, published keyless and refreshed daily (~1,800 cameras). */
export const TAIWAN_FREEWAY_CCTV_URL =
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
/** The whole network is ~1.2 MB. */
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
export const DEFAULT_TAIWAN_MAX_SOURCES = 600;
/** After a failed download, wait this long before asking again. The catalog
* host does not answer from some networks outside Taiwan, and every attempt
* there holds the shared catalog refresh until the connect timeout. */
export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
/** The table is republished daily; a good copy stands in for a failed
* download for up to this long. */
export const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000;
// The table publishes each camera's MJPEG stream. The same camera servers
// answer a single JPEG for the same camera ID on a sibling path, which is what
// the frame proxy can relay; only these exact pairs are ever registered.
const STREAM_TO_FRAME = new Map([
['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
[
'cctvs.freeway.gov.tw',
['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'],
],
]);
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
/** Prioritization anchors, north to south, so a cap keeps the whole island
* rather than only greater Taipei. */
const ANCHORS = [
{ lat: 25.033, lon: 121.5654 }, // Taipei
{ lat: 24.9936, lon: 121.301 }, // Taoyuan
{ lat: 24.8138, lon: 120.9675 }, // Hsinchu
{ lat: 24.7021, lon: 121.7378 }, // Yilan
{ lat: 24.1477, lon: 120.6736 }, // Taichung
{ lat: 22.9999, lon: 120.227 }, // Tainan
{ lat: 22.6273, lon: 120.3014 }, // Kaohsiung
];
/** Taiwan main island with slack for the coast; the freeway network has no
* offshore-island cameras. */
export function isLikelyTaiwanCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 21.8 &&
lat <= 25.4 &&
lon >= 119.9 &&
lon <= 122.1
);
}
const XML_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
};
function codePoint(value, radix) {
const code = parseInt(value, radix);
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: '';
}
function decodeXml(text) {
return String(text ?? '')
.replace(
/&(?:(amp|lt|gt|quot|apos)|#(\d{1,7})|#x([0-9a-fA-F]{1,6}));/g,
(_, name, dec, hex) =>
name ? XML_ENTITIES[name] : codePoint(dec ?? hex, dec ? 10 : 16),
)
.trim();
}
function tag(block, name) {
const match = new RegExp(`<${name}>([^<]*)</${name}>`).exec(block);
return match ? decodeXml(match[1]) : '';
}
/**
* Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the
* Catalonia and Tarktee parsers: the schema is flat, and only the fields used
* below are read.
*
* @param {string} xml
* @returns {Array<object>}
*/
export function parseTaiwanCctvXml(xml) {
const rows = [];
const blockRe = /<CCTV>([\s\S]*?)<\/CCTV>/g;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const block = match[1];
rows.push({
cctvId: tag(block, 'CCTVID'),
streamUrl: tag(block, 'VideoStreamURL'),
lat: Number(tag(block, 'PositionLat')),
lon: Number(tag(block, 'PositionLon')),
roadName: tag(block, 'RoadName'),
direction: tag(block, 'RoadDirection'),
sectionStart: tag(block, 'Start'),
sectionEnd: tag(block, 'End'),
mile: tag(block, 'LocationMile'),
});
}
return rows;
}
/**
* Published MJPEG stream URL -> the same camera's still, plus the host and
* camera number that identify it, or null.
*
* @param {string} streamUrl
* @returns {?{frameUrl: string, host: string, camera: string}}
*/
export function taiwanFrame(streamUrl) {
let url;
try {
url = new URL(String(streamUrl ?? ''));
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port)
return null;
const paths = STREAM_TO_FRAME.get(url.hostname);
if (!paths || url.pathname !== paths[0]) return null;
const camera = url.searchParams.get('camera') || '';
if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null;
return {
// Reconstruct so extra query parameters cannot alter the request.
frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`,
host: url.hostname.split('.')[0],
camera,
};
}
/**
* One parsed Freeway Bureau row -> one catalog source, or null.
*
* @param {object} row - Output of parseTaiwanCctvXml.
* @returns {?object}
*/
export function taiwanCameraToSource(row) {
if (!row || typeof row !== 'object') return null;
const { lat, lon } = row;
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
const frame = taiwanFrame(row.streamUrl);
if (!frame) return null;
const imageUrl = frame.frameUrl;
// CCTVID carries Chinese place names and a few IDs repeat across distinct
// cameras, so the stable key is the camera server plus its camera number.
const rawId = String(row.cctvId ?? '')
.trim()
.slice(0, 80);
const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`;
const road = String(row.roadName ?? '').trim();
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
const mile = String(row.mile ?? '').trim();
const section = [row.sectionStart, row.sectionEnd]
.map((part) => String(part ?? '').trim())
.filter(Boolean)
.join('→');
// A bare carriageway letter is not a name; fall back to the published ID.
const name =
(road || mile || section
? [road, direction, mile, section].filter(Boolean).join(' ')
: '') ||
rawId ||
`Freeway camera ${frame.camera}`;
return {
id: cameraId,
name: name.slice(0, 160),
// One island-wide category in the camera picker, like Finland and Norway.
city: 'Taiwan / 臺灣',
cityId: 'taiwan',
provider: 'Freeway Bureau, MOTC',
lat,
lon,
// The table gives the carriageway direction, not the camera's bearing,
// so the pose uses the shared low-confidence prior.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tw-freeway-motc',
license:
'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)',
code: cameraDisplayCode(
`${road} ${mile}`.trim().toUpperCase() || frame.camera,
),
};
}
let lastGood = null;
let lastGoodAt = 0;
let retryAfter = 0;
/** Forget the last good table and any failure backoff (tests only). */
export function resetTaiwanSourcesCache() {
lastGood = null;
lastGoodAt = 0;
retryAfter = 0;
}
function fallbackSources() {
return lastGood && Date.now() - lastGoodAt <= TAIWAN_LAST_GOOD_MAX_AGE_MS
? lastGood
: [];
}
function taiwanSourceCap() {
const rawCap = Number(
process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES,
);
return Number.isFinite(rawCap)
? Math.max(1, Math.min(2000, Math.floor(rawCap)))
: DEFAULT_TAIWAN_MAX_SOURCES;
}
/**
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
* One bounded request per shared catalog refresh. A failed download serves the
* last good table (up to a day old) and is not retried for an hour, so a host
* that cannot reach the Freeway Bureau does not wait on it every refresh.
*
* @returns {Promise<Array<object>>}
*/
export async function loadTaiwanSources() {
if (Date.now() < retryAfter) return fallbackSources();
try {
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
const response = await fetch(
process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL,
{
headers: { Accept: 'application/xml,text/xml' },
redirect: 'manual',
signal,
},
);
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `HTTP ${response.status}`,
);
}
const xml = await readResponseTextCapped(
response,
TAIWAN_MAX_CATALOG_BYTES,
signal,
);
const cameras = new Map();
for (const row of parseTaiwanCctvXml(xml)) {
const camera = taiwanCameraToSource(row);
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
}
if (!cameras.size) throw new Error('no usable cameras in the table');
const sources = prioritizeSources(
[...cameras.values()],
taiwanSourceCap(),
ANCHORS,
);
console.log(
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
);
lastGood = sources;
lastGoodAt = Date.now();
retryAfter = 0;
return sources;
} catch (error) {
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
const fallback = fallbackSources();
console.warn(
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
(fallback.length
? `serving the last good ${fallback.length} cameras`
: 'no Taiwan cameras') +
', next attempt in 1 h',
);
return fallback;
}
}
+53 -8
View File
@@ -14,16 +14,34 @@ async function readRequestBodyCapped(req, maxBytes) {
return Buffer.concat(chunks);
}
function readRequestBody(req, maxBytes = 1024 * 1024) {
/** Optional cancellation discards buffered data while the request drains. */
function readRequestBody(req, maxBytes = 1024 * 1024, signal) {
return new Promise((resolve, reject) => {
let total = 0;
let bodyTooLarge = false;
let settled = false;
const chunks = [];
req.on('data', (chunk) => {
const stopReading = () => {
if (!signal) return;
req.off('data', data);
req.off('end', end);
signal.removeEventListener('abort', abort);
};
const fail = (error) => {
if (signal) {
if (settled) return;
settled = true;
chunks.length = 0;
signal.removeEventListener('abort', abort);
}
reject(error);
};
const abort = () => fail(signal.reason);
const data = (chunk) => {
// Keep draining after the cap without retaining more bytes. Destroying
// the request also destroys its socket, so the handler cannot deliver
// the fixed error response it constructs for BODY_TOO_LARGE.
if (bodyTooLarge) return;
if (bodyTooLarge || settled) return;
total += chunk.length;
if (total > maxBytes) {
// Same marker readRequestBodyCapped sets, so a caller can tell an
@@ -32,15 +50,42 @@ function readRequestBody(req, maxBytes = 1024 * 1024) {
chunks.length = 0;
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
error.code = 'BODY_TOO_LARGE';
reject(error);
fail(error);
return;
}
chunks.push(chunk);
});
req.on('end', () => {
};
const end = () => {
if (settled) {
stopReading();
return;
}
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', reject);
if (signal) {
settled = true;
chunks.length = 0;
stopReading();
}
};
const close = () => {
fail(
new DOMException('Request closed before its body ended', 'AbortError'),
);
stopReading();
req.off('error', fail);
req.off('close', close);
};
if (signal) {
// Abort/overflow release retained bytes but keep draining. Retain an
// error handler until close, including a late error after end/abort.
req.on('close', close);
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
}
req.on('data', data);
req.on('end', end);
req.on('error', fail);
if (signal && req.destroyed) close();
});
}
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter, getEventListeners } from 'node:events';
import { readRequestBody } from './request.js';
function fixture() {
const req = new EventEmitter();
req.pause = () => assert.fail('The reader must keep draining');
req.destroy = () =>
assert.fail('The reader must preserve the response socket');
const owner = new AbortController();
return { req, owner };
}
function finish(req, owner) {
assert.doesNotThrow(() => req.emit('error', new Error('late stream error')));
req.emit('end');
assert.doesNotThrow(() => req.emit('error', new Error('error before close')));
req.emit('close');
for (const event of ['data', 'end', 'error', 'close'])
assert.equal(req.listenerCount(event), 0, event);
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
}
test('default and cancellable readers preserve byte caps and UTF-8 across chunks', async () => {
for (const useSignal of [false, true]) {
for (const text of ['', 'hello', 'é']) {
const { req, owner } = fixture();
const bytes = Buffer.from(text);
const body = readRequestBody(
req,
bytes.length,
useSignal ? owner.signal : undefined,
);
for (const byte of bytes) req.emit('data', Buffer.from([byte]));
req.emit('end');
assert.equal(await body, text);
if (useSignal) finish(req, owner);
}
}
});
test('default overflow rejects before end and drains without pausing or destroying', async () => {
const { req } = fixture();
const rejection = assert.rejects(readRequestBody(req, 2), {
code: 'BODY_TOO_LARGE',
});
req.emit('data', Buffer.from('é'));
req.emit('data', Buffer.from('!'));
await rejection;
req.emit('data', Buffer.alloc(4096));
assert.doesNotThrow(() => req.emit('error', new Error('late')));
req.emit('end');
});
test('partial abort settles before end, discards late data and guards errors until close', async () => {
const { req, owner } = fixture();
const reason = new Error('request deadline');
const rejection = assert.rejects(
readRequestBody(req, 1024, owner.signal),
(error) => error === reason,
);
req.emit('data', Buffer.from('partial'));
owner.abort(reason);
await rejection;
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
req.emit('data', Buffer.alloc(4096));
finish(req, owner);
});
test('already-aborted and already-closed requests settle and release their listeners', async () => {
for (const closed of [false, true]) {
const { req, owner } = fixture();
if (closed) req.destroyed = true;
else owner.abort();
await assert.rejects(readRequestBody(req, 1024, owner.signal), {
name: 'AbortError',
});
if (!closed) finish(req, owner);
for (const event of ['data', 'end', 'error', 'close'])
assert.equal(req.listenerCount(event), 0);
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
}
});
test('overflow keeps its first failure through later abort and stream errors', async () => {
const { req, owner } = fixture();
const rejection = assert.rejects(readRequestBody(req, 2, owner.signal), {
code: 'BODY_TOO_LARGE',
});
req.emit('data', Buffer.from('é!'));
await rejection;
owner.abort(new Error('later deadline'));
req.emit('data', Buffer.alloc(4096));
finish(req, owner);
});
test('early close and request error reject once; successful end wins over later abort', async () => {
for (const event of ['close', 'error', 'end']) {
const { req, owner } = fixture();
const reason = new Error('request failed');
const body = readRequestBody(req, 32, owner.signal);
const observed =
event === 'end'
? body.then((value) => assert.equal(value, 'ok'))
: assert.rejects(
body,
event === 'close'
? { name: 'AbortError' }
: (error) => error === reason,
);
req.emit('data', Buffer.from('ok'));
req.emit(event, reason);
owner.abort(new Error('later abort'));
await observed;
if (event !== 'close') finish(req, owner);
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
}
});
test('a prior data observer can abort synchronously without retaining or resolving that chunk', async () => {
const { req, owner } = fixture();
const reason = new Error('observer stopped request');
const external = () => owner.abort(reason);
req.on('data', external);
const rejection = assert.rejects(
readRequestBody(req, 1024, owner.signal),
(error) => error === reason,
);
req.emit('data', Buffer.from('must be discarded'));
await rejection;
req.emit('end');
req.emit('close');
assert.deepEqual(req.listeners('data'), [external]);
assert.equal(getEventListeners(owner.signal, 'abort').length, 0);
req.off('data', external);
});
+45
View File
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
return handler(req, res, next);
};
}
/**
* Proxy header values arrive client-controlled; read one only when it is a
* single scheme token, never a comma-separated chain.
* @param {import('node:http').IncomingMessage} req
* @returns {'http:'|'https:'}
*/
function forwardedProtocol(req) {
if (req.socket?.encrypted) return 'https:';
const proto = String(req.headers?.['x-forwarded-proto'] || '')
.trim()
.toLowerCase();
return proto === 'https' ? 'https:' : 'http:';
}
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
* and refusing them would break rendering for an instance served behind the
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
* the scheme an `Origin` is compared against. That header cannot be attached
* to a cross-site browser request without a CORS preflight this server never
* answers, and `Sec-Fetch-Site` is still enforced regardless.
* @param {import('node:http').IncomingMessage} req
* @param {import('node:http').ServerResponse} res
* @returns {boolean} True when a 403 was sent.
*/
export function admitSameSiteKeyless(req, res) {
const verdict = admitSameSiteRequest({
hostHeader: req.headers?.host,
protocol: forwardedProtocol(req),
origin: req.headers?.origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: {},
});
if (verdict.ok) return false;
res.statusCode = verdict.status;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: verdict.error }));
return true;
}
+22
View File
@@ -0,0 +1,22 @@
import { createGeminiTokenHandler } from './gemini/realtime.js';
/** Mint constrained Live API credentials; the permanent key stays on Node. */
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
function install(middlewares) {
middlewares.use(
'/api/gemini/token',
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
);
}
return {
name: 'gemini-live-proxy',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}
export { geminiLiveProxy };
+53
View File
@@ -0,0 +1,53 @@
import { realtimeInstructions } from '../openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
export const GEMINI_TOKEN_ENDPOINT =
'https://generativelanguage.googleapis.com/v1beta/auth_tokens';
export const GEMINI_LIVE_WEBSOCKET_URL =
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
export function normalizeGeminiModel(value) {
const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT)
.trim()
.replace(/^models\//, '');
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
}
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
export function createGeminiLiveConfig(
annotationGuidance,
inputMode = 'open-mic',
tools = GEV_REALTIME_TOOLS,
) {
if (!['open-mic', 'push-to-talk'].includes(inputMode))
throw new Error('Invalid Gemini input mode');
const config = {
generationConfig: { responseModalities: ['AUDIO'] },
systemInstruction: {
parts: [{ text: realtimeInstructions(annotationGuidance) }],
},
tools: [
{
functionDeclarations: tools.map(
({ name, description, parameters }) => ({
name,
description,
// GEV's canonical schemas are JSON Schema, not Google's Schema.
parametersJsonSchema: structuredClone(parameters),
// App actions must finish before the model confirms their result.
behavior: 'BLOCKING',
}),
),
},
],
inputAudioTranscription: {},
outputAudioTranscription: {},
contextWindowCompression: { slidingWindow: {} },
};
if (inputMode === 'push-to-talk')
config.realtimeInputConfig = {
automaticActivityDetection: { disabled: true },
};
return config;
}
+208
View File
@@ -0,0 +1,208 @@
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js';
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
import {
GEMINI_TOKEN_ENDPOINT,
GEMINI_LIVE_WEBSOCKET_URL,
GEMINI_LIVE_MODEL_DEFAULT,
createGeminiLiveConfig,
normalizeGeminiModel,
} from './config.js';
import {
isGeminiRequestOriginAllowed,
readGeminiTokenRequest,
} from './request.js';
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
export function createGeminiTokenHandler({
annotationGuidance,
tools,
fetchImpl = (...args) => fetch(...args),
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveModel = () =>
process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT,
resolveHost = () => process.env.HOST,
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30',
now = Date.now,
timeoutMs = 15_000,
} = {}) {
let limiter;
return async (req, res) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (status, errorOrPayload) => {
if (res.destroyed || res.writableEnded) return;
res.statusCode = status;
if (status >= 400) res.setHeader('Connection', 'close');
res.end(
JSON.stringify(
typeof errorOrPayload === 'string'
? { error: errorOrPayload }
: errorOrPayload,
),
);
};
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return reply(405, 'Method not allowed');
}
let apiKey;
let model;
try {
if (
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
)
return reply(403, 'Gemini voice requires a same-origin app request');
apiKey = String(resolveApiKey() || '').trim();
if (!apiKey)
return reply(503, 'Add a Gemini API key in Provider Settings');
model = normalizeGeminiModel(resolveModel());
if (!model)
return reply(503, 'The Gemini Live model configuration is invalid');
if (limiter === undefined)
limiter = makeCostRateLimiter(resolveRateLimit(), 30);
if (limiter && !limiter(clientKey(req))) {
res.setHeader('Retry-After', '60');
return reply(
429,
'Gemini voice is starting too often. Try again shortly.',
);
}
} catch {
// Connect does not observe the handler's returned promise. A synchronous
// configuration resolver must fail with a bounded, fixed response.
return reply(503, MINT_FAILURE);
}
const lifetime = new AbortController();
let disconnected = false;
const disconnect = () => {
disconnected = true;
lifetime.abort(new DOMException('Request closed', 'AbortError'));
};
const responseClosed = () => {
if (!res.writableEnded) disconnect();
};
req.once('aborted', disconnect);
res.once('close', responseClosed);
const deadline = setTimeout(() => {
lifetime.abort(
new DOMException('Token request timed out', 'TimeoutError'),
);
}, timeoutMs);
deadline.unref?.();
let acquiredResponse;
try {
const inputMode = await readGeminiTokenRequest(req, lifetime.signal);
lifetime.signal.throwIfAborted();
const config = createGeminiLiveConfig(
annotationGuidance,
inputMode,
tools,
);
const issuedAt = now();
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
// input shape, converted before transport. With no fieldMask the complete
// setup is locked to this server-owned configuration.
const request = {
uses: 1,
expireTime: new Date(issuedAt + 30 * 60_000).toISOString(),
newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(),
bidiGenerateContentSetup: { model: `models/${model}`, ...config },
};
const acquisition = Promise.resolve(
fetchImpl(GEMINI_TOKEN_ENDPOINT, {
method: 'POST',
redirect: 'error',
signal: lifetime.signal,
headers: {
'x-goog-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
}),
);
// A fetch implementation can ignore abort. Bound this caller's wait and
// discard a response that arrives after timeout/disconnection.
void acquisition.then(
(late) => {
acquiredResponse = late;
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
},
() => {},
);
const response = await waitForSignal(acquisition, lifetime.signal);
lifetime.signal.throwIfAborted();
if (
response.redirected ||
(response.url && response.url !== GEMINI_TOKEN_ENDPOINT)
) {
void response.body?.cancel().catch(() => {});
throw new Error('Unexpected token response destination');
}
if (!response.ok) {
// Never forward upstream bodies, headers or exception messages.
void response.body?.cancel().catch(() => {});
if (response.status === 429) {
res.setHeader('Retry-After', '60');
return reply(429, 'Gemini quota is unavailable. Try again later.');
}
if (response.status === 401 || response.status === 403)
return reply(
503,
'Gemini access was refused. Check the API key and model access.',
);
if (response.status === 400 || response.status === 404)
return reply(
502,
'Gemini Live is unavailable for the configured model.',
);
return reply(502, MINT_FAILURE);
}
const data = await readResponseJsonCapped(
response,
UPSTREAM_RESPONSE_BYTES,
lifetime.signal,
);
lifetime.signal.throwIfAborted();
if (
!data ||
Array.isArray(data) ||
typeof data !== 'object' ||
typeof data.name !== 'string' ||
data.name.length > 8192 ||
!/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) ||
data.name.includes(apiKey)
)
throw new Error('Invalid ephemeral token');
reply(200, {
token: data.name,
model,
config,
websocketUrl: GEMINI_LIVE_WEBSOCKET_URL,
});
} catch (error) {
if (disconnected || req.aborted) return;
if (error?.code === 'BODY_TOO_LARGE')
return reply(413, 'Token request is too large');
if (error?.code === 'BAD_CONTENT_TYPE')
return reply(415, 'Use an empty JSON token request');
if (error?.code === 'BAD_BODY')
return reply(400, 'Use an empty JSON token request');
reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE);
} finally {
// Abort can happen after acquisition resolves but before this handler
// resumes and passes ownership to the body reader.
if (lifetime.signal.aborted)
void acquiredResponse?.body?.cancel().catch(() => {});
clearTimeout(deadline);
req.off('aborted', disconnect);
res.off('close', responseClosed);
}
};
}
+79
View File
@@ -0,0 +1,79 @@
import { readRequestBody } from '../common/request.js';
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
import {
isAllowedHost,
resolveAllowedHosts,
} from '../../../build/allowedHosts.js';
/** Use the app's Host and request policies, with a required browser Origin. */
export function isGeminiRequestOriginAllowed(
req,
configuredHost = '',
configuredAllowedHosts,
) {
const host = req.headers?.host;
const origin = req.headers?.origin;
if (
typeof host !== 'string' ||
typeof origin !== 'string' ||
!host ||
!origin ||
/[\s/?#@\\]/.test(host)
)
return false;
const protocol = req.socket?.encrypted ? 'https:' : 'http:';
if (
!admitSameSiteRequest({
hostHeader: host,
protocol,
origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: req.headers || {},
}).ok
)
return false;
// The shared gate compares a present Origin exactly with this authority;
// this route also requires the Origin and an allowed Host.
const boundHost = String(configuredHost || '')
.trim()
.toLowerCase();
return isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [
boundHost,
]);
}
/** Admit only the existing input gesture; callers cannot supply model/setup. */
export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) {
const declared = Number(req.headers?.['content-length']);
const fail = (code) =>
Object.assign(new Error('Invalid token request'), { code });
const body = await readRequestBody(
req,
maxBytes,
Number.isFinite(declared) && declared > maxBytes
? AbortSignal.abort(fail('BODY_TOO_LARGE'))
: signal,
);
if (!body.trim()) return 'open-mic';
if (
!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '')
)
throw fail('BAD_CONTENT_TYPE');
let parsed;
try {
parsed = JSON.parse(body);
} catch {
throw fail('BAD_BODY');
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
throw fail('BAD_BODY');
const keys = Object.keys(parsed);
if (!keys.length) return 'open-mic';
if (
keys.length === 1 &&
keys[0] === 'inputMode' &&
parsed.inputMode === 'push-to-talk'
)
return 'push-to-talk';
throw fail('BAD_BODY');
}
+4 -2
View File
@@ -19,6 +19,7 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js';
import { aisLiveProxy } from './vessels/ais-live.js';
import { trackBackfillProxies } from './aircraft/tracks.js';
import { openAiRealtimeProxy } from './openai.js';
import { geminiLiveProxy } from './gemini.js';
import { googlePlacesContextProxy } from './places.js';
import { mapillaryProxy } from './mapillary.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
@@ -30,9 +31,9 @@ import { windProxy } from './wind.js';
/**
* Construct the local provider plugins in their established order.
* `realtime` configures the voice session token endpoint.
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
*/
function localProviderPlugins({ realtime } = {}) {
function localProviderPlugins({ realtime, gemini } = {}) {
return [
openSkyProxy(),
celestrakProxy(),
@@ -55,6 +56,7 @@ function localProviderPlugins({ realtime } = {}) {
aisLiveProxy(),
trackBackfillProxies(),
openAiRealtimeProxy({ realtime }),
...(gemini ? [geminiLiveProxy({ realtime: gemini })] : []),
googlePlacesContextProxy(),
windProxy(),
weatherProxy(),
+101 -14
View File
@@ -1,19 +1,106 @@
import { installMapillaryRoutes } from './mapillary/routes.js';
import { sameSiteGated } from './common/same-site.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
fetchTile,
mapillaryToken,
parseTilePath,
retryAfterWait,
TileUpstreamError,
} from './mapillary/tiles.js';
/**
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
* status route that says whether a token is configured.
* Tile requests per client IP per minute, cache hits included: over 60 new
* 9-tile views a minute, more than a person flying the camera reaches.
*/
function mapillaryProxy() {
return {
name: 'mapillary-proxy',
configureServer(server) {
installMapillaryRoutes(server.middlewares);
},
configurePreviewServer(server) {
installMapillaryRoutes(server.middlewares);
},
};
export const TILE_ROUTE_MAX_PER_MIN = 600;
function sendJson(res, status, payload) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
}
export { mapillaryProxy };
/** GET /api/mapillary/status — whether a token is configured, never its value. */
function handleStatus(req, res) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
}
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — trimmed protobuf tile. */
async function handleTile(req, res, allow) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
if (!allow(clientKey(req))) {
res.setHeader('Retry-After', '5');
return sendJson(res, 429, {
error: 'Too many tile requests',
retryAfter: 5,
});
}
const address = parseTilePath((req.url || '').split('?')[0]);
if (!address)
return sendJson(res, 400, {
error: 'Tile path must be /coverage/{z}/{x}/{y} with z 11–14',
});
if (!mapillaryToken())
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
// A client that leaves (a tile the camera moved past) cancels its fetch.
const left = new AbortController();
res.on?.('close', () => {
if (!res.writableEnded) left.abort();
});
try {
const { bytes, source } = await fetchTile(address, { signal: left.signal });
res.statusCode = bytes.length ? 200 : 204;
res.setHeader('Content-Type', 'application/x-protobuf');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Gev-Cache', source);
res.end(bytes.length ? bytes : undefined);
} catch (error) {
if (left.signal.aborted) return;
const status = error instanceof TileUpstreamError ? error.status : 0;
// A rejected token is a key problem the panel can name, not a fault.
if (status === 401 || status === 403)
return sendJson(res, 403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
if (status === 429) {
const retryAfter = retryAfterWait(error.retryAfter);
res.setHeader('Retry-After', String(retryAfter));
return sendJson(res, 429, {
error: 'Mapillary is rate-limiting tile requests',
retryAfter,
});
}
// An upstream 400 must not read as a malformed request to this proxy.
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
}
}
/**
* Vite plugin: Mapillary coverage tiles with the token added server-side, and
* a status route. Both refuse cross-site requests so another page cannot
* spend the token.
*/
export function mapillaryProxy() {
const install = (middlewares) => {
const allow = makeRateLimiter({
windowMs: 60_000,
max: TILE_ROUTE_MAX_PER_MIN,
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
});
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
middlewares.use(
'/api/mapillary/tiles',
sameSiteGated((req, res) => handleTile(req, res, allow)),
);
};
return {
name: 'mapillary-proxy',
configureServer: (server) => install(server.middlewares),
configurePreviewServer: (server) => install(server.middlewares),
};
}
-72
View File
@@ -1,72 +0,0 @@
import path from 'node:path';
/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */
export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
export const TILE_LAYERS = Object.freeze({
// Region-sized z6–10 tiles are refused; the app never asks for them. The
// z14 `image` layer is ~98% of a tile and unused (positions come from the
// graph API), so it is dropped in transit.
coverage: Object.freeze({
upstream: 'mly1_public',
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
dropLayers: Object.freeze(['image']),
}),
});
/** Disk cache root, alongside the other providers' caches. */
export const MAPILLARY_CACHE_DIR = path.join(
process.cwd(),
'.gev-cache',
'mapillary',
);
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
/**
* Longer than the 15-minute upstream Cache-Control: tiles change only when new
* imagery is processed, and a day avoids re-downloading a city per camera move.
*/
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
/**
* A tile write starts a background sweep at most this often: expired files go
* first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
*/
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
/** Per-file cost on top of its size, so empty tiles still count. */
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
/** Per-entry cost on top of its bytes, so empty tiles still count. */
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
export const TILE_FETCH_TIMEOUT_MS = 60_000;
/**
* Upstream fetches at once; further misses queue. A view needs at most 25
* tiles, and the cap stops a burst of misses opening thousands of requests.
*/
export const TILE_UPSTREAM_CONCURRENCY = 6;
/**
* Tile requests per client IP per minute, cache hits included. 600 is 24 new
* 25-tile views a minute, more than a person flying the camera reaches, but it
* stops one page draining the shared token into a Mapillary rate limit.
*/
export const TILE_ROUTE_MAX_PER_MIN = 600;
/**
* How long a 401/403 answers tile misses before asking Mapillary again. A
* changed token is tried at once.
*/
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
/** After a 429 without a usable Retry-After, hold tile misses this long. */
export const TILE_RATE_LIMIT_HOLD_MS = 60_000;
/** The longest Retry-After honoured, so a bad header cannot stall coverage. */
export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
}
-95
View File
@@ -1,95 +0,0 @@
import { sameSiteGated } from '../common/same-site.js';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js';
import { mapillaryToken, TILE_ROUTE_MAX_PER_MIN } from './constants.js';
function sendJson(res, status, payload) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
}
/** GET /api/mapillary/status — whether a token is configured, never its value. */
function handleStatus(req, res) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
}
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */
async function handleTile(req, res, allow) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
if (!allow(clientKey(req))) {
res.setHeader('Retry-After', '5');
return sendJson(res, 429, {
error: 'Too many tile requests',
retryAfter: 5,
});
}
const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
(req.url || '').split('?')[0],
);
if (!match)
return sendJson(res, 400, {
error: 'Tile path must be /coverage/{z}/{x}/{y}',
});
if (!mapillaryToken())
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
const abandoned = new AbortController();
req.on?.('aborted', () => abandoned.abort());
res.on?.('close', () => abandoned.abort());
try {
const { bytes, source } = await fetchTile(
{ layer: match[1], z: match[2], x: match[3], y: match[4] },
{ signal: abandoned.signal },
);
if (res.writableEnded) return;
res.statusCode = bytes.length ? 200 : 204;
res.setHeader('Content-Type', 'application/x-protobuf');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Gev-Cache', source);
res.end(bytes.length ? bytes : undefined);
} catch (error) {
if (res.writableEnded || abandoned.signal.aborted) return;
if (error instanceof TileRequestError)
return sendJson(res, error.status, { error: error.message });
if (error instanceof TileUpstreamError) {
// A rejected token is a key problem the panel can name, not a fault.
if (error.keyRejected)
return sendJson(res, 403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
if (error.status === 429) {
const retryAfter = error.retryAfterSec || 60;
res.setHeader('Retry-After', String(retryAfter));
return sendJson(res, 429, {
error: 'Mapillary is rate-limiting tile requests',
retryAfter,
});
}
// An upstream 400 must not read as a malformed request to this proxy.
return sendJson(res, 502, { error: error.message });
}
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
}
}
/**
* Attach the Mapillary routes. Both refuse cross-site requests so another page
* cannot spend the token or provoke a 429 that holds every tile miss.
*/
export function installMapillaryRoutes(middlewares) {
const allow = makeRateLimiter({
windowMs: 60_000,
max: TILE_ROUTE_MAX_PER_MIN,
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
});
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
middlewares.use(
'/api/mapillary/tiles',
sameSiteGated((req, res) => handleTile(req, res, allow)),
);
}
+216 -469
View File
@@ -1,518 +1,265 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { readResponseBytesCapped } from '../common/http.js';
import { stripTileLayers } from './trim.js';
import {
MAPILLARY_TILE_HOST,
TILE_LAYERS,
TILE_DISK_DIR,
TILE_DISK_TTL_MS,
TILE_DISK_SWEEP_INTERVAL_MS,
TILE_DISK_MAX_BYTES,
TILE_DISK_FILE_OVERHEAD_BYTES,
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
TILE_FETCH_TIMEOUT_MS,
TILE_UPSTREAM_CONCURRENCY,
TILE_KEY_REJECTED_HOLD_MS,
TILE_RATE_LIMIT_HOLD_MS,
TILE_RATE_LIMIT_MAX_HOLD_MS,
mapillaryToken,
} from './constants.js';
/** Log without the stack; the token is never in scope here. */
function warn(what, error) {
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */
const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Only the street zooms the app draws are proxied. */
const MIN_ZOOM = 11;
const MAX_ZOOM = 14;
/**
* The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused
* (positions come from the Graph API), so it is dropped in transit.
*/
const DROP_LAYERS = ['image'];
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
/** Upstream fetches at once; more wait their turn. */
export const TILE_UPSTREAM_CONCURRENCY = 6;
/** Tiles change only when new imagery is processed. */
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
export const TILE_MEMORY_MAX_TILES = 120;
/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */
export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024;
/** The longest upstream Retry-After honoured, in seconds. */
export const MAX_RETRY_AFTER_SEC = 600;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
}
/** Thrown for a request this proxy refuses before contacting Mapillary. */
export class TileRequestError extends Error {
constructor(message, status = 400) {
super(message);
this.name = 'TileRequestError';
this.status = status;
}
}
/** Mapillary answered with an error status; 401/403 set `keyRejected`. */
/** Mapillary answered with an error status (or a size this proxy refuses). */
export class TileUpstreamError extends Error {
constructor(status, message, { retryAfterSec = null } = {}) {
constructor(status, message, retryAfter = null) {
super(message || `Mapillary tiles HTTP ${status}`);
this.name = 'TileUpstreamError';
this.status = status;
this.keyRejected = status === 401 || status === 403;
this.retryAfterSec = retryAfterSec;
/** The upstream Retry-After header, as sent. */
this.retryAfter = retryAfter;
}
}
/**
* A rejected token or rate limit that answers every tile miss until it expires
* (or the token changes). Cached tiles are still served meanwhile.
* @type {{status: number, until: number, token: string}|null}
*/
let _upstreamHold = null;
/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */
function retryAfterMs(header, now = Date.now()) {
/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */
export function retryAfterSeconds(header, now = Date.now()) {
const text = String(header ?? '').trim();
let ms = NaN;
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text) ms = Date.parse(text) - now;
if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS;
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
if (!text) return null;
const seconds = /^\d+$/.test(text)
? Number(text)
: (Date.parse(text) - now) / 1000;
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null;
}
function heldRefusal(token) {
const hold = _upstreamHold;
if (!hold) return null;
const left = hold.until - Date.now();
if (left <= 0 || hold.token !== token) {
_upstreamHold = null;
/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */
export function retryAfterWait(header, now = Date.now()) {
return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC);
}
/**
* While Mapillary rate-limits, tile misses are refused here until the wait is
* over, so the rest of a view (and the fetches queued behind the first 429)
* do not go on asking. Scoped to the token that was refused.
* @type {{until: number, token: string}|null}
*/
let refusal = null;
/** The refusal still in force for `token`, as the error to answer with. */
function refusedNow(token) {
if (!refusal) return null;
const left = refusal.until - Date.now();
if (left <= 0 || refusal.token !== token) {
refusal = null;
return null;
}
return new TileUpstreamError(hold.status, undefined, {
retryAfterSec: Math.ceil(left / 1000),
});
return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000)));
}
/**
* Validate a tile address against the layer's zoom ranges.
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
* Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid.
* @returns {{z: number, x: number, y: number, key: string}|null}
*/
export function normalizeTileAddress({ layer, z, x, y }) {
const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null;
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
const zi = Number(z);
const xi = Number(x);
const yi = Number(y);
if (
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
!spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max)
)
throw new TileRequestError(
`Tile zoom for ${layer} must be ${spec.zoomRanges
.map(([min, max]) => `${min}–${max}`)
.join(' or ')}`,
);
const n = 2 ** zi;
if (xi >= n || yi >= n)
throw new TileRequestError('Tile address out of range');
return {
layer,
upstream: spec.upstream,
dropLayers: spec.dropLayers || [],
z: zi,
x: xi,
y: yi,
key: `${layer}/${zi}/${xi}/${yi}`,
};
export function parseTilePath(pathname) {
const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname);
if (!match) return null;
const [z, x, y] = match.slice(1).map(Number);
if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null;
return { z, x, y, key: `${z}/${x}/${y}` };
}
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
const _memory = new Map();
let _memoryBytes = 0;
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
/**
* Upstream fetches shared by every request for the same tile; the last waiter
* to leave cancels it (see `joinFlight`).
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
*/
const _inFlight = new Map();
function memoryGet(key) {
const hit = _memory.get(key);
if (!hit) return null;
// Same life as the disk copy, so a long-running server never serves stale
// coverage.
if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
_memory.delete(key);
_memoryBytes -= memoryCost(hit.bytes);
return null;
}
_memory.delete(key);
_memory.set(key, hit);
return hit.bytes;
}
/** `at` is when Mapillary served the bytes (a disk tile's mtime). */
function memoryPut(key, bytes, at = Date.now()) {
if (memoryCost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
const existing = _memory.get(key);
if (existing) _memoryBytes -= memoryCost(existing.bytes);
_memory.set(key, { bytes, at });
_memoryBytes += memoryCost(bytes);
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
const [oldest, entry] = _memory.entries().next().value;
_memory.delete(oldest);
_memoryBytes -= memoryCost(entry.bytes);
}
}
/** Disk cache root; tests point it at a temporary directory. */
let _diskDir = TILE_DISK_DIR;
function diskPath({ layer, z, x, y }, root = _diskDir) {
return path.join(root, layer, String(z), `${x}-${y}.pbf`);
}
async function readDisk(address) {
const file = diskPath(address);
function trim(bytes) {
try {
const stat = await fsp.stat(file);
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
return { bytes: await fsp.readFile(file), at: stat.mtimeMs };
} catch {
return null;
}
}
/** Unsettled background writes and sweeps; requests never wait on them. */
const _background = new Set();
function track(task) {
const settled = task
.catch(() => {})
.finally(() => _background.delete(settled));
_background.add(settled);
return settled;
}
/** @type {Map<string, number>} cache file to its newest write's sequence */
const _diskWrites = new Map();
let _diskWriteSeq = 0;
function diskWritePending(address) {
return _diskWrites.has(diskPath(address));
}
/**
* Write a tile in the background. `at` keeps a rewrite's original fetch time
* so it does not extend the tile's life. An overtaken write steps aside.
*/
function writeDisk(address, bytes, at = null) {
// Sweep this write's root even if the cache dir has moved since.
const root = _diskDir;
const file = diskPath(address, root);
const seq = ++_diskWriteSeq;
const tmp = `${file}.${process.pid}-${seq}.tmp`;
const newest = () => _diskWrites.get(file) === seq;
_diskWrites.set(file, seq);
return track(
fsp
.mkdir(path.dirname(file), { recursive: true })
.then(() => fsp.writeFile(tmp, bytes))
.then(() => at != null && fsp.utimes(tmp, new Date(), new Date(at)))
.then(() => {
if (!newest()) return fsp.rm(tmp, { force: true });
return fsp.rename(tmp, file).then(() => scheduleSweep(root));
})
.catch(async (error) => {
await fsp.rm(tmp, { force: true }).catch(() => {});
warn('tile cache write failed', error);
})
.finally(() => {
if (newest()) _diskWrites.delete(file);
}),
);
}
let _lastSweepAt = 0;
let _sweeping = null;
let _sweepWarned = false;
function scheduleSweep(root) {
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
return;
_lastSweepAt = Date.now();
const sweep = sweepTileDisk({ root })
.catch((error) => {
if (_sweepWarned) return;
_sweepWarned = true;
warn('tile cache sweep failed', error);
})
.finally(() => {
if (_sweeping === tracked) _sweeping = null;
});
const tracked = track(sweep);
_sweeping = tracked;
}
/** Every file under `dir` with size and mtime; a missing dir is empty. */
async function listCacheFiles(dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
return stripTileLayers(bytes, DROP_LAYERS);
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
const files = [];
for (const entry of entries) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
else if (entry.isFile()) {
const stat = await fsp.stat(file).catch(() => null);
if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs });
}
}
return files;
}
/**
* Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
*/
export async function sweepTileDisk({
root = _diskDir,
maxBytes = TILE_DISK_MAX_BYTES,
} = {}) {
const now = Date.now();
const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at);
const cost = (size) => size + TILE_DISK_FILE_OVERHEAD_BYTES;
let bytes = files.reduce((sum, { size }) => sum + cost(size), 0);
let removed = 0;
for (const { file, size, at } of files) {
if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break;
await fsp.rm(file, { force: true });
bytes -= cost(size);
removed++;
}
return { removed, bytes };
}
/** The one origin the tile token may be sent to, at every redirect hop. */
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
export const TILE_MAX_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/**
* Follow redirects by hand so every hop stays HTTPS on the tile origin and the
* token in the query never reaches another host. A bad redirect is a 502.
*/
async function fetchPinned(url, init) {
let next = url;
for (let hop = 0; ; hop++) {
const response = await fetch(next, { ...init, redirect: 'manual' });
if (!REDIRECT_STATUSES.has(response.status)) return response;
await response.body?.cancel().catch(() => {});
if (hop >= TILE_MAX_REDIRECTS)
throw new TileUpstreamError(502, 'Mapillary tile redirected too often');
let target = null;
try {
target = new URL(response.headers.get('location') ?? '', next);
} catch {
/* unparseable Location */
}
if (
!response.headers.get('location') ||
target?.protocol !== 'https:' ||
target.origin !== TILE_ORIGIN ||
target.username ||
target.password
)
throw new TileUpstreamError(
502,
'Mapillary tile redirect left the tile origin',
);
next = target.href;
}
}
async function fetchUpstream(address, signal) {
const token = mapillaryToken();
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
const held = heldRefusal(token);
if (held) throw held;
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
const oversize = new AbortController();
const response = await fetchPinned(url, {
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
headers: { Accept: 'application/x-protobuf' },
});
if (response.status === 404 || response.status === 204) {
await response.body?.cancel().catch(() => {});
return Buffer.alloc(0);
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
const error = new TileUpstreamError(response.status);
if (error.keyRejected)
_upstreamHold = {
status: response.status,
until: Date.now() + TILE_KEY_REJECTED_HOLD_MS,
token,
};
else if (response.status === 429) {
const wait = retryAfterMs(response.headers.get('retry-after'));
_upstreamHold = { status: 429, until: Date.now() + wait, token };
error.retryAfterSec = Math.ceil(wait / 1000);
}
throw error;
}
// Chunked or compressed bodies have no Content-Length: the cap applies as
// the body streams, and an oversized one also aborts the request.
try {
const bytes = await readResponseBytesCapped(
response,
TILE_MAX_BYTES,
signal,
);
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
} catch (error) {
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
oversize.abort();
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
}
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
let _slots = { active: 0, queue: [] };
/**
* Resolve with a release function once a slot is free. An abort while
* queued leaves the queue.
* @returns {Promise<() => void>}
*/
function acquireUpstreamSlot(signal) {
const slots = _slots;
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const onAbort = () => {
const index = slots.queue.indexOf(grant);
if (index !== -1) slots.queue.splice(index, 1);
reject(signal.reason);
};
function grant() {
signal.removeEventListener('abort', onAbort);
slots.active++;
let released = false;
resolve(() => {
if (released) return;
released = true;
slots.active--;
slots.queue.shift()?.();
});
}
if (slots.active < TILE_UPSTREAM_CONCURRENCY) return grant();
signal.addEventListener('abort', onAbort, { once: true });
slots.queue.push(grant);
});
}
function trim(address, bytes) {
if (!address.dropLayers.length || !bytes.length) return bytes;
try {
return stripTileLayers(bytes, address.dropLayers);
} catch (error) {
warn('tile trim failed, serving raw', error);
console.warn('[Mapillary Proxy] tile trim failed:', error?.message);
return bytes;
}
}
/**
* Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
* bytes mean no coverage.
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
*/
export async function fetchTile(request, { signal } = {}) {
const address = normalizeTileAddress(request);
const memory = memoryGet(address.key);
if (memory) return { bytes: memory, source: 'memory', address };
const disk = await readDisk(address);
if (disk) {
// Older cache files may be untrimmed: rewrite once, keeping the fetch time.
const bytes = trim(address, disk.bytes);
if (bytes !== disk.bytes && !diskWritePending(address))
writeDisk(address, bytes, disk.at);
memoryPut(address.key, bytes, disk.at);
return { bytes, source: 'disk', address };
/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */
async function fetchUpstream({ z, x, y }, signal) {
const token = mapillaryToken();
const refused = refusedNow(token);
if (refused) throw refused;
const response = await fetch(
`${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`,
{
signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
// The token is in the query: a redirect is never followed (a 3xx is a 502).
redirect: 'manual',
headers: { Accept: 'application/x-protobuf' },
},
);
if (!response.ok || response.status === 204) {
await response.body?.cancel().catch(() => {});
if (response.status === 204 || response.status === 404)
return Buffer.alloc(0);
const retryAfter = response.headers.get('retry-after');
if (response.status === 429)
refusal = {
token,
until: Date.now() + retryAfterWait(retryAfter) * 1000,
};
throw new TileUpstreamError(response.status, undefined, retryAfter);
}
try {
const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES);
return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length));
} catch (error) {
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
// A caller gone while the disk was read must not start a fetch nobody joins.
signal?.throwIfAborted();
let flight = _inFlight.get(address.key);
const joined = Boolean(flight) && !flight.controller.signal.aborted;
if (!joined) flight = startFlight(address);
const bytes = await joinFlight(flight, signal);
return { bytes, source: joined ? 'inflight' : 'upstream', address };
}
/** Start the shared upstream fetch; it holds a slot until the body is read. */
let upstreamActive = 0;
/** @type {Array<() => void>} fetches waiting for a slot, oldest first */
const upstreamWaiting = [];
/**
* Run `task` in an upstream slot, waiting for one unless `signal` aborts
* first. A freed slot passes straight to the oldest waiter, still counted, so
* a request arriving meanwhile cannot take it first.
*/
async function withUpstreamSlot(signal, task) {
if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++;
else
await new Promise((resolve, reject) => {
const wake = () => {
signal.removeEventListener('abort', leave);
resolve();
};
const leave = () => {
const at = upstreamWaiting.indexOf(wake);
if (at !== -1) upstreamWaiting.splice(at, 1);
reject(signal.reason);
};
upstreamWaiting.push(wake);
signal.addEventListener('abort', leave, { once: true });
});
try {
// Handed a slot after leaving: it goes on to the next waiter.
signal.throwIfAborted();
return await task();
} finally {
const next = upstreamWaiting.shift();
if (next) next();
else upstreamActive--;
}
}
/** `promise`, or a rejection as soon as `signal` aborts. */
function unlessAborted(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const leave = () => reject(signal.reason);
signal.addEventListener('abort', leave, { once: true });
promise
.then(resolve, reject)
.finally(() => signal.removeEventListener('abort', leave));
});
}
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
const memory = new Map();
/**
* Upstream fetches shared per tile, with the requests still waiting on each.
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
*/
const inFlight = new Map();
function remember(key, bytes, at = Date.now()) {
if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return;
memory.delete(key);
memory.set(key, { bytes, at });
for (const oldest of memory.keys()) {
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
memory.delete(oldest);
}
}
function recall(key) {
const hit = memory.get(key);
if (!hit) return null;
if (Date.now() - hit.at > TILE_TTL_MS) {
memory.delete(key);
return null;
}
remember(key, hit.bytes, hit.at); // most recently used again
return hit.bytes;
}
/** Start the upstream fetch for a tile, shared until its last request leaves. */
function startFlight(address) {
const controller = new AbortController();
const flight = { controller, waiters: 0, promise: null };
flight.promise = acquireUpstreamSlot(controller.signal)
.then((release) =>
fetchUpstream(address, controller.signal).finally(release),
)
.then((raw) => trim(address, raw))
const flight = { controller, waiters: 0 };
flight.promise = withUpstreamSlot(controller.signal, () =>
fetchUpstream(address, controller.signal),
)
.then((bytes) => {
memoryPut(address.key, bytes);
writeDisk(address, bytes);
remember(address.key, bytes);
return bytes;
})
.finally(() => {
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
});
// Every waiter may have left before it settles; nobody awaits it then.
// Every request may have left; the rejection is then nobody's.
flight.promise.catch(() => {});
_inFlight.set(address.key, flight);
inFlight.set(address.key, flight);
return flight;
}
/**
* Wait on a shared flight. A caller's abort rejects only that caller; the
* fetch is cancelled when the last waiter leaves.
* One tile from memory, a shared in-flight fetch, or Mapillary. `signal`
* aborts when the request's client leaves; the fetch stops once all have.
* @param {{z: number, x: number, y: number, key: string}} address from parseTilePath
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>}
*/
function joinFlight(flight, signal) {
signal?.throwIfAborted();
export async function fetchTile(address, { signal } = {}) {
const cached = recall(address.key);
if (cached) return { bytes: cached, source: 'memory' };
let flight = inFlight.get(address.key);
const shared = Boolean(flight);
flight ||= startFlight(address);
flight.waiters++;
return new Promise((resolve, reject) => {
let done = false;
const leave = () => {
if (done) return false;
done = true;
signal?.removeEventListener('abort', onAbort);
flight.waiters--;
return true;
};
const onAbort = () => {
if (!leave()) return;
if (flight.waiters === 0) flight.controller.abort();
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
flight.promise.then(
(bytes) => leave() && resolve(bytes),
(error) => leave() && reject(error),
);
});
try {
const bytes = await unlessAborted(flight.promise, signal);
return { bytes, source: shared ? 'inflight' : 'upstream' };
} finally {
if (--flight.waiters === 0) {
// A later request for the tile must not join a cancelled fetch.
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
flight.controller.abort();
}
}
}
/** Test seam: forget every cached tile held in memory. */
export function _resetTileMemoryForTest() {
_memory.clear();
_memoryBytes = 0;
_inFlight.clear();
_upstreamHold = null;
_slots = { active: 0, queue: [] };
}
/** Test seam: how many tiles memory holds and the bytes charged for them. */
export function _tileMemoryForTest() {
return { entries: _memory.size, bytes: _memoryBytes };
}
/** Test seam: wait for every background write and the sweeps they start. */
export async function _settleTileWritesForTest() {
while (_background.size) await Promise.allSettled([..._background]);
}
/** Test seam: keep the disk cache in `dir` (null restores the default). */
export function _setTileCacheDirForTest(dir) {
_diskDir = dir || TILE_DISK_DIR;
_lastSweepAt = 0;
/** Test seam: forget every cached and in-flight tile. */
export function _resetTileCacheForTest() {
memory.clear();
inFlight.clear();
refusal = null;
upstreamActive = 0;
upstreamWaiting.length = 0;
}
-13
View File
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
}, null);
return dropped ? Buffer.from(writer.finish()) : bytes;
}
/** Names of the layers inside a vector tile, in order. */
export function listTileLayers(bytes) {
const names = [];
if (!bytes?.length) return names;
new PbfReader(bytes).readFields((tag, _result, pbf) => {
if (tag !== TILE_LAYER_FIELD) return;
const end = pbf.readVarint() + pbf.pos;
names.push(layerName(bytes.subarray(pbf.pos, end)));
pbf.pos = end;
}, null);
return names;
}
+2
View File
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
const normalized = String(token || '').trim();
return {
Accept: 'application/json',
'User-Agent':
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
};
}
+99 -4
View File
@@ -8,6 +8,8 @@ import {
terrainPointKey,
validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
/**
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
@@ -20,11 +22,31 @@ import {
* Only missing/stale points go upstream; the response is rebuilt in exact
* request order. Larger requests are chunked sequentially, and one failing
* chunk does not discard the chunks that resolved.
*
* Bounds, because every distinct 5dp point is its own key: coordinates must be
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
* with oldest-first eviction, an over-large cache file is not read back, and
* the route carries an always-on per-client limiter. The route also refuses
* cross-site browser requests, so another web page cannot use the visitor's
* browser to fill the cache or spend the visitor's own rate-limit quota.
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
*/
export function terrainHeightsProxy() {
export function terrainHeightsProxy(options = {}) {
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
// extracts these proxies by slicing to the first closing brace in column
// zero, so a multi-line parameter list would cut the function in half.
// Overridable so the ceilings can be exercised as behaviour rather than
// asserted as constants; production constructs this with no arguments.
const {
cacheMaxEntries = 50_000,
cacheMaxDiskBytes = 24 * 1024 * 1024,
cachePath,
} = options;
const TTL_MS = 30 * 24 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
const CACHE_PATH =
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
const CACHE_DIR = path.dirname(CACHE_PATH);
// Sized against measured upstream latency, not the documented page cap.
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
@@ -34,9 +56,50 @@ export function terrainHeightsProxy() {
// that range.
const UPSTREAM_CHUNK = 64;
const MAX_POINTS = 2000;
/**
* Entry ceiling for the point cache, and with it the file written from it.
*
* Every distinct 5dp point is a separate key, so without a ceiling the map
* — and the JSON dumped from it verbatim — grow for as long as a caller
* supplies new coordinates. Measured against real use rather than guessed:
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
* eight times that working set and ~8 MB on disk, comfortably inside the
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
*/
const CACHE_MAX_ENTRIES = cacheMaxEntries;
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
/**
* Requests/min/client. Terrain is keyless and free, so this guards the point
* cache and the upstream community service rather than a bill. Sized from
* measurement: across a full `npm run test:track` run — the heaviest
* exercise this app has — the route served 10 requests, peaking at 8/min.
* 90/min is an order of magnitude above that, and the band `/api/overpass`
* and `/api/military-installations` already use.
*/
const RATE_PER_MIN = 90;
const allow = makeRateLimiter({
windowMs: 60_000,
max: RATE_PER_MIN,
globalMax: RATE_PER_MIN * 3,
});
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
const mem = new Map();
/**
* Drop the oldest entries until the map is back inside its ceiling.
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
* Terrain does not move, so evicting an old point costs one refetch, never
* a wrong answer.
*/
function evictOverflow() {
if (mem.size <= CACHE_MAX_ENTRIES) return;
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.delete(byAge[i][0]);
}
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
const inflight = new Map();
let diskLoaded = false;
@@ -47,6 +110,21 @@ export function terrainHeightsProxy() {
if (diskLoaded) return;
diskLoaded = true;
try {
// A cache file past the ceiling is not read back into memory: loading it
// would reinstate exactly the unbounded map the ceiling exists to stop.
// The next flush rewrites a bounded one.
//
// "Cannot measure it" is not "too large": if stat is unavailable, fall
// through and let readFile decide, so an absent file behaves exactly as
// it did before this guard existed.
let sizeBytes = null;
try {
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
} catch {
sizeBytes = null;
}
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
throw new Error('cache file too large');
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
const pointEntries =
parsed?.version === 2 &&
@@ -87,6 +165,7 @@ export function terrainHeightsProxy() {
}
diskDirty = mem.size > 0;
}
evictOverflow();
} catch {
/* no disk cache yet */
}
@@ -157,12 +236,25 @@ export function terrainHeightsProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/terrain/heights', async (req, res) => {
// Refuse cross-site browser requests (an <img> or fetch from another
// site) before the limiter, cache or upstream see them. Same-origin app
// requests, non-browser loopback callers (MCP panel_request) and the app
// served behind a reverse proxy pass.
if (admitSameSiteKeyless(req, res)) return;
const send = (status, bodyObj) => {
if (res.headersSent) return;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(bodyObj));
};
try {
if (!allow(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
await loadDiskOnce();
const parsedUrl = new URL(req.url || '', 'http://internal');
const rawPoints = parsedUrl.searchParams.get('points');
@@ -187,7 +279,10 @@ export function terrainHeightsProxy() {
fetchMissing: fetchMissingSingleFlight,
ttlMs: TTL_MS,
});
if (outcome.cacheChanged) diskDirty = true;
if (outcome.cacheChanged) {
evictOverflow();
diskDirty = true;
}
if (outcome.upstreamError) {
console.warn(
'[terrain-heights-proxy] refresh incomplete' +
+2 -1
View File
@@ -16,9 +16,10 @@ export default defineConfig(({ command, mode }) => {
for (const [key, value] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = value;
}
const tools = standaloneVoiceTools();
return createBrowserViteConfig({
plugins: [
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
localMcpPlugin(),
apiNotFoundPlugin(),
unwatchedRuntimeDirsPlugin(),
+170
View File
@@ -0,0 +1,170 @@
import * as Cesium from 'cesium';
import {
feedProvenanceEnvelope,
layerSnapshot,
layerSnapshots,
} from '../data/layerSnapshot.js';
import { contextModeWord } from '../contextModePolicy.js';
export const TRACKABLE_FAMILIES = Object.freeze([
{ layerId: 'flights', kind: 'aircraft' },
{ layerId: 'military', kind: 'aircraft' },
{ layerId: 'ais-live-vessels', kind: 'vessel' },
{ layerId: 'satellites', kind: 'satellite' },
]);
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
{ field: 'mode', emptyAs: 'off' },
{ field: 'entering', emptyAs: null },
{ field: 'priorMode', emptyAs: null },
]);
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
'context',
'contextRollback',
]);
/**
* Translate context-mode payloads into the public tool vocabulary while
* retaining the internal id beside each translated field.
*/
export function withContextModeVocabulary(state) {
if (!state || typeof state !== 'object') return state;
let out = state;
const mutable = () => {
if (out === state) out = { ...state };
return out;
};
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
if (!(field in state)) continue;
const internal = state[field] ?? null;
const target = mutable();
target[field] = contextModeWord(internal, { emptyAs });
target[`${field}Internal`] = internal;
}
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
const nested = state[field];
if (!nested || typeof nested !== 'object') continue;
const translated = withContextModeVocabulary(nested);
if (translated !== nested) mutable()[field] = translated;
}
return out;
}
/** Read tracked entities from their canonical layer owners. */
export function collectTrackedEntities(dataManager) {
const tracked = [];
for (const family of TRACKABLE_FAMILIES) {
const module = dataManager?.layers?.get(family.layerId)?.module;
if (!module) continue;
try {
const info =
family.kind === 'vessel'
? module.getSelectedInfo?.()
: module.getTrackedInfo?.();
if (info)
tracked.push({
kind: family.kind,
layerId: family.layerId,
...info,
});
} catch {
// A not-yet-ready layer contributes no tracked observation.
}
}
return tracked;
}
/** Read the active Contacts window from its owning awareness layer. */
export function activeContactsWindow(dataManager) {
try {
const layer = dataManager?.layers?.get('military-awareness')?.module;
return (
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
);
} catch {
return null;
}
}
/**
* Build the model-free fast World Packet from existing application owners.
*
* This performs no network requests and intentionally mirrors the historical
* get_current_view_state payload so voice can delegate without behavior drift.
*/
export function getWorldPacket(options = {}) {
const {
viewer,
styleManager,
dataManager,
sceneDirector = null,
now: requestedNow,
} = options ?? {};
// A default parameter only covers undefined; null or NaN would age every
// feed against the epoch and report stale data as "just now".
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
if (!viewer?.camera?.positionWC)
throw new TypeError('World Packet requires a viewer camera');
if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
// Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll();
const enabledRows = rows.filter((layer) => layer.enabled);
const contactsWindow = activeContactsWindow(dataManager);
const contextState =
typeof styleManager?.getContextModeState === 'function'
? withContextModeVocabulary(styleManager.getContextModeState())
: null;
return {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
heightM: cartographic.height,
},
style: styleManager?.activeStyle || 'normal',
context: contextState
? {
...contextState,
...(contactsWindow ? { contactsWindow } : {}),
}
: null,
cockpit:
typeof styleManager?.getCockpitState === 'function'
? styleManager.getCockpitState()
: null,
controls:
typeof styleManager?.getControlState === 'function'
? styleManager.getControlState()
: null,
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
tracked: collectTrackedEntities(dataManager),
disabledLayerCount: rows.length - enabledRows.length,
// Preserve get_current_view_state's existing compact enabled-only shape.
layers: enabledRows.map((layer) => {
const snapshot = layerSnapshot(layer, { now });
return {
id: layer.id,
name: layer.name,
enabled: layer.enabled,
count: layer.stats?.count || 0,
error: layer.stats?.error || null,
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
};
}),
feedProvenance: feedProvenanceEnvelope(
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
{ now },
),
};
}
+335
View File
@@ -0,0 +1,335 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getWorldPacket } from './worldPacket.js';
import { createGevActionRunner } from '../voice/gevActions.js';
function withoutVoicePresentation(result) {
const packet = { ...result };
delete packet.say;
delete packet.display;
delete packet.schedule;
return packet;
}
function harness() {
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
const viewer = {
trackedEntity: undefined,
clock: { onTick: { addEventListener: () => () => {} } },
scene: {
canvas: {
clientWidth: 1200,
clientHeight: 800,
addEventListener() {},
removeEventListener() {},
},
globe: { getHeight: () => 0 },
tweens: [],
},
camera: {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: 0,
pitch: Cesium.Math.toRadians(-45),
moveEnd: { addEventListener: () => () => {} },
cancelFlight() {},
lookAtTransform() {},
},
};
const rows = [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 3,
source: 'fixture',
lastUpdate: null,
},
},
];
const dataManager = {
layers: new Map([['traffic', { module: null }]]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'normal',
getContextModeState: () => ({ mode: 'flights' }),
getCockpitState: () => null,
getControlState: () => ({ hud: { visible: 'auto' } }),
};
return { viewer, dataManager, styleManager };
}
test('World Packet preserves get_current_view_state parity', async () => {
const { viewer, dataManager, styleManager } = harness();
const packet = getWorldPacket({ viewer, dataManager, styleManager });
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
assert.equal(packet.context.mode, 'contacts');
assert.equal(packet.context.modeInternal, 'flights');
assert.equal(packet.layers[0].feedState, 'nominal');
assert.equal(packet.feedProvenance.overall, 'nominal');
});
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
const { viewer, dataManager, styleManager } = harness();
dataManager.getAll = () => [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
];
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(packet.layers[0].count, 0);
assert.notEqual(packet.layers[0].feedState, 'nominal');
assert.notEqual(packet.feedProvenance.overall, 'nominal');
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
});
// The 2026-10 main voice contract omits disabled layers from the model payload.
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
const { viewer, dataManager, styleManager } = harness();
const rows = dataManager.getAll();
rows.push({
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
});
let reads = 0;
dataManager.getAll = () => {
reads += 1;
return rows;
};
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
assert.equal(packet.disabledLayerCount, 1);
assert.deepEqual(
packet.layers.map((layer) => layer.id),
['traffic'],
);
assert.equal(packet.feedProvenance.overall, 'nominal');
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
});
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
// Exercises every packet field: context vocabulary and Contacts window,
// cockpit, controls, scene playback, tracked families (one not ready),
// enabled and disabled layers, and an aged and an unavailable feed.
function pinnedHarness() {
const { viewer } = harness();
const rows = [
{
id: 'flights',
name: 'Flights',
enabled: true,
stats: {
count: 12,
source: 'fixture-adsb',
lastUpdate: PINNED_NOW - 90_000,
},
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
},
];
const module = (methods) => ({ module: methods });
const dataManager = {
layers: new Map([
[
'flights',
module({
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
}),
],
[
'ais-live-vessels',
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
],
[
'satellites',
module({
getTrackedInfo: () => {
throw new Error('not ready');
},
}),
],
[
'military-awareness',
module({
getContextSnapshot: () => ({ snapshot: true }),
contactsWindowFromSnapshot: (snapshot) =>
snapshot ? { windowMin: 30, count: 4 } : null,
}),
],
]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'night-vision',
getContextModeState: () => ({
mode: 'flights',
entering: null,
priorMode: null,
}),
getCockpitState: () => ({ active: false }),
getControlState: () => ({ hud: { visible: 'auto' } }),
};
const sceneDirector = {
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
};
return { viewer, dataManager, styleManager, sceneDirector };
}
// Captured from main's get_current_view_state before the extraction
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
// minus the speech envelope. Key order is part of the contract.
const PRE_EXTRACTION_PAYLOAD = {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: 30.259999999999994,
longitude: -97.74,
heightM: 499.99999999901877,
},
style: 'night-vision',
context: {
mode: 'contacts',
entering: null,
priorMode: null,
modeInternal: 'flights',
enteringInternal: null,
priorModeInternal: null,
contactsWindow: { windowMin: 30, count: 4 },
},
cockpit: { active: false },
controls: { hud: { visible: 'auto' } },
scenePlayback: { playing: false, sceneId: 'fixture' },
tracked: [
{
kind: 'aircraft',
layerId: 'flights',
icao24: 'abc123',
callsign: 'TEST1',
},
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
],
disabledLayerCount: 1,
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
count: 12,
error: null,
feedState: 'nominal',
source: 'fixture-adsb',
lastUpdate: 1792238310000,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
count: 0,
error: 'fixture unavailable',
feedState: 'unavailable',
source: 'fixture',
lastUpdate: 0,
},
],
feedProvenance: {
overall: 'unavailable',
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
feedState: 'nominal',
source: 'fixture-adsb',
count: 12,
lastUpdate: 1792238310000,
ageSec: 90,
ageLabel: '1m ago',
error: null,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
feedState: 'unavailable',
source: 'fixture',
count: 0,
lastUpdate: 0,
ageSec: null,
ageLabel: null,
error: 'fixture unavailable',
},
],
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
},
};
function assertPinnedPayload(actual) {
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
// deepEqual ignores key order; the serialized form does not.
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
}
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
assertPinnedPayload(packet);
});
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
const runner = createGevActionRunner(pinnedHarness());
const voice = await runner('get_current_view_state');
assertPinnedPayload(withoutVoicePresentation(voice));
});
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
for (const now of [null, Number.NaN, '2026-10-17']) {
const packet = getWorldPacket({ ...pinnedHarness(), now });
const flights = packet.feedProvenance.layers.find(
(layer) => layer.id === 'flights',
);
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
}
});
test('World Packet rejects null options with the camera requirement', () => {
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
});
+87
View File
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
+10 -10
View File
@@ -30,6 +30,7 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
+106
View File
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+40 -8
View File
@@ -444,6 +444,7 @@ export function createAnnotationResolver({
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -469,6 +470,7 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
const neCentroid = ringCentroid(ne.ring);
@@ -2004,8 +2006,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2020,7 +2022,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2037,16 +2039,24 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
if (ne?.polygons?.length) {
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
if (admin) return { name: admin.name, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2067,8 +2077,26 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2077,12 +2105,16 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
if (admin) return { name: q, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
return null;
}
+1 -11
View File
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: {
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
services: { sprites, picking, input, scenePick, render },
});
}
+14 -2
View File
@@ -881,6 +881,7 @@ export function createAnalystEngine(providers) {
followUp && !spec.scope ? lastResult.distanceOf || null : null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
@@ -890,8 +891,16 @@ export function createAnalystEngine(providers) {
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
const region = await providers.resolveRegionRing(scope.name, signal);
if (!isCurrent()) return cancelled();
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
if (signal?.aborted || !isCurrent()) return cancelled();
throw error;
}
if (signal?.aborted || !isCurrent()) return cancelled();
if (region?.error === 'region-timeout') {
return {
ok: false,
@@ -910,6 +919,7 @@ export function createAnalystEngine(providers) {
}
resolvedScope = region;
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
} else if (scope.kind === 'radius') {
@@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
@@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
};
+303
View File
@@ -1,6 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
// Stub world: a square "Texland" region, flights + ships + fires around it.
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
assert.ok(r.coverage.scope.includes('Texland'));
});
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async () => ({ ...TEXLAND, source }),
});
const result = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.coverage.scopeSource, source);
}
});
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
const lookups = [];
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
lookups.push(name);
return { ...TEXLAND, source: 'natural-earth' };
},
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const airborne = await engine.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: false }],
});
const military = await engine.query({
followUp: true,
filters: [{ field: 'military', op: 'eq', value: true }],
});
assert.equal(first.count, 3);
assert.equal(airborne.count, 2);
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
for (const result of [first, airborne, military]) {
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.scopeLabel, 'over Texland');
assert.equal(result.coverage.scopeSource, 'natural-earth');
}
assert.deepEqual(lookups, ['Texland']);
});
test('analyst: an explicit replacement region replaces retained provenance', async () => {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: {
name,
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
source: 'remote-boundary',
},
});
await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({
followUp: true,
scope: { kind: 'region', name: 'Northern Texland' },
});
const followUp = await engine.query({ followUp: true });
for (const result of [replacement, followUp]) {
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
assert.equal(result.coverage.scope, 'region:Northern Texland');
assert.equal(result.coverage.scopeSource, 'remote-boundary');
}
});
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
for (const scope of [
{ kind: 'anywhere' },
{ kind: 'view' },
{ kind: 'radius', km: 150 },
{ kind: 'region', name: 'Uncredited Texland' },
]) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({
...TEXLAND,
name,
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({ followUp: true, scope });
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.scopeSource, 'natural-earth');
for (const result of [replacement, followUp]) {
assert.equal(result.ok, true, scope.kind);
assert.equal(result.count, 3, scope.kind);
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
}
}
});
for (const [label, committedScope, scopeSource] of [
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
['window without a boundary source', 'window:presented', null],
]) {
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
const committedSnapshot = {
id: 'flights', enabled: true, feedState: 'stale',
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
};
let reads = 0;
let lookups = 0;
const engine = createAnalystEngine({
getRecords: () => { reads++; return FLIGHTS; },
getLayerSnapshot: () => ({
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
}),
resolveRegionRing: async () => {
lookups++;
return { ...TEXLAND, source: 'natural-earth' };
},
});
await engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{
beforeCommit: async (result) => {
assert.equal(result.count, 3);
assert.equal(result.coverage.scopeSource, 'natural-earth');
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
};
},
},
);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.count, 1);
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
assert.equal(followUp.scopeLabel, 'the presented cohort');
assert.equal(followUp.display.scope, 'the presented cohort window');
assert.equal(followUp.coverage.scope, committedScope);
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
});
}
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
assert.equal(result.cancelled, true);
});
for (const [label, reason, trackAuthority] of [
['default abort reason', undefined, false],
['custom error reason', new Error('operator stopped'), false],
['primitive reason and revoked authority', 'operator stopped', true],
]) {
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
const started = Promise.withResolvers();
const geocode = Promise.withResolvers();
const controller = new AbortController();
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
method,
async () => { throw new Error('offline region query reached feature source'); },
])),
});
const placeSearch = {
geocode(name, { signal }) {
lookups.push(name);
assert.equal(signal, controller.signal);
started.resolve();
return geocode.promise;
},
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
const first = await engine.query({
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
});
assert.equal(first.ok, true);
assert.equal(first.coverage.scopeSource, 'bundled-admin');
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
let commits = 0;
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
{
signal: controller.signal,
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
beforeCommit: async () => { commits++; },
},
);
await started.promise;
controller.abort(reason);
// Let an actual late geocode result cross the resolver's post-await check.
geocode.resolve({ place: {
lat: 5, lng: -73, label: 'Cordillera Oriental',
name: 'Cordillera Oriental', types: ['natural_feature'],
} });
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.equal(commits, 0);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
assert.equal(followUp.scopeLabel, first.scopeLabel);
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
});
}
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
const lookup = Promise.withResolvers();
let current = true;
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: lookup.promise,
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ isCurrent: () => current },
);
current = false;
lookup.reject(new Error('late lookup failed'));
assert.equal((await pending).code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
assert.equal(followUp.coverage.scope, first.coverage.scope);
});
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
const controller = new AbortController();
const lookup = Promise.withResolvers();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: () => lookup.promise,
});
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{ signal: controller.signal },
);
controller.abort();
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
assert.equal((await pending).code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
const controller = new AbortController();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
throw error;
},
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
await assert.rejects(
engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ signal: controller.signal },
),
(thrown) => thrown === error,
);
assert.equal(controller.signal.aborted, false);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
});
}
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
+36
View File
@@ -77,6 +77,11 @@ import {
CCTV_FOCUS_REQUEST_EVENT,
activateCctvCameraFromWorldClick,
} from '../cctvFocusRequest.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
}
});
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
const record = (id, feedType) => ({
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
billboard: {},
coverageEntities: [],
projection: null,
});
const idleVideo = record('idle-hls', 'hls');
const idleStill = record('idle-image', 'image');
const activeVideo = record('active-hls', 'hls');
const viewer = { entities: { add: (entity) => entity } };
try {
// Disabled keeps the active video camera from opening a real projection;
// icon colors are assigned either way.
_setCctvCoverageStateForTest({
viewer,
records: [idleVideo, idleStill, activeVideo],
activeCameraId: activeVideo.camera.id,
enabled: false,
coverageMode: 'off',
});
refreshCoverageStyles();
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
assert.deepEqual(
+222
View File
@@ -6,6 +6,7 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
const envSources = (prefix, count = 3) =>
JSON.stringify(
Array.from({ length: count }, (_, i) => ({
id: `${prefix}-${i}`,
name: `${prefix} ${i}`,
lat: 30 + i * 0.01,
lon: -97,
url: `https://example.test/${prefix}-${i}.jpg`,
})),
);
/** Let a background refresh that needs no timers run to completion. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
};
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
const saved = { ...process.env };
try {
// Live packs run alongside the env pack, so one slow upstream can hold
// the refresh open.
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'log', () => {});
const hung = [];
let hang = false;
t.mock.method(globalThis, 'fetch', () =>
hang
? new Promise((_, reject) => {
hung.push(() => reject(new Error('upstream gave up')));
})
: Promise.reject(new Error('offline')),
);
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
assert.deepEqual(
(await getSources()).map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Past the TTL, with every live pack's upstream hanging.
now += CCTV_SOURCE_CACHE_MS + 1;
hang = true;
process.env.CCTV_SOURCES_JSON = envSources('new');
let timer;
const served = await Promise.race([
getSources(),
new Promise((resolve) => {
timer = setTimeout(() => resolve('waited'), 200);
}),
]);
clearTimeout(timer);
assert.notEqual(
served,
'waited',
'a stale request waited for the background refresh',
);
assert.deepEqual(
served.map((s) => s.id),
['old-0', 'old-1', 'old-2'],
);
// Requests during the refresh share it rather than starting another.
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
await pause();
const inFlight = hung.length;
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
await getSources();
await getSources();
await pause();
assert.equal(hung.length, inFlight, 'only one refresh ran');
// When the slow pack gives up, the refreshed catalog replaces the old one.
hang = false;
for (const giveUp of hung) giveUp();
for (let i = 0; i < 50; i += 1) {
const ids = (await getSources()).map((s) => s.id);
if (ids[0] === 'new-0') break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(
(await getSources()).map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
} finally {
restoreEnv(saved);
}
});
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
const saved = { ...process.env };
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
process.env.CCTV_PREFER_AUSTIN = '0';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
process.env.CCTV_MAX_SOURCES = '8';
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
// Nine cameras over a cap of eight make every refresh warn; throwing from
// that warning is how this test makes a refresh reject.
let failRefresh = false;
let failedRefreshes = 0;
const warnings = [];
t.mock.method(console, 'warn', (...args) => {
const text = args.join(' ');
if (failRefresh && text.includes('exceeds cap')) {
failedRefreshes += 1;
throw new Error('refresh broke');
}
warnings.push(text);
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
assert.equal(old.length, 8);
// Past the TTL the refresh throws: the old catalog is still served.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = true;
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1);
assert.equal(
warnings.filter((w) => w.includes('background catalog refresh failed'))
.length,
1,
);
// The failure is not retried on every request until the next TTL.
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
// After it, a refresh that works replaces the catalog. With no live packs
// the refresh finishes before the request that started it returns.
now += CCTV_SOURCE_CACHE_MS + 1;
failRefresh = false;
process.env.CCTV_SOURCES_JSON = envSources('new');
await getSources();
await settle();
const fresh = await getSources();
assert.deepEqual(
fresh.map((s) => s.id),
['new-0', 'new-1', 'new-2'],
);
// Trim notes stay with the list they describe, so a request served the
// old list during the refresh never reports the new list's notes.
assert.deepEqual(
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
[['env', 9, 8]],
);
assert.deepEqual(getSources.trimmedPacks(fresh), []);
assert.deepEqual(getSources.trimmedPacks(), []);
assert.deepEqual(unhandled, []);
} finally {
process.off('unhandledRejection', onUnhandled);
restoreEnv(saved);
}
});
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
const saved = { ...process.env };
try {
process.env.CCTV_FORCE_AUSTIN = '1';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = envSources('old');
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let fetches = 0;
t.mock.method(globalThis, 'fetch', () => {
fetches += 1;
return Promise.reject(new Error('offline'));
});
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const old = await getSources();
// Past the TTL every pack comes back empty, as in an upstream outage.
now += CCTV_SOURCE_CACHE_MS + 1;
process.env.CCTV_SOURCES_JSON = '[]';
assert.equal(await getSources(), old);
for (let i = 0; i < 50; i += 1) {
if (warnings.some((w) => w.includes('source refresh returned empty')))
break;
await settle();
}
assert.ok(
warnings.some((w) => w.includes('serving 3 stale cameras')),
'the empty refresh kept the old catalog',
);
// Until the next TTL, requests are served without touching any upstream.
const fetchesAfterRefresh = fetches;
assert.equal(await getSources(), old);
assert.equal(await getSources(), old);
await settle();
assert.equal(
fetches,
fetchesAfterRefresh,
'an empty refresh reran at once',
);
} finally {
restoreEnv(saved);
}
});
+202
View File
@@ -1,11 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
TARKTEE_IMAGES_URL,
TARKTEE_LOCATIONS_URL,
TARKTEE_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
@@ -84,3 +96,193 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
quiet(t);
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="loc-1" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
<predefinedLocation id="loc-2" version="0">
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
</payloadPublication>
</d2LogicalModel>`;
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init?.redirect]);
const body =
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(seen, [
[TARKTEE_LOCATIONS_URL, 'manual'],
[TARKTEE_IMAGES_URL, 'manual'],
]);
assert.equal(cameras.length, 1);
assert.equal(cameras[0].id, 'ee-tarktee-42');
assert.equal(cameras[0].name, 'Tallinn Ringtee');
});
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
quiet(t);
const cancelled = { locations: false, images: false };
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
if (isLocations) cancelled.locations = true;
else cancelled.images = true;
},
});
return new Response(body, {
status: isLocations ? 302 : 200,
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
});
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
});
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
for (const delayedSibling of [false, true]) {
await t.test(
`${failedUrl}, delayed sibling: ${delayedSibling}`,
async (t) => {
quiet(t);
let cancelled = false;
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<xml/>'));
},
cancel() {
cancelled = true;
},
}),
);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === failedUrl) throw new TypeError('fetch failed');
if (delayedSibling) await new Promise(setImmediate);
return response;
});
try {
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
cancelled,
true,
'fulfilled sibling body is cancelled',
);
} finally {
await response.body.cancel();
}
},
);
}
}
});
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
quiet(t);
let siblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
return new Response('', { status: 503 });
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
siblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
siblingCancelled,
true,
'sibling 200 stream is cancelled on 503',
);
let overflowSiblingCancelled = false;
t.mock.method(globalThis, 'fetch', async (url) => {
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
if (isLocations) {
const padded =
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
return new Response(padded, {
status: 200,
headers: { 'Content-Length': String(padded.length) },
});
}
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
},
cancel() {
overflowSiblingCancelled = true;
},
});
return new Response(body, { status: 200 });
});
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
assert.equal(
overflowSiblingCancelled,
true,
'sibling stream is cancelled when the other feed exceeds the byte cap',
);
});
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
const timeouts = [];
const timeout = AbortSignal.timeout;
t.mock.method(AbortSignal, 'timeout', (ms) => {
timeouts.push(ms);
return timeout.call(AbortSignal, ms);
});
t.mock.method(console, 'log', () => {});
t.mock.method(
globalThis,
'fetch',
async (url) =>
new Response(
String(url).includes('roadCameraLocations')
? SAMPLE_LOCATIONS_XML
: SAMPLE_IMAGES_XML,
),
);
const cameras = await loadTarkteeSourcesFromDatex();
assert.deepEqual(timeouts, [30_000, 30_000]);
assert.equal(cameras.length, 1);
});
+376
View File
@@ -0,0 +1,376 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
floridaCameraId,
floridaCameraToSource,
loadFloridaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
FLORIDA_CCTV_URL,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
isLikelyFloridaCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
/** A response with a live body, plus a flag that flips when it is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One FL511 feature, shaped like the live FeatureServer payload. */
const feature = (overrides = {}) => ({
attributes: {
OBJECTID_1: 6,
ID: '1220',
DESCRIPT: 'I-75 @ MM 346.5 SB',
COUNTY: 'Marion',
HIGHWAY: 'I-75',
DIRECTION: 'S',
LATITUDE: 29.107363,
LONGITUDE: -82.188334,
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
...overrides,
},
});
/** One catalog page of `count` distinct cameras, channels from `start`. */
const page = (start, count, extra = {}) => ({
features: Array.from({ length: count }, (_, i) =>
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
),
...extra,
});
const offsetOf = (url) =>
Number(new URL(String(url)).searchParams.get('resultOffset'));
/** Lift the pack cap for one test so every loaded camera comes back. */
const uncapped = (t) => {
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
t.after(() => {
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
});
};
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
const source = floridaCameraToSource(feature());
assert.equal(source.id, 'fl-9431');
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
assert.equal(source.city, 'Marion County');
assert.equal(source.cityId, 'florida');
assert.equal(source.provider, 'FL511');
assert.equal(source.lat, 29.107363);
assert.equal(source.lon, -82.188334);
assert.equal(source.headingDeg, 180);
assert.equal(
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
90,
);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.feedType, 'image');
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
assert.equal(source.url, source.snapshotUrl);
assert.equal(source.sourceKind, 'fl511-open-data');
assert.match(source.license, /personal use only/);
assert.match(source.license, /written consent/);
});
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
const source = floridaCameraToSource(
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
});
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
const dropped = [
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
feature({ LATITUDE: 0, LONGITUDE: 0 }),
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
feature({ LATITUDE: '', LONGITUDE: '' }),
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
feature({
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
}),
feature({ IMAGE: 'not a url' }),
{ attributes: null },
null,
];
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
});
test('camera ids come from the image channel, not the repeating ID column', () => {
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
const marion = floridaCameraToSource(feature());
const broward = floridaCameraToSource(
feature({
DESCRIPT: 'I-95 SB before E Cypress Rd',
COUNTY: 'Broward',
LATITUDE: 26.22006,
LONGITUDE: -80.13676,
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
}),
);
assert.equal(marion.id, 'fl-9431');
assert.equal(broward.id, 'fl-6351');
// Any resolution suffix keys the same way.
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
// A filename without a channel falls back to a stable path slug.
assert.equal(
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
'fl-us-1-20at-20sr-5',
);
assert.equal(floridaCameraId('not a url'), null);
});
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
const inside = [
[24.5551, -81.78], // Key West
[30.4213, -87.2169], // Pensacola
[30.99, -84.0], // Georgia line
[25.7617, -80.1918], // Miami
];
const outside = [
[33.749, -84.388], // Atlanta
[25.0443, -77.3504], // Nassau
[0, 0],
[-81.78, 24.5551], // swapped
[NaN, -81],
];
for (const [lat, lon] of inside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
for (const [lat, lon] of outside)
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
});
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
calls.push([offsetOf(url), init.redirect]);
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
// Last page (no exceededTransferLimit): two new cameras and a repeat.
return Response.json(page(1999, 3));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(calls, [
[0, 'manual'],
[2000, 'manual'],
]);
assert.equal(cameras.length, 2002);
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
});
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
t.mock.method(console, 'log', () => {});
uncapped(t);
const offsets = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const params = new URL(String(url)).searchParams;
// Offset paging is only stable over a fixed order on the object id.
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
const offset = offsetOf(url);
offsets.push(offset);
// A layer whose max record count was lowered to 1,000.
return Response.json(
page(offset, offset < 2000 ? 1000 : 500, {
exceededTransferLimit: offset < 2000,
}),
);
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.deepEqual(offsets, [0, 1000, 2000]);
assert.equal(cameras.length, 2500);
});
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
t.mock.method(console, 'log', () => {});
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
uncapped(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
});
const cameras = await loadFloridaSourcesFromOpenData();
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
assert.equal(cameras.length, 2000);
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
});
test('a redirect on any page drops the whole pack and releases it', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras' },
});
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: redirected.response,
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
});
test('a failed later page keeps the pages already read', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
uncapped(t);
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async (url) =>
offsetOf(url) === 0
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
: failed.response,
);
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
// A network error on a later page keeps them too.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (offsetOf(url) === 0)
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
throw new Error('network down');
});
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
});
test('an oversized catalog page is refused', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(JSON.stringify(page(0, 1)), {
headers: {
'Content-Type': 'application/json',
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
},
}),
);
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
// So is a body that only turns out too long while streaming.
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
});
/**
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
* to every other pack, so one catalog refresh exercises the registration
* without reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
return Response.json([]);
});
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Put process.env back exactly as it was. */
const restoreEnv = (saved) => {
for (const key of Object.keys(process.env)) {
if (key in saved) continue;
delete process.env[key];
}
Object.assign(process.env, saved);
};
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_FLORIDA_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
'the catalog refresh invokes the Florida loader',
);
assert.deepEqual(
sources
.filter((s) => s.cityId === 'florida')
.map((s) => s.id)
.sort(),
['fl-0', 'fl-1'],
'Florida cameras reach the served catalog through the registered lane',
);
} finally {
restoreEnv(saved);
}
});
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_FLORIDA_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'florida'),
[],
);
} finally {
restoreEnv(saved);
}
});
+464
View File
@@ -0,0 +1,464 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
hkCameraName,
hkCameraToSource,
hkImageUrlFromKey,
loadHkSourcesFromOpenData,
normalizeHkImageUrl,
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_IMAGE_ORIGIN,
HK_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
prioritizeSources,
} from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<image-list>'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Transport Department `<image>` block as a parsed row. */
const row = (overrides = {}) => ({
key: 'BC101F',
region: 'New Territories',
district: 'North',
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
latitude: '22.508',
longitude: '114.078',
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
...overrides,
});
/** Fixture XML matching the published English locations schema. */
const fixtureXml = (...records) => {
const images = records
.map(
(record) => `
<image>
<key>${record.key}</key>
<region>${record.region}</region>
<district>${record.district}</district>
<description>${record.description}</description>
<latitude>${record.latitude}</latitude>
<longitude>${record.longitude}</longitude>
<url>${record.url}</url>
</image>`,
)
.join('');
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
};
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
const source = hkCameraToSource(row());
assert.equal(source.id, 'hk-bc101f');
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
assert.equal(source.cityId, 'hk');
assert.equal(source.provider, 'Transport Department');
assert.equal(source.lat, 22.508);
assert.equal(source.lon, 114.078);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'hk-td-open-data');
assert.equal(
source.license,
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
);
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
});
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
assert.equal(
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
null,
);
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
assert.equal(hkCameraToSource(row({ key: '' })), null);
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
assert.equal(
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
null,
);
assert.equal(
hkCameraToSource(
row({
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
}),
),
null,
);
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
assert.equal(hkCameraToSource(null), null);
});
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
assert.equal(
hkImageUrlFromKey('bc101f'),
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
);
assert.equal(hkImageUrlFromKey(''), null);
assert.equal(hkImageUrlFromKey('bad/key'), null);
assert.equal(
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
'https://tdcctv.data.one.gov.hk/H429F.JPG',
);
assert.equal(normalizeHkImageUrl(''), null);
assert.equal(normalizeHkImageUrl('not a url'), null);
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
hkCameraName(
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
'hk-h429f',
),
'Aberdeen Praya Road near Fish Market [H429F]',
);
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
});
test('the XML parser reads each image block', () => {
const rows = parseHkCameraLocationsXml(
fixtureXml(
row(),
row({
key: 'H429F',
region: 'Hong Kong Island',
district: 'Southern',
description: 'Aberdeen Praya Road near Fish Market [H429F]',
latitude: '22.24845',
longitude: '114.1505',
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
}),
),
);
assert.equal(rows.length, 2);
assert.equal(rows[0].key, 'BC101F');
assert.equal(rows[1].key, 'H429F');
assert.equal(rows[1].latitude, '22.24845');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return new Response(
fixtureXml(
row(),
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
row({ url: 'https://evil.example/x.jpg' }),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
const cameras = await loadHkSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest Central/TST first: Wan Chai before the northern New Territories.
['hk-h210f', 'hk-bc101f'],
);
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('the unselected label is the description, trimmed to the code width', () => {
assert.equal(
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
'SAN TIN HIGHWAY [BC101F]',
);
const long = hkCameraToSource(
row({
description:
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.xml' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = fixtureXml(row());
return new Response(body, {
headers: {
'Content-Type': 'application/xml',
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[
lane('austin', 250),
lane('calgary', 215),
lane('hk', DEFAULT_HK_MAX_SOURCES),
],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['calgary', 10],
['hk', 10],
],
);
assert.deepEqual(
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
);
});
/**
* Serve the HK catalog to the HK endpoint and an empty payload to every other
* pack, so one catalog refresh exercises the registration without reaching the
* network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.includes('Traffic_Camera_Locations_En.xml')) {
return new Response(
fixtureXml(
row(),
row({
key: 'H210F',
region: 'Hong Kong Island',
district: 'Wan Chai',
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
latitude: '22.2747',
longitude: '114.17936',
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
}),
),
{ headers: { 'Content-Type': 'application/xml' } },
);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_HK_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_HK_CAMERAS_URL),
'the catalog refresh invokes the HK loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
['hk-h210f', 'hk-bc101f'],
'HK cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HK_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_HK_CAMERAS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'hk'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
assert.ok(
DEFAULT_HK_MAX_SOURCES >= 1013,
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
);
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
});
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
const cameras = [
hkCameraToSource(
row({
key: 'H001F',
region: 'Hong Kong Island',
district: 'Central & Western',
description: 'Central harbour [H001F]',
latitude: '22.2819',
longitude: '114.158',
}),
),
hkCameraToSource(
row({
key: 'ST101F',
region: 'New Territories',
district: 'Sha Tin',
description: 'Sha Tin Road [ST101F]',
latitude: '22.3827',
longitude: '114.19',
}),
),
hkCameraToSource(
row({
key: 'K999F',
region: 'Kowloon',
district: 'Yau Tsim Mong',
description: 'Far Kowloon filler [K999F]',
latitude: '22.32',
longitude: '114.18',
}),
),
].filter(Boolean);
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
'hk-h001f',
'hk-st101f',
]);
});
+37
View File
@@ -223,3 +223,40 @@ test('an abandoned consumer expires while a renewed consumer keeps the session',
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
test('client lease ownership covers refresh, segments and release, with a bounded client budget', async (t) => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, leasesPerClient: 1, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
t.after(() => manager.shutdown());
const entry = await manager.ensure('a', base, 'lease-a', 'peer-a');
await manager.waitReady(entry);
await assert.rejects(
manager.ensure('a', base, 'lease-a', 'peer-b'),
/another client/,
);
assert.equal(
manager.getSegment('a', entry.token, 0, 'lease-a', 'peer-b'),
null,
);
await assert.rejects(
manager.buildPlaylist(entry, 'a', 'lease-a', 'peer-b'),
/another client/,
);
manager.release('a', 'lease-a', 'peer-b');
assert.equal(entry.leases.size, 1);
await assert.rejects(
manager.ensure('b', base, 'lease-b', 'peer-a'),
/client lease capacity/,
);
assert.equal(
manager.stats().sessions,
1,
'failed admission reserves no session',
);
await manager.ensure('b', base, 'lease-b', 'peer-b');
manager.release('a', 'lease-a', 'peer-a');
assert.equal(entry.stopping, true);
});
+43
View File
@@ -268,3 +268,46 @@ test('/frame does not follow a still camera off its origin', async (t) => {
[STILL.url],
);
});
test('cross-site media creation, segment reads and release fail before any catalog or media fetch', async (t) => {
let handler;
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls++;
throw new Error('must not fetch');
};
t.after(() => {
globalThis.fetch = originalFetch;
});
cctvProxy().configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
for (const headers of [
{ origin: 'https://foreign.example' },
{ origin: 'null' },
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
]) {
for (const [method, url] of [
['GET', '/media/camera?lease=test'],
['DELETE', '/media/camera?lease=test'],
['GET', '/media/camera/seg_0.ts?lease=test'],
]) {
const { res } = recordingResponse();
res.setHeader = (name, value) => {
res.headers[name] = value;
};
await handler(
{ method, url, headers: { host: 'localhost:4173', ...headers } },
res,
);
assert.equal(res.statusCode, 403);
}
}
assert.equal(calls, 0);
});
+342
View File
@@ -0,0 +1,342 @@
import { afterEach, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTaiwanSources,
parseTaiwanCctvXml,
resetTaiwanSourcesCache,
taiwanCameraToSource,
taiwanFrame,
TAIWAN_FREEWAY_CCTV_URL,
TAIWAN_MAX_CATALOG_BYTES,
TAIWAN_RETRY_AFTER_FAILURE_MS,
} from '../../server/providers/cctv/taiwan.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
// Settings that would steer the loader or the catalog away from the mocks.
const ISOLATED_ENV = [
'CCTV_SOURCES_FILE',
'CCTV_SOURCES_JSON',
'CCTV_PREFER_AUSTIN',
'CCTV_FORCE_AUSTIN',
'CCTV_TAIWAN_ENABLED',
'CCTV_TAIWAN_URL',
'CCTV_TAIWAN_MAX_SOURCES',
];
let savedEnv = {};
beforeEach(() => {
resetTaiwanSourcesCache();
savedEnv = Object.fromEntries(
ISOLATED_ENV.map((key) => [key, process.env[key]]),
);
for (const key of ISOLATED_ENV) delete process.env[key];
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function cctvXml(...blocks) {
return `<?xml version='1.0' encoding='utf-8'?>
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
<AuthorityCode>NFB</AuthorityCode>
<CCTVs>${blocks.join('')}</CCTVs>
</CCTVList>`;
}
function block({
id = 'CCTV-N1-S-0.000-M',
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
lat = '25.1229931',
lon = '121.735695',
} = {}) {
return `<CCTV>
<CCTVID>${id}</CCTVID>
<VideoStreamURL>${stream}</VideoStreamURL>
<PositionLon>${lon}</PositionLon>
<PositionLat>${lat}</PositionLat>
<RoadName>國道1號</RoadName>
<RoadDirection>S</RoadDirection>
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
<LocationMile>0K+000</LocationMile>
</CCTV>`;
}
test('parses the MOTC 2.0 table and registers the same camera still', () => {
const [row] = parseTaiwanCctvXml(cctvXml(block()));
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(camera.lat, 25.1229931);
assert.equal(camera.lon, 121.735695);
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
assert.equal(camera.cityId, 'taiwan');
assert.equal(camera.feedType, 'image');
assert.equal(camera.headingConfidence, 'low');
assert.equal(
camera.url,
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
);
assert.equal(camera.snapshotUrl, camera.url);
assert.match(camera.license, /Open Government Data License, Taiwan/);
});
test('each camera server maps its stream path to its own still path only', () => {
assert.equal(
taiwanFrame(
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
).frameUrl,
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
);
assert.equal(
taiwanFrame(
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
).frameUrl,
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.equal(
taiwanFrame(
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
).frameUrl,
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
);
});
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
for (const stream of [
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
'http://127.0.0.1/private',
'',
]) {
assert.equal(taiwanFrame(stream), null, stream);
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
assert.equal(taiwanCameraToSource(row), null, stream);
}
});
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
for (const [lat, lon] of [
['121.735695', '25.1229931'],
['35.68', '139.76'],
['', '121.7'],
['NaN', '121.7'],
]) {
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
}
assert.equal(taiwanCameraToSource(null), null);
});
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
const [row] = parseTaiwanCctvXml(
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&amp;(北)' })),
);
const camera = taiwanCameraToSource(row);
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
});
function mockCatalog(t, xml) {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
requested.push(String(url));
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
assert.equal(options.redirect, 'manual');
return new Response(xml, {
headers: { 'content-type': 'application/xml' },
});
}
return Response.json([]);
});
return requested;
}
test('loader deduplicates cameras that share a camera number', async (t) => {
mockCatalog(
t,
cctvXml(
block(),
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
block({
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
lat: '24.1',
lon: '120.6',
}),
),
);
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
'tw-freeway-cctvc-495',
'tw-freeway-cctvn-10000',
]);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
t.mock.method(
globalThis,
'fetch',
async () =>
new Response(cctvXml(block()), {
status,
headers: status === 302 ? { location: 'https://evil.test/' } : {},
}),
);
resetTaiwanSourcesCache();
assert.deepEqual(await loadTaiwanSources(), []);
}
});
async function runCatalogWithTaiwanFlag(t, flag) {
const requested = mockCatalog(t, cctvXml(block()));
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
}
test('the Taiwan pack is in the catalog by default', async (t) => {
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
assert.equal(
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
'taiwan',
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block()) + padding),
);
assert.deepEqual(await loadTaiwanSources(), []);
resetTaiwanSourcesCache();
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('fetch failed');
});
assert.deepEqual(await loadTaiwanSources(), []);
});
test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(console, 'warn', () => {});
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
throw new TypeError('fetch failed');
});
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(block())),
);
const first = await loadTaiwanSources();
assert.deepEqual(
first.map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
fetchMock.mock.mockImplementation(
async () => new Response('', { status: 503 }),
);
assert.deepEqual(
(await loadTaiwanSources()).map((c) => c.id),
['tw-freeway-cctvn-10000'],
);
});
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
t.mock.method(console, 'log', () => {});
const rows = Array.from({ length: 3 }, (_, i) =>
block({
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
}),
);
t.mock.method(
globalThis,
'fetch',
async () => new Response(cctvXml(...rows)),
);
for (const [value, expected] of [
['0', 1],
['2', 2],
['5000', 3],
['abc', 3],
]) {
resetTaiwanSourcesCache();
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
const sources = await loadTaiwanSources();
assert.equal(sources.length, expected, value);
assert.equal(sources.available, 3, value);
}
});
test('names fall back to the published ID when the row has no road details', () => {
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
.replace('<LocationMile>0K+000</LocationMile>', '')
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
assert.equal(camera.name, 'CCTV-N3-N-100-M');
});
test('numeric character references in the table are decoded', () => {
const xml = cctvXml(block()).replace(
'<RoadName>國道1號</RoadName>',
'<RoadName>&#22283;&#x9053;1號</RoadName>',
);
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
});
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
const camera = taiwanCameraToSource(
parseTaiwanCctvXml(
cctvXml(
block({
stream:
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
}),
),
)[0],
);
assert.equal(
camera.id,
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
);
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
});
+621 -1
View File
@@ -2,11 +2,24 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
applyVegvesenGroundHeights,
clearKartverketHeightCache,
fetchKartverketHeights,
kartverketBackoffMs,
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
KARTVERKET_HEIGHT_URL,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_CONCURRENCY,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_POINTS,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_USER_AGENT,
CCTV_SOURCE_CACHE_MS,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
});
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
clearKartverketHeightCache();
t.after(clearKartverketHeightCache);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
});
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
// The catalog first; the rest is the Kartverket height lookup.
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
// Two unique positions: one Kartverket request.
assert.equal(requested.length, 2);
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
// Nearest-to-anchor first: Oslo leads.
assert.deepEqual(
cameras.map((c) => c.id),
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
);
});
});
/** Answer a Kartverket request the way the live API does: echo every point,
* with the height `heightFor(x, y)` returns (null = outside coverage). */
const kartverketResponse = (url, heightFor) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
});
};
/** Cameras at distinct Norwegian positions. */
const cameraAt = (i) => ({
id: `no-vegvesen-${i}`,
lat: 60 + i * 0.001,
lon: 7 + i * 0.001,
groundElevationM: 150,
});
const withHeightsEnv = (t) => {
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
delete process.env.CCTV_VEGVESEN_HEIGHTS;
clearKartverketHeightCache();
t.after(() => {
clearKartverketHeightCache();
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
};
const sentPoints = (url) =>
JSON.parse(new URL(url).searchParams.get('punkter'));
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push(String(url));
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
// Redirects are refused, so a request can never leave the fixed host.
assert.equal(init.redirect, 'error');
// Reply in reverse order: matching must not depend on list position.
const response = kartverketResponse(url, (x) =>
x > 7.04 ? null : Math.round((x - 7) * 10000),
);
const body = await response.json();
body.punkter.reverse();
return Response.json(body);
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
// One pass, no retries: 20 + 20 + 10.
assert.deepEqual(
requested.map((url) => sentPoints(url).length),
[KARTVERKET_MAX_POINTS, 20, 10],
);
for (const url of requested) {
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
}
assert.equal(heights.size, 50);
assert.equal(heights.get('7.010000,60.010000'), 100);
// Past x = 7.04 the API has no height: recorded as null, never as 0.
assert.equal(heights.get('7.049000,60.049000'), null);
});
test('only the points a request asked about are recorded', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
...points.map(([x, y]) => ({ x, y, z: 10 })),
{ x: 7.5, y: 60.5, z: 999 }, // never asked
],
});
});
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
});
test('either water marker counts as the surface', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const [a, b, c] = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
{
x: a[0],
y: a[1],
z: -547.1,
datakilde: 'dybdekurver',
terreng: null,
},
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
{
x: c[0],
y: c[1],
z: -0.2,
datakilde: 'dtm1',
terreng: 'ÅpentOmråde',
},
],
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
]);
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), 0);
// A quay just below the datum on land data stays as measured.
assert.equal(heights.get('7.002000,60.002000'), -0.2);
});
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
const zs = [-20, -20.5, 2500, 2500.5];
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
cameraAt(3),
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
]);
assert.equal(requested.length, 1);
assert.equal(sentPoints(requested[0]).length, 4);
assert.deepEqual(
[0, 1, 2, 3].map((i) =>
heights.get(
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
),
),
[-20, null, 2500, null],
);
});
test('no more than eight requests are in flight', async (t) => {
withHeightsEnv(t);
let inFlight = 0;
let peak = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return kartverketResponse(url, () => 1);
});
const { heights } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(heights.size, 400);
assert.equal(peak, KARTVERKET_CONCURRENCY);
});
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('busy', {
status: 429,
headers: { 'Retry-After': '120' },
});
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
// The first wave is already in flight when the first failure lands; nothing
// after it starts, and no point is resent.
assert.equal(calls, KARTVERKET_CONCURRENCY);
assert.equal(heights.size, 0);
assert.deepEqual(failure, {
status: 429,
retryAfter: '120',
deadline: false,
});
});
/** A fetch that never answers, and rejects only when its signal aborts. */
const hangingFetch = (url, init) => {
assert.ok(init.signal instanceof AbortSignal);
return new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason));
});
};
test('the lookup deadline bounds a hanging service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const started = Date.now();
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 30,
});
const elapsed = Date.now() - started;
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
assert.equal(heights.size, 0);
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
});
test('one hanging request times out on its own, as a failing service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 60_000,
requestTimeoutMs: 20,
});
assert.deepEqual(failure, {
status: null,
retryAfter: null,
deadline: false,
});
});
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', (url, init) => {
calls += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(kartverketResponse(url, () => 1)),
40,
);
init.signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(init.signal.reason);
});
});
});
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
cameraAt(i),
),
{ timeoutMs: 100 },
);
assert.equal(failure?.deadline, true);
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
});
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
withHeightsEnv(t);
const answers = {
503: () => new Response('down', { status: 503 }),
'not json': () =>
new Response('<html>maintenance</html>', {
headers: { 'Content-Type': 'text/html' },
}),
oversized: () =>
Response.json({
punkter: [],
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
}),
};
for (const [label, answer] of Object.entries(answers)) {
clearKartverketHeightCache();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return answer();
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
assert.equal(heights.size, 0, label);
assert.equal(failure?.deadline, false, label);
assert.equal(failure?.status, label === '503' ? 503 : null, label);
}
});
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
sentPoints(url)[0][0] === 7
? new Response('bad point', { status: 400 })
: kartverketResponse(url, () => 42),
);
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
assert.equal(heights.size, 40);
assert.equal(heights.get('7.000000,60.000000'), null);
assert.equal(heights.get('7.020000,60.020000'), 42);
});
test('a point a 200 answer leaves out is cached as no height', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url).slice(1); // drop the first point
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
});
});
const cameras = [cameraAt(0), cameraAt(1)];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[0].groundElevationM, 150);
// The next refresh asks nothing: the left-out point is a cached miss.
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
assert.equal(kartverketBackoffMs('120', now), 120_000);
assert.equal(kartverketBackoffMs('5', now), 60_000);
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
assert.equal(
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
300_000,
);
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
});
test('heights are cached, and misses are not asked again until they expire', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(sentPoints(url).length);
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
});
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
assert.deepEqual(
cameras.map((c) => c.groundElevationM),
[1043.3, 1043.3, 150],
);
// The next refresh asks nothing: heights and the miss are both cached.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
now,
}),
2,
);
assert.deepEqual(asked, [3]);
// Once the miss expires, only that point is asked again.
clock += KARTVERKET_NULL_TTL_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
assert.deepEqual(asked, [3, 1]);
});
test('after a failure no lookup starts until the backoff ends', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('offline');
});
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 1);
// The default backoff outlasts the catalog refresh, so the next refresh
// and every one inside the backoff cost nothing.
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
clock += CCTV_SOURCE_CACHE_MS;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
assert.equal(fetchMock.mock.callCount(), 1);
// After it, the failed point is asked again.
clock += 2;
fetchMock.mock.mockImplementation(async (url) =>
kartverketResponse(url, () => 321),
);
const later = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
assert.equal(later[0].groundElevationM, 321);
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a 429 backs off for its Retry-After, not the default', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const retryAfterS = 50 * 60;
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () =>
new Response('busy', {
status: 429,
headers: { 'Retry-After': String(retryAfterS) },
}),
);
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
t.mock.method(globalThis, 'fetch', hangingFetch);
// The lookup runs out of time; that is not a failing service.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0)], {
now,
timeoutMs: 10,
waitMs: 1000,
}),
0,
);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 64),
);
clock += CCTV_SOURCE_CACHE_MS;
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
assert.equal(cameras[0].groundElevationM, 64);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('overlapping refreshes share one lookup', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 3);
});
await Promise.all([
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
]);
assert.equal(fetchMock.mock.callCount(), 1);
release();
});
test('a warm cache does not wait for a few new cameras', async (t) => {
withHeightsEnv(t);
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 500),
);
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 600);
});
const refresh = [...cameras, cameraAt(20)];
const started = Date.now();
assert.equal(
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
20,
);
assert.ok(Date.now() - started < 1000);
assert.equal(fetchMock.mock.callCount(), 1);
// The background lookup fills the cache for the next refresh.
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [...cameras, cameraAt(20)];
assert.equal(await applyVegvesenGroundHeights(next), 21);
assert.equal(next[20].groundElevationM, 600);
});
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 9),
);
const cameras = [
{ id: 'no-coordinates' },
null,
{ id: 'strings', lat: '60', lon: '7' },
cameraAt(0),
];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[3].groundElevationM, 9);
});
test('a lookup started before a cache clear cannot write after it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 111);
});
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
clearKartverketHeightCache();
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 222),
);
const fresh = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
assert.equal(fresh[0].groundElevationM, 222);
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const later = [cameraAt(0)];
await applyVegvesenGroundHeights(later, { waitMs: 0 });
assert.equal(later[0].groundElevationM, 222);
});
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 5),
);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 0);
});
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 812);
});
const first = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
assert.equal(first[0].groundElevationM, 150);
release();
// The lookup kept running and filled the cache for the next refresh.
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
assert.equal(next[0].groundElevationM, 812);
});
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return kartverketResponse(url, () => 987.6);
}
return Response.json({ type: 'FeatureCollection', features: [feature()] });
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(
cameras.map((c) => [c.id, c.groundElevationM]),
[['no-vegvesen-3000063_1', 987.6]],
);
});
test('the loader budgets the height wait from its own start', async (t) => {
withHeightsEnv(t);
const realNow = Date.now.bind(Date);
let offset = 0;
t.mock.method(Date, 'now', () => realNow() + offset);
t.mock.method(globalThis, 'fetch', (url, init) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return hangingFetch(url, init);
}
// The catalog fetch uses up all but 10 ms of the lane's budget.
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [feature()] }),
);
});
const started = performance.now();
const cameras = await loadVegvesenSourcesFromOpenData();
assert.ok(performance.now() - started < 1000);
assert.equal(cameras[0].groundElevationM, 150);
});
+45 -2
View File
@@ -239,7 +239,8 @@ export const DATA_CREDITS = [
html:
'CCTV cameras &amp; frames (Ontario): ' +
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
'· provided as is, without warranty; actual road conditions may differ',
},
{
key: 'fintraffic-cctv',
@@ -257,12 +258,28 @@ export const DATA_CREDITS = [
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
},
{
key: 'taiwan-cctv',
html:
'Freeway camera stills (Taiwan): ' +
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'florida-cctv',
html:
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
'Individual, personal use only under the ' +
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
"any other use needs FDOT's express written consent",
},
{
key: 'catalonia-cctv',
html:
@@ -280,12 +297,22 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'hk-cctv',
html:
'Traffic snapshot cameras (Hong Kong): ' +
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
},
{
key: 'vegvesen-cctv',
html:
'Road cameras (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
'distributed by Statens vegvesen · camera ground heights © ' +
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'modified: heights over water set to 0 m and rounded to 0.1 m',
},
{
key: 'nyc-cctv',
@@ -300,6 +327,22 @@ export const DATA_CREDITS = [
'Traffic cameras (Toronto): contains information licensed under the ' +
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
},
{
key: 'mapillary',
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'google-street-view',
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
},
{
key: 'kartaview',
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'gbfs',
html:
+13
View File
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
'google-street-view',
'kartaview',
'panoramax',
])
assert.ok(
DATA_CREDITS.some((entry) => entry.key === key),
`${key} has no credit`,
);
});
+28 -21
View File
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
return index;
});
function toResult(entry) {
function toResult(entry, candidates = 1) {
return {
name: entry.name,
featurecla: entry.featurecla,
@@ -197,6 +197,7 @@ function toResult(entry) {
polygons: entry.polygons,
bboxDiagonalKm: entry.bboxDiagonalKm,
areaKm2: entry.areaKm2,
candidates,
};
}
@@ -209,7 +210,9 @@ function toResult(entry) {
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
* areaKm2:number, candidates:number}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. Uniqueness in
* this pack alone does not establish the user's intended place.
*/
export async function findNaturalRegion(query) {
const norm = normalizeName(query);
@@ -223,7 +226,7 @@ export async function findNaturalRegion(query) {
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
if (list && list.length) return toResult(list[0]);
if (list && list.length) return toResult(list[0], list.length);
}
return null;
}
@@ -270,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
/**
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
* matched name (duplicate names included — the US and Spanish "Sierra
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
* geocoded anchor. Containment is simultaneously the disambiguator and the
* matched name (duplicate names included), trying exact names, aliases and
* broader suffix variants in that order. Returns a ring only when exactly one
* entry under the matched name CONTAINS the anchor. Containment is the
* disambiguator and the
* wrong-place guard: when no ring contains the anchor this returns null and
* the resolver's normal ladder continues unchanged.
*
@@ -291,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
ALIASES[norm],
...suffixVariants(ALIASES[norm] || norm),
];
const seen = new Set();
for (const key of candidates) {
if (!key || seen.has(key)) continue;
seen.add(key);
for (const key of new Set(candidates.filter(Boolean))) {
const containing = [];
for (const entry of index.get(key) || []) {
for (const ring of entry.polygons) {
if (pointInRing(ring, lat, lon)) {
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
}
const ring = entry.polygons.find((candidate) =>
pointInRing(candidate, lat, lon),
);
if (ring) containing.push({ entry, ring });
}
// Specific names win over aliases and broader rewrites. Ambiguity belongs
// to this name, not the union of every lower-priority candidate name.
if (containing.length > 1) return null;
if (containing.length === 0) continue;
const [{ entry, ring }] = containing;
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
return null;
}
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more