Compare commits

...
Author SHA1 Message Date
Bilawal Sidhu 379c2aaa3c Wait for existing style transitions in scope browser checks 2026-10-25 23:55:28 -05:00
Bilawal Sidhu 40b471f1e3 Show HUD backplates only with scope off and preserve original text styling 2026-10-25 23:48:00 -05:00
Bilawal Sidhu cfbdf22121 Refine HUD backings and default scope feather to 100 percent 2026-10-25 23:37:43 -05:00
Bilawal Sidhu d8c264b501 docs: add scope and HUD changelog entry 2026-10-25 17:51:50 -05:00
Bilawal Sidhu c3e782ef73 feat: add scope checkbox and improve HUD text readability 2026-10-25 17:46:16 -05:00
Said Turksever 2a3e0d669f Merge pull request #1395 from rudycelekli/fix/fires-tool-acquired-time 2026-10-25 21:32:37 +00:00
Sameh Khamis 9b52e83fd7 Merge main into PR #1395 2026-10-25 14:14:33 -07:00
Sameh Khamis 8886e063e6 fix(local-adsb): show a 359.7° track as TRK 0°, not TRK 360° (#1396)
Wrap rounded local ADS-B track labels at north.

Contribution by @rudycelekli.
2026-10-25 14:14:28 -07:00
Sameh Khamis 6642176de5 docs: document Local ADS-B track rounding 2026-10-25 14:08:32 -07:00
Sameh Khamis 3de745bd33 fix(fires): reject rolled-over acquisition dates and document UTC times 2026-10-25 14:08:25 -07:00
Sameh Khamis 9aeeb37ccf Merge PR #1396 contribution for review 2026-10-25 14:07:13 -07:00
Sameh Khamis 5ae18032fc Merge PR #1395 contribution for review 2026-10-25 14:07:10 -07:00
Rudy Celekli 224d71753b fix(fires): parse the detection time inside the tools package
The tools package cannot import firmsCsv. Pad the FIRMS clock here so 00:45 UTC still reads as 00:45.
2026-10-25 16:01:26 -04:00
Rudy Celekli 315c0f5dc6 fix(local-adsb): show a 359.7° track as TRK 0°, not TRK 360°
The Local ADS-B card rounded the track without wrapping, so an aircraft
tracking 359.5° or more read "TRK 360°". The rounded track now wraps
to 0°.
2026-10-25 15:58:46 -04:00
Rudy Celekli 57df5f61ba fix(fires): report a 00:45 UTC detection as 00:45, not "45"
get_active_fires joined FIRMS acq_date and the unpadded acq_time, so a
detection at 00:45 UTC came back as "2026-01-01 45". The row now uses
acquisitionMsUtc, which pads the time, and reports an ISO UTC timestamp
like the other tool times.
2026-10-25 15:58:45 -04:00
Said Turksever ac8dc84f3c docs: require screenshots, recording, category and data licence on new-layer PRs (#957)
Document visual evidence, category and data licensing requirements for new layers, using the changelog fragment workflow.

Contribution by @asturksever.
2026-10-25 12:43:01 -07:00
quanticsoul4772 69fba2ebbe fix(perf): free a turned-off layer's entities that Cesium keeps (#1367)
Release stale Cesium command references after disabling a layer while preserving current-frame commands.

Contribution by @quanticsoul4772.
2026-10-25 12:36:50 -07:00
Sameh Khamis 405c7f0d20 fix(cockpit): show wind from 359.6° as 0°, not 360° (#1389)
Wrap rounded cockpit wind direction to zero degrees at north.

Contribution by @rudycelekli.
2026-10-25 12:30:37 -07:00
Sameh Khamis e6e8bb0e95 Merge main into PR #1389 2026-10-25 12:26:21 -07:00
Sameh Khamis db74129eb0 fix(draw): show a 999.6 m line as 1.0 km, not 1000 m (#1388)
Select drawn-line distance units after rounding metres.

Contribution by @rudycelekli.
2026-10-25 12:26:14 -07:00
Sameh Khamis 8f02541657 docs: document PR #1389 rounding behavior 2026-10-25 12:11:49 -07:00
Sameh Khamis 1244ec1332 Merge PR #1389 contribution for review 2026-10-25 12:11:49 -07:00
Sameh Khamis 6ebdee6710 docs: document PR #1388 rounding behavior 2026-10-25 12:11:19 -07:00
Sameh Khamis 1031569f47 Merge PR #1388 contribution for review 2026-10-25 12:11:19 -07:00
Said Turksever 769ecbfe98 Merge branch 'main' into fix/cockpit-wind-north 2026-10-25 18:51:44 +00:00
Rudy Celekli 5ef12391e1 fix(cockpit): show wind from 359.6° as 0°, not 360°
formatCockpitWindDirection rounded the bearing and printed that number, so wind from 359.5° or more read "N · 360°". Take the rounded degrees modulo 360 so north reads 0°.
2026-10-25 12:32:18 -04:00
Rudy Celekli 99429904d4 fix(draw): show a 999.6 m line as 1.0 km, not 1000 m
formatMeasure checked the raw length against 1000 but printed it rounded, so a drawn line of 999.5 m or more read "1000 m" in the hint. Compare the rounded metres so it moves to kilometres.
2026-10-25 12:32:10 -04:00
quanticsoul4772 496d024431 fix(hud): drop a summary reply once the camera starts moving (#1384)
Discard stale HUD summaries when camera movement starts and allow cancelled requests to retry for unchanged place context.

Contribution by @quanticsoul4772.
2026-10-25 09:18:22 -07:00
Sameh Khamis 5b6c52437e fix(awareness): show a 999.6 m contact as 1.0 km, not 1000 m (#1387)
Choose awareness distance units after rounding so nearby contacts never display as 1000 metres.

Contribution by @rudycelekli.
2026-10-25 09:07:36 -07:00
Sameh Khamis ba783b4eea Merge main into PR #1387 2026-10-25 09:01:32 -07:00
Sameh Khamis 81af549ad7 fix(directions): show a 996 m leg as 1.0 km, not 1000 m (#1386)
Choose route distance units after rounding so near-kilometre legs display in kilometres.

Contribution by @rudycelekli.
2026-10-25 09:01:26 -07:00
Sameh Khamis 3cd795527c Merge main into PR #1386 2026-10-25 08:55:23 -07:00
Sameh Khamis 3f9b44ecc8 fix(directions): show a 59.6-second leg as 1 min, not 60 s (#1385)
Choose route duration units after rounding so near-minute legs display as one minute.

Contribution by @rudycelekli.
2026-10-25 08:55:17 -07:00
Sameh Khamis 48e649a95e docs(directions): scope the distance rounding threshold precisely 2026-10-25 08:50:11 -07:00
Sameh Khamis 455dbf2b5f docs: clarify unit transition examples 2026-10-25 08:49:00 -07:00
Sameh Khamis 4d64ac48bc docs: clarify unit transition examples 2026-10-25 08:48:58 -07:00
Sameh Khamis c30d2d3473 docs: document PR #1387 rounding behavior 2026-10-25 08:48:37 -07:00
Sameh Khamis d8d3ac85b6 Merge PR #1387 contribution for review 2026-10-25 08:48:36 -07:00
Sameh Khamis d6ae581652 docs: document PR #1386 rounding behavior 2026-10-25 08:48:30 -07:00
Sameh Khamis 5c8d371552 Merge PR #1386 contribution for review 2026-10-25 08:48:29 -07:00
Sameh Khamis be869f9160 docs: document PR #1385 rounding behavior 2026-10-25 08:48:22 -07:00
Sameh Khamis 9d07e67cf8 Merge PR #1385 contribution for review 2026-10-25 08:48:22 -07:00
Rudy Celekli 6c225605c4 fix(awareness): show a 999.6 m contact as 1.0 km, not 1000 m
The Contacts panel checked the raw meters against 1000 but printed them rounded, so 999.5 m or more read "1000 m". Compare the rounded meters so the distance moves to kilometres.
2026-10-25 11:32:19 -04:00
Rudy Celekli b7e00f18a4 fix(directions): show a 996 m leg as 1.0 km, not 1000 m
routeDistanceText checked the raw meters against 1000 but printed them rounded to tens, so 995 m or more read "1000 m". Compare the rounded value so it moves to kilometres.
2026-10-25 11:32:15 -04:00
Rudy Celekli eb8b63fc62 fix(directions): show a 59.6-second leg as 1 min, not 60 s
routeDurationText checked the raw seconds against 60 but printed them rounded, so a leg of 59.5 s or more read "60 s". Compare the rounded seconds so it moves to minutes.
2026-10-25 11:32:12 -04:00
quanticsoul4772 6bbf9b94c4 fix(traffic): flag stale cached TomTom tiles in get_traffic_flow (#1239)
Report stale traffic tiles and their fetch time, and release pending freshness metadata when tile requests fail or are cancelled.

Contribution by @quanticsoul4772.
2026-10-25 08:26:26 -07:00
Sameh Khamis f6e7f63a63 fix(voice): keep FIRMS and ALPR entity labels English in voice data (#1207)
Keep FIRMS and ALPR entity data labels in English and localize their presentation.

Contribution by @daikaginza.
2026-10-25 08:21:25 -07:00
Sameh Khamis ad79e57028 Merge main into PR #1207 2026-10-25 08:14:59 -07:00
KnottyDyes 6fee8382ce fix(first-run): make the dismiss hint a button so touch screens can close the card (#1226)
Allow touch users to dismiss the welcome card through the existing dismissal path.

Contribution by @KnottyDyes.
2026-10-25 08:14:52 -07:00
quanticsoul4772 91144e88ad perf(models): aircraft models skip Cesium's dynamic environment map (#1366)
Skip per-aircraft dynamic environment maps while retaining default model lighting.

Contribution by @quanticsoul4772.
2026-10-25 08:08:43 -07:00
Sameh Khamis 1a72da81b5 docs(voice): document stable entity labels and localized presentation 2026-10-25 07:54:47 -07:00
Sameh Khamis dae03f9a11 Merge PR #1207 with current main 2026-10-25 07:54:46 -07:00
Sameh Khamis abd81155f9 Merge pull request #1379 from bilawalsidhu/perf/aircraft-label-candidates
Reduce repeated work in aircraft detection candidates
2026-10-24 23:48:30 -07:00
Sameh Khamis 75eaa56c15 Merge remote-tracking branch 'origin/main' into perf/aircraft-label-candidates 2026-10-24 23:42:23 -07:00
Rudy Mizrahi Celekli b07352ea6a fix(search): read hemisphere letters separated from the number (#1340)
Support spaced hemisphere coordinates while rejecting malformed numeric fragments and ambiguous component assignments. Include regression tests and runtime documentation.

Contribution by @rudycelekli.
2026-10-24 23:39:51 -07:00
Sameh Khamis d53d68b7cf docs: record aircraft candidate processing improvement 2026-10-24 23:36:58 -07:00
Sameh Khamis e7cf071a1c Merge remote-tracking branch 'origin/main' into perf/aircraft-label-candidates 2026-10-24 23:36:30 -07:00
Sameh Khamis 45a093fb42 perf(flights): reuse resolved models in detection queries 2026-10-24 23:36:30 -07:00
Sameh Khamis 7cdaa6044b perf(flights): cache unchanged detection label text 2026-10-24 23:36:30 -07:00
Said Turksever 5b8e6fbc38 fix(street-level): read the older {zoom, kind} zoomFor shape (#1288)
Restore legacy provider zoomFor compatibility and document supported return values.

Contribution by @asturksever.
2026-10-24 23:33:10 -07:00
Sameh Khamis 8dec9eaa10 perf(terrain): evict from the front of the point cache instead of sorting it (#1326)
Preserve oldest-fetch terrain cache eviction without sorting the full cache on each overflow.

Contribution by @daikaginza.
2026-10-24 23:27:37 -07:00
Sameh Khamis 5d21beed9a Merge main into PR #1326 2026-10-24 23:21:10 -07:00
Sameh Khamis 86d52a0f7a fix(cyclones): label NHC classification codes by their published meanings (#1349)
Correct NHC cyclone classification labels and document their published meanings.

Contribution by @rudycelekli.
2026-10-24 23:21:04 -07:00
Sameh Khamis 29db102f59 docs: document PR #1326 behavior 2026-10-24 23:14:56 -07:00
Sameh Khamis 134505f716 Merge PR #1326 with current main 2026-10-24 23:14:55 -07:00
Sameh Khamis 04b6a99a30 docs: document PR #1349 behavior 2026-10-24 23:14:52 -07:00
Sameh Khamis fae7703b4f Merge PR #1349 with current main 2026-10-24 23:14:52 -07:00
Sameh Khamis 6b458c897d Merge pull request #1378 from bilawalsidhu/fix/touch-zoom-aircraft-performance
Improve touch zoom and aircraft update performance
2026-10-24 22:55:14 -07:00
Sameh Khamis ad3f26be2d Merge remote-tracking branch 'origin/main' into fix/touch-zoom-aircraft-performance 2026-10-24 22:48:07 -07:00
Sameh Khamis 979fd652cf Document touch zoom and aircraft update improvements 2026-10-24 22:47:43 -07:00
Sameh Khamis 27cfbe5562 Reuse aircraft render times and extrapolation frames 2026-10-24 22:47:20 -07:00
Sameh Khamis f490404b7f Increase touch-first globe zoom sensitivity 2026-10-24 22:47:20 -07:00
Sameh Khamis a888e0fa22 Merge pull request #1353 from rudycelekli/fix/transit-ninety-second-age
Correct the transit report-age label and document its rounding rule.
2026-10-24 22:46:45 -07:00
Sameh Khamis 77e233600a Merge main into PR #1353 2026-10-24 22:40:23 -07:00
Sameh Khamis f2ee3c984f Merge pull request #1352 from rudycelekli/fix/cockpit-destination-heading
Reviewed display correction with its runtime documentation update.
2026-10-24 22:33:05 -07:00
Sameh Khamis 19de386bcc Merge main into PR #1352 2026-10-24 22:27:39 -07:00
Sameh Khamis 5d23a874b1 Merge pull request #1354 from rudycelekli/fix/firms-frp-ten-megawatts
Reviewed display correction with its runtime documentation update.
2026-10-24 22:27:34 -07:00
Sameh Khamis b616188cbf Merge main into PR #1354 2026-10-24 22:21:46 -07:00
Said Turksever b304fd4a34 docs(changelog): restore the GBFS rate-limit entry (#1184)
Restore the original GBFS rate-limit changelog entry accidentally removed by #1176. Documentation only.

Contribution by Said Turksever (@asturksever).
2026-10-24 22:21:40 -07:00
Sameh Khamis 6d47525e4e docs: record PR #1353 display behavior 2026-10-24 22:17:56 -07:00
Sameh Khamis 5673831619 docs: record PR #1352 display behavior 2026-10-24 22:17:56 -07:00
Sameh Khamis 6b85990fec docs: record PR #1354 display behavior 2026-10-24 22:17:56 -07:00
Sameh Khamis 80cd3476bf Merge main into PR #1354 2026-10-24 22:16:42 -07:00
Sameh Khamis 023be3c189 Merge main into PR #1353 2026-10-24 22:16:41 -07:00
Sameh Khamis 5754e8f046 Merge main into PR #1352 2026-10-24 22:16:39 -07:00
Sameh Khamis 7a8ba29186 Merge pull request #1351 from rudycelekli/fix/ais-static-keeps-motion
fix(ais): keep a tracked vessel's motion when static data arrives
2026-10-24 21:45:40 -07:00
Sameh Khamis a2a3aba1d2 Merge main into fix/ais-static-keeps-motion 2026-10-24 21:39:32 -07:00
Sameh Khamis 2fc9ca8516 Merge pull request #1350 from rudycelekli/fix/cyclone-movement-knots
fix(cyclones): convert NHC movement speed from mph to knots
2026-10-24 21:39:26 -07:00
Sameh Khamis 19ed82af07 Merge branch 'main' into fix/cyclone-movement-knots 2026-10-24 21:33:20 -07:00
Sameh Khamis 88803f16e8 Merge pull request #1303 from quanticsoul4772/upstream/wind-stale
fix(tools): flag stale wind forecasts in get_wind
2026-10-24 21:31:01 -07:00
Sameh Khamis 77b485d619 fix(ais): refresh changed static metadata without advancing position 2026-10-24 21:29:21 -07:00
Sameh Khamis 4ba0a1b57f Merge branch 'main' into upstream/wind-stale 2026-10-24 21:26:18 -07:00
Sameh Khamis 2a48e1d186 Merge pull request #1244 from KnottyDyes/fix/terrain-test-cache-isolation
test(terrain): give every terrain-heights proxy in the bounds tests its own cache
2026-10-24 21:26:11 -07:00
Sameh Khamis aa33bb5ec3 Merge branch 'main' into fix/terrain-test-cache-isolation 2026-10-24 21:20:50 -07:00
Sameh Khamis aa9a7d41bf Merge main into fix/cyclone-movement-knots 2026-10-24 21:20:41 -07:00
Sameh Khamis cf4e8e1d87 docs(cyclones): record upstream movement speed units 2026-10-24 21:19:54 -07:00
Said Turksever 2dca762e4a Merge pull request #1282 from bilawalsidhu/codex/fix-hidden-tab-polling
fix(layers): pause periodic network updates in hidden tabs
2026-10-24 23:59:30 +01:00
Said Turksever 60ec1f962b Merge branch 'main' into codex/fix-hidden-tab-polling 2026-10-24 23:51:23 +01:00
quanticsoul4772 89a6f8c96f Merge branch 'main' into upstream/wind-stale 2026-10-24 08:51:41 -07:00
quanticsoul4772andSameh Khamis 1c234ce027 fix(opensky): load OAuth credential files and retire Basic auth (#1229)
Read OpenSky OAuth credentials from OPENSKY_CREDENTIALS_FILE and stop sending retired Basic authentication. Update setup diagnostics and guidance.

Keep credential-file warnings free of parser excerpts and filesystem error details, cover both failure paths with a regression test, and use a launcher-compatible absolute path example.

Original contribution by quanticsoul4772, with credential-warning and documentation follow-up by Sameh Khamis.

Co-authored-by: quanticsoul4772 <[email protected]>
Co-authored-by: Sameh Khamis <[email protected]>
2026-10-24 08:25:42 -07:00
quanticsoul4772 07318cc9a4 fix(geo): wrap longitude differences across the antimeridian in point distances (#1304)
Wrap longitude differences in annotation proximity checks and nearby-place distances so points across the antimeridian remain close.

Original contribution by quanticsoul4772.

Co-authored-by: quanticsoul4772 <[email protected]>
2026-10-24 08:19:21 -07:00
Rudy Celekli a9eda1d9e7 changelog: note the rounded-north destination heading 2026-10-24 00:36:02 -04:00
Rudy Celekli 9f8ed74e7b changelog: note the 10 MW fire-power label 2026-10-24 00:35:59 -04:00
Rudy Celekli 1469cc2ade changelog: note the 90-second transit report age 2026-10-24 00:35:53 -04:00
Rudy Celekli 05316bb34b fix(cockpit): read a rounded-north destination heading as 000
359.6 degrees was shown as 360 degrees on the destination bearing while the compass tape uses 000.
2026-10-24 00:24:43 -04:00
Rudy Celekli b8f8a8685d docs(changelog): add AIS static data fragment 2026-10-24 00:24:08 -04:00
Rudy Celekli 2bcd27c1e0 fix(ais): keep a tracked vessel's motion when static data arrives 2026-10-24 00:23:44 -04:00
Rudy Celekli 2beef9be39 fix(firms): drop the decimal when fire power rounds to 10 MW
A 9.96 MW detection was labeled 10.0 MW because the one-decimal cutoff ran before rounding.
2026-10-24 00:23:11 -04:00
Rudy Celekli 1b52298c6c docs(changelog): add cyclone movement speed fragment 2026-10-24 00:21:39 -04:00
Rudy Celekli 0b19f11ad0 fix(cyclones): convert NHC movement speed from mph to knots 2026-10-24 00:21:05 -04:00
Rudy Celekli 7276e300eb docs(changelog): add cyclone classification fragment 2026-10-24 00:18:37 -04:00
Rudy Celekli 66ea77237f fix(cyclones): label NHC classification codes by their published meanings 2026-10-24 00:17:38 -04:00
Rudy Celekli 0309a58643 fix(transit): count a 90-second report as one minute
The card kept seconds through 89s, then rounded 90s up to 2 minutes.
2026-10-24 00:10:14 -04:00
Said Turksever 9d3548c27c Merge pull request #1341 from rudycelekli/fix/kartaview-impossible-timestamps
fix(kartaview): reject impossible photo timestamps
2026-10-24 01:49:53 +01:00
Said Turksever 39777ad173 Merge branch 'main' into fix/kartaview-impossible-timestamps 2026-10-24 01:42:59 +01:00
Said Turksever 98fd1eca69 Merge pull request #1342 from rudycelekli/fix/firms-acquisition-calendar
fix(firms): keep impossible acquisition dates off the fire clock
2026-10-24 01:41:29 +01:00
Said Turksever ddd3962078 Merge branch 'main' into fix/firms-acquisition-calendar 2026-10-24 01:31:25 +01:00
Said Turksever f76b6ef9ab Merge pull request #1278 from bilawalsidhu/codex/fix-overpass-cache-singleflight
fix(overpass): reserve singleflight before disk cache reads
2026-10-24 01:30:09 +01:00
Said Turksever 1c2fe8201f Merge branch 'main' into codex/fix-overpass-cache-singleflight 2026-10-24 01:11:43 +01:00
Said Turksever 8b7cdac910 Merge pull request #1279 from bilawalsidhu/codex/fix-launch-hover-throttle
fix(missions): throttle hover picks and clear stale cursors
2026-10-24 01:11:13 +01:00
Said Turksever 70bdcbfd8a Merge branch 'main' into codex/fix-launch-hover-throttle 2026-10-24 01:06:07 +01:00
Rudy CelekliandCursor d9ecd9869a Drop the second changelog fragment for the KartaView fix.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:14:35 -04:00
Rudy CelekliandCursor e07b0e9256 Add the changelog note for impossible wildfire acquisition dates.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:34 -04:00
Rudy CelekliandCursor cbefa2567a Add the changelog note for impossible KartaView timestamps.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:32 -04:00
Rudy CelekliandCursor bdb3851bb2 changelog: note 1342-firms-acquisition-calendar
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:07 -04:00
Rudy CelekliandCursor dbf306379f changelog: note 1341-kartaview-impossible-timestamps
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:04 -04:00
Rudy CelekliandCursor 7d2294580b fix(firms): keep impossible acquisition dates off the fire clock
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:08:55 -04:00
Rudy CelekliandCursor 9e2879c0ba fix(kartaview): reject impossible photo timestamps
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:08:52 -04:00
Sameh Khamis 8d454bf7f1 fix: improve mobile contact selection and context drawers (#1338)
Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo.
2026-10-23 15:05:23 -07:00
manjunath22466 b2fb4a261e feat(camera): add globe rotation (#1225)
Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards.
2026-10-23 14:51:42 -07:00
Sameh Khamis 8cdd184a90 feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324)
* feat(street-level): export the KartaView and Panoramax providers

A host that composes Street Level with its own provider list can already
import the Mapillary and Google Street View providers; KartaView and Panoramax
were reachable only through the default list, which needs a Mapillary source.
Exporting them lets a host offer the keyless providers without Mapillary.

* chore: changelog entry for the Street Level provider exports

* feat(street-level): send Mapillary Graph lookups to a source endpoint

A Mapillary source can take a graphEndpoint, a Graph API base that adds
the token itself. Its sequence lookups go there through the source's
transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider
at that endpoint, so a host that proxies Mapillary needs no client token in
the page. Without it, behaviour is unchanged: direct Graph API calls with
the client token.
2026-10-23 07:47:57 -07:00
Said Turksever f19a6da5b8 Merge pull request #1274 from bilawalsidhu/codex/fix-kml-root-scanner
fix(import): scan the KML root delimiter in bounded time
2026-10-23 15:37:27 +01:00
Said Turksever 6194989018 Merge branch 'main' into codex/fix-kml-root-scanner 2026-10-23 15:26:25 +01:00
Said Turksever 525f48c472 Merge pull request #1273 from bilawalsidhu/codex/fix-military-history-failures
fix(military): retire unavailable history and keep local trails
2026-10-23 15:07:46 +01:00
Said Turksever 8eb1029393 Merge branch 'main' into codex/fix-military-history-failures 2026-10-23 15:01:01 +01:00
Said Turksever 1516fc1902 Merge pull request #1270 from bilawalsidhu/codex/fix-space-provider-cooldowns
fix(space): respect provider cooldowns and bound TLE groups
2026-10-23 14:48:44 +01:00
Said Turksever a4f9b5e925 Merge branch 'main' into codex/fix-space-provider-cooldowns 2026-10-23 14:39:06 +01:00
Said Turksever 3b9e83d4a5 Merge pull request #1281 from bilawalsidhu/codex/fix-traffic-render-hold
fix(traffic): hold rendering only for animated content
2026-10-23 13:05:27 +01:00
Said Turksever 3af42082b1 Merge branch 'main' into codex/fix-traffic-render-hold 2026-10-23 12:59:46 +01:00
Said Turksever f6eabb4912 Merge pull request #1268 from bilawalsidhu/codex/fix-radio-tag-normalization
fix(radio): normalize station tags and retain valid mixed rows
2026-10-23 12:52:27 +01:00
Said TurkseverandClaude Opus 5.5 f93982f55e Carry server structural rejections into Radio diagnostics
The proxy now skips malformed upstream rows individually but counts
them, capped at 10,000, in the catalog metadata and the /stations
envelope (rejectedStationCount). The count never fails health policy,
so an otherwise healthy generation is still accepted. The client
validates the field, adds its own rejected rows after admission, and
reports degraded coverage when either side skipped rows. Adds an
end-to-end proxy-to-client test and aligns DATA_SOURCES.md and
CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:39:56 +01:00
Said TurkseverandClaude Opus 5.5 82190d531a Reset the Radio rejection counter on destroy
Clear _rejectedStationCount alongside _degraded so a destroyed and
re-initialized layer does not report the previous catalog's count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:39:56 +01:00
Said TurkseverandClaude Opus 5.5 0c20ed9975 fix(overpass): retry a joined caller's admission on its own quota
A caller that joins a cold reservation no longer inherits another client's
RATE_LIMITED result. Once the rejected reservation is released it retries:
it either owns the next reservation and is admitted on its own quota, or joins
an admitted fetch. Adds a two-client regression (exhausted owner, available
joiner, exhausted joiner coalescing onto the admitted fetch).

Also drops the duplicate memory publication for a disk hit in the handler;
preflight already sets it, the handler only trims.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:33:05 +01:00
Said TurkseverandClaude Opus 5.5 6b2aa95cc6 Skip DOCTYPE comments/PIs and match root namespaces only as attributes
- The DOCTYPE scan now jumps over whole comments and processing
  instructions found outside a declaration string literal, so a quote or
  bracket inside them no longer leaves the scanner stuck before the root.
- Root namespace repair walks the root's attributes in order, so
  declaration-like text inside a quoted value can no longer fill `seen`
  and cause the real declaration to be dropped as a duplicate.
- Regression tests for both, plus linear-time cases in the isolated
  deadline test.
- CURRENT-STATE: describe the scanner as a boundary check and leave the
  rest of the XML grammar to DOMParser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:30:32 +01:00
Said TurkseverandClaude Opus 5.5 0b5b897c11 Allow active TLE group, admit proxied same-origin requests, keep longest cooldown
- CelesTrak allowlist includes `active`, which Space Missions'
  getActiveTle() requests; unknown groups still answer 400.
- The CelesTrak gate uses admitSameSiteKeyless so the documented reverse
  proxy keeps working while foreign/opaque Origins and cross-site
  Sec-Fetch-Site are still refused with 403.
- A provider failure never shortens an existing cooldown (CelesTrak and
  Launch Library keep the later deadline).
- Tests: real getActiveTle() through the middleware, proxied app request
  vs unrelated Origin, concurrent failures finishing in both orders.
- docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:26:41 +01:00
Said TurkseverandClaude Opus 5.5 730ba57073 Report unsupported military track history in get_aircraft_track
The standalone military source no longer has getTrack, so the MCP
get_aircraft_track tool threw a TypeError and MCP advised a retry. It
now returns an `unsupported` ToolError when the selected feed has no
getTrack, while custom sources that supply history still work. Tests
cover the default composition through the catalog and MCP.

Also drop the unused epoch import and update the CURRENT-STATE track
sections to describe local trails and 503 history_unavailable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:26:02 +01:00
Said TurkseverandClaude Opus 5.5 e60caab180 Run a trailing hover check for the last suppressed pointer position
A move inside the 120 ms hover throttle window was dropped, so a pointer
that stopped right after a pick kept a stale cursor (pointer over empty
space, or default over a mission) until the next move. Keep a copy of the
last suppressed position (Cesium reuses the movement object) and run one
trailing check when the window ends. The check rechecks hover eligibility
and is cancelled by reset, disable, camera motion, and destroy.

Tests cover a stopped pointer in both directions without another event,
the copied position, one timer at a time, cancellation, and the
eligibility recheck.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:23:07 +01:00
Said TurkseverandClaude Opus 5.5 07d6417446 Realign periodic polling to the deadline after a visibility catch-up
An overdue refresh on return to a visible tab advanced the due time but kept
the old setInterval phase, so the next phase tick was skipped and the gap
could approach two intervals (also after a failed resume refresh). The
catch-up now re-arms the interval at the new deadline. Interval ticks no
longer compare against the due time, since the phase and the deadline share
one origin. Overlap prevention and teardown are unchanged, and a stale
listener cannot re-arm a stopped loop.

Tests cover the next poll one interval after a between-ticks resume, with a
successful and a failed catch-up refresh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:22:42 +01:00
Said TurkseverandClaude Opus 5.5 e974ce096a Qualify the Traffic render-hold note for failed loads
A failed load leaves no hold only when no animated content is retained.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said TurkseverandClaude Opus 5.5 1b7498820f Remove the unused trafficLogs collector from Traffic QA
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said TurkseverandClaude Opus 5.5 090eeb9b11 Settle pending Traffic height work when the population empties
A closure that removes the last animated points mid height correction
released the render hold, and animate() then returned before easeHeights()
could run, leaving _heightPending (and getStats().loading) true. Settle
every retained road on its corrected heights before the hold is released.

Adds a regression combining height correction, a late closure and an
empty population.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said Turksever d8dce93ff4 Merge remote-tracking branch 'origin/main' into fix/1281
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:12:27 +01:00
Said Turksever bdf5899dda Merge origin/main into codex/fix-overpass-cache-singleflight 2026-10-23 12:10:39 +01:00
Said Turksever 5ca2266f53 Merge remote-tracking branch 'origin/main' into fix/1274
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:32 +01:00
Said Turksever 98af799cb4 Merge remote-tracking branch 'origin/main' into fix/1273
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/tooling/liveProviders.test.mjs
2026-10-23 12:09:32 +01:00
Said TurkseverandClaude Opus 5.5 0caffd8b70 Merge origin/main into codex/fix-space-provider-cooldowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:09:31 +01:00
Said Turksever be44f523e0 Merge remote-tracking branch 'origin/main' into fix/1282
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:26 +01:00
Said Turksever 17864965d9 Merge remote-tracking branch 'origin/main' into fix/1268
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:23 +01:00
Said d41c0ccaea Merge remote-tracking branch 'origin/main' into fix/1279
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:19 +01:00
daikaginza e60cbb42e3 perf(terrain): evict from the front of the point cache instead of sorting it
Once the cache was full, every write sorted all 50,000 entries to find the
oldest. A Map keeps insertion order, so keep the map in fetch-time order
and delete from the front: the resolver re-inserts each point it refetches
(an overwrite would leave it in its old place), and the disk load sorts
once, since a file written before this change, or a migrated raw-batch
cache, is not in age order.

Suggested by asturksever on #975.
2026-10-23 11:57:37 +07:00
daikaginza 626852401a test(terrain): pin which point the cache evicts after a refetch
A stale point that is refetched becomes the newest entry, and the cache
file's order is not its age order. The ceiling must still evict the
oldest fetch. Passes on the current sort-based eviction.
2026-10-23 11:57:29 +07:00
quanticsoul4772 ba2dda5beb Merge branch 'main' into upstream/wind-stale 2026-10-22 19:18:02 -07:00
quanticsoul4772 a94677aed0 Merge branch 'main' into upstream/wind-stale 2026-10-22 06:18:13 -07:00
quanticsoul4772andClaude Opus 5.5 80292fab72 docs(changelog): reword fragment for #1303
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:20:56 -07:00
quanticsoul4772andClaude Opus 5.5 6d3e7c6c67 docs(changelog): fragment for #1303
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:09:38 -07:00
Said TurkseverandClaude Opus 5.5 e925f75a4a Merge branch 'main' into codex/fix-hidden-tab-polling
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:54 +01:00
Said TurkseverandClaude Opus 5.5 b493418ed1 Merge branch 'main' into codex/fix-traffic-render-hold
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:52 +01:00
Said TurkseverandClaude Opus 5.5 a2968af45e Merge branch 'main' into codex/fix-launch-hover-throttle
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:48 +01:00
Said TurkseverandClaude Opus 5.5 635fb55514 Merge branch 'codex/fix-overpass-request-admission' into codex/fix-overpass-cache-singleflight
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:45 +01:00
Said TurkseverandClaude Opus 5.5 2a0f78d169 Merge branch 'main' into codex/fix-kml-root-scanner
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:33 +01:00
Said TurkseverandClaude Opus 5.5 6477f9c910 Merge branch 'main' into codex/fix-military-history-failures
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:31 +01:00
Said TurkseverandClaude Opus 5.5 76ee07ee6e Merge branch 'main' into codex/fix-space-provider-cooldowns
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:23 +01:00
Said TurkseverandClaude Opus 5.5 023b6a1991 Merge branch 'main' into codex/fix-radio-tag-normalization
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:18 +01:00
quanticsoul4772andClaude Opus 5.5 fb88dd6e1b fix(tools): say when get_wind answers from a stale forecast run
When the model upstream fails, the wind proxy keeps serving its last
run, however old, with manifest.stale set. The wind layer shows that,
but get_wind ignored it and gave the old run's wind as current.

get_wind now returns stale and adds "(forecast may be stale)" to its
summary, the way get_weather and the space tools flag stale data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:25:45 -07:00
Said 56be69d065 test: restore minimized Transit windows before screenshots 2026-10-20 15:12:33 +01:00
Said 168d491038 test: keep Traffic interception off workers and restore screenshot windows 2026-10-20 15:12:32 +01:00
Said 116f28a525 test: isolate keyed and keyless Traffic browser sessions 2026-10-20 14:55:18 +01:00
Said 77ed0a7c31 test: run Traffic fixtures with normal browser security 2026-10-20 14:53:08 +01:00
Said 72f1625257 test: avoid concurrent format-scope append conflicts 2026-10-20 14:43:01 +01:00
Said 5b9c726f33 test: prove hidden-tab polling in a real browser 2026-10-20 14:37:57 +01:00
Said 2c8b3593d1 test: observe a poll transition in the Transit anchor probe 2026-10-20 14:19:11 +01:00
Said f16fda2350 test: exercise traffic with current vector and terrain fixtures 2026-10-20 14:18:23 +01:00
Said e4955f351f test: qualify Transit visibility and city feed ownership 2026-10-20 14:07:13 +01:00
Said 9fd434438b test: target mounted voice input in radio takeover gate 2026-10-20 13:50:42 +01:00
Said eabff9005d docs: record polling fix 2026-10-20 01:26:21 +01:00
Said ec0985618c fix(layers): pause periodic network updates in hidden tabs 2026-10-20 01:25:40 +01:00
Said 34b064fc62 docs: record traffic fix 2026-10-20 01:22:32 +01:00
Said 122b511d1e fix(traffic): hold rendering only for animated content 2026-10-20 01:21:53 +01:00
Said bc0c4b8489 docs: record launch fix 2026-10-20 01:15:33 +01:00
Said 08e90e8182 fix(missions): throttle hover picks and clear stale cursors 2026-10-20 01:15:19 +01:00
Said 133d82621e docs: record overpass fix 2026-10-20 01:12:53 +01:00
Said 5980a08533 fix(overpass): reserve singleflight before disk cache reads 2026-10-20 01:12:36 +01:00
Said 9541963aa6 docs: record import fix 2026-10-20 00:48:45 +01:00
Said 6552a89ddd fix(import): scan the KML root delimiter in bounded time 2026-10-20 00:48:25 +01:00
Said 5d9eabc919 docs: record history fix 2026-10-20 00:44:15 +01:00
Said 5f3d1eaa2f fix(military): retire unavailable history and keep local trails 2026-10-20 00:43:47 +01:00
Said 8602999056 docs: record space fix 2026-10-20 00:29:24 +01:00
Said 9d3d5164fe fix(space): respect provider cooldowns and bound TLE groups 2026-10-20 00:28:00 +01:00
Said 34507f606a docs: record radio fix 2026-10-20 00:23:34 +01:00
Said 32b0304c2c fix(radio): normalize station tags and retain valid mixed rows 2026-10-20 00:23:10 +01:00
knottyandClaude Opus 5.5 6bb2856403 docs(changelog): add the terrain test cache isolation fragment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4
2026-10-19 10:30:41 -06:00
knottyandClaude Opus 5.5 657fd589d7 test(terrain): give every terrain-heights proxy in the bounds tests its own cache
Seven tests built terrainHeightsProxy() with no cachePath, so the proxy fell
back to this checkout's .gev-cache/terrain-heights.json. A point the app had
already cached there answered a request the test expected to reach the
stubbed upstream: after running the dev server near Austin, the cross-site
test failed with 1 !== 2.

isolatedCachePath(t) gives each test a mkdtemp cache file removed in
t.after, as the over-large cache test already did; that test now uses it too.

Fixes #1242.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4
2026-10-19 10:30:17 -06:00
daikaginza 843c8b1a6b test(i18n): feed real FIRMS and ALPR labels through the voice card
The card's fire pattern is written by hand against the producers' template,
so a format change upstream would silently show English. Both locale tests
now pass the label the layer actually produced through shownEntityLabel()
under ru, and set the locale inside the try that resets it.
2026-10-18 15:22:31 +07:00
daikaginza 9011918731 docs(changelog): add the #1207 entry 2026-10-18 14:35:07 +07:00
daikaginza cec5d99e13 fix(voice): keep FIRMS and ALPR entity labels English in voice data
The fire and camera labels in the context store, getStrongestFire() and
getSelectedInfo() were translated at the source, so the voice model got
"Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity,
get_entity_context and pointer_context while every other entity name in
those payloads is English. Keep them English, as before the interface
was translated; the voice card translates them where it shows them.
2026-10-17 07:32:38 +07:00
daikaginza 681ec84748 fix(voice): show FIRMS and ALPR entity labels translated on the voice card
The pointer chip, its live-region line and the get_entity_context card
title now translate the English entity labels the FIRMS and ALPR layers
register ("Fire · FRP {frp} MW", "ALPR camera"). Other names, such as
callsigns and place names, show as they came.
2026-10-17 07:32:38 +07:00
250 changed files with 10834 additions and 767 deletions

No files matched your search

+5 -2
View File
@@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
MAPILLARY_CLIENT_TOKEN=
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired
# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously.
# Default: oauth — requires client credentials below
# Set to "anon" to skip aircraft auth (rate-limited but works)
OPENSKY_AUTH_MODE=oauth
@@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET=
# 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)
# Optional: OpenSky credentials JSON file path, used when the two values above
# are empty: the credentials.json your OpenSky account page downloads when you
# create an API client. Use an absolute path for compatibility with launchers.
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
# Optional: dev server config
+59
View File
@@ -0,0 +1,59 @@
## What this changes
<!-- One or two sentences: what changed and why. Link the issue if there is one. -->
## How I verified it
- [ ] `npm run build`
- [ ] `npm test`
- [ ] `npm run test:track` (dev server running)
- [ ] Feature gate for the area I touched: <!-- e.g. `node scripts/qa-radio.mjs`, or "none applies" -->
<!-- Screenshots are welcome for anything visual. -->
## New layer checklist
<!--
Fill this in if the PR adds a new data layer. Delete the whole section if it doesn't.
See "Adding a new layer" in CONTRIBUTING.md.
-->
**Screenshots**
<!-- At least one screenshot of the layer on the globe, plus its info card or panel if it has one. -->
**Recording**
<!--
Preferably 15 to 30 seconds: turn the layer on, move around, open a record.
Drag an .mp4 or .mov in here; GitHub accepts up to 10 MB on free accounts.
If it's bigger, link to it instead.
-->
**Proposed category**
<!--
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
If none fits, propose a new group and say why.
-->
Category:
**Data source and licence**
- Source (provider and endpoint):
- Licence or terms (link):
- Attribution shown in the app:
- Needs a key? (none / optional / required):
- Fetched at runtime, or bundled in the repo:
<!-- Bundled data must be data we have the right to redistribute. -->
- [ ] Added a row to `DATA_SOURCES.md`
- [ ] Credit is registered in `src/data/dataCredits.js`
- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable
## Docs
- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/<pr-number>-<slug>.md` fragment added, or this PR doesn't change runtime behaviour
+9
View File
@@ -6,6 +6,15 @@
never derived: an aircraft that has not transmitted a rate reads as `null`
rather than a level-flight `0`.
- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted
where it could go — https, an allowlisted host, a station feed path, no
redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted
how often, so the dev server would make unlimited requests at a third-party
bikeshare operator on any caller's behalf. The route now admits 120
requests/minute per client address, refusing the rest with 429 and
`Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a
minute with every catalogued system in range, so ordinary use is unchanged.
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
licence quote and attribution. A feed can now name its own modes, so Toronto's
+28 -1
View File
@@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
The highest-leverage places to jump in:
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
@@ -153,6 +153,33 @@ ownership and adoption process.
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly.
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer).
## Adding a new layer
A new layer changes what everyone sees on the globe, so maintainers need to see
it working and know where its data comes from before reviewing the code. PRs
that add a layer must include all four of these in the description. The PR
template has a section for them.
1. **Screenshots.** At least one of the layer on the globe, plus its info card
or panel if it has one.
2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move
around, and open a record. Drag an `.mp4` or `.mov` into the PR description.
GitHub accepts videos up to 10 MB on free accounts, so link to a larger file
instead of attaching it.
3. **The proposed category.** Say which layer-panel group the layer belongs in.
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
that isn't listed there falls under "Other layers". If none of the groups
fits, propose a new one and explain why.
4. **The data source and licence.** Name the provider and endpoint, link its
licence or terms, give the attribution shown in the app, and say whether it
needs a key and whether the data is fetched at runtime or bundled. Add the
same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit
in `src/data/dataCredits.js`.
Maintainers may hold a layer PR until all four are there.
## Maintainers
+1 -1
View File
@@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **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.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
+4
View File
@@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's
**🎥 Direct it** — drone-operator camera verbs:
“Rotate the globe” spins Earth to the right continuously; ask for left, a speed,
or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance;
camera input stops rotation.
> 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_
**🖊️ Annotate it** — a whiteboard over the real world:
@@ -0,0 +1,3 @@
- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results
(`track_entity`, `get_entity_context`, pointer context); the voice card still
shows them in the interface language (daikaginza, #1207).
+2
View File
@@ -0,0 +1,2 @@
- Add continuous globe rotation with direction and speed controls and a globe-fit
check before starting (manjunath22466, #1225).
@@ -0,0 +1,4 @@
- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is
now a button that closes the card the same way ESC does; touch screens read
DISMISS, and phones no longer hide it, so choosing a mission is not the only
way out (KnottyDyes, #1226).
+8
View File
@@ -0,0 +1,8 @@
- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server
now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's
account page downloads) on every platform, not only through the macOS
launcher, and no longer sends retired username/password Basic auth:
`OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to
set instead (quanticsoul4772, #1229).
- Credential-file warnings omit parser excerpts and filesystem error details;
the path example also works with the macOS launchers.
+3
View File
@@ -0,0 +1,3 @@
- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest
tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata
released on failed or cancelled tile reads (quanticsoul4772, #1239).
@@ -0,0 +1,4 @@
- The terrain-heights bounds tests no longer read the checkout's
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
`npm test` no longer fails after the dev server has cached terrain near
Austin (KnottyDyes, #1244).
+2
View File
@@ -0,0 +1,2 @@
- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268).
- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268).
+1
View File
@@ -0,0 +1 @@
- Respect provider cooldowns and bound tle groups (asturksever, #1270).
+1
View File
@@ -0,0 +1 @@
- Retire unavailable history and keep local trails (asturksever, #1273).
+1
View File
@@ -0,0 +1 @@
- Scan the kml root delimiter in bounded time (asturksever, #1274).
+1
View File
@@ -0,0 +1 @@
- Reserve singleflight before disk cache reads (asturksever, #1278).
+1
View File
@@ -0,0 +1 @@
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
+8
View File
@@ -0,0 +1,8 @@
- Hold rendering only for animated content (asturksever, #1281).
- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281).
- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281).
- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281).
- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281).
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281).
- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281).
- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281).
+7
View File
@@ -0,0 +1,7 @@
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
+4
View File
@@ -0,0 +1,4 @@
- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the
shape before the core went slim, draws its lines again instead of a silent
blank; an overview level or any answer that is not a whole zoom shows the
zoom-in hint (asturksever, #1288).
+2
View File
@@ -0,0 +1,2 @@
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
cached model run (quanticsoul4772, #1303).
@@ -0,0 +1,2 @@
- Point distances wrap longitude differences across the antimeridian, in voice
proximity checks and `places_nearby` (quanticsoul4772, #1304).
@@ -0,0 +1,8 @@
- Export the KartaView and Panoramax Street Level providers, so a host composing
its own provider list can offer the keyless providers without Mapillary
(samehkhamis, #1324).
- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the
token itself (such as a same-origin proxy). Its sequence lookups go there
through the source's transport, and the photo viewer uses MapillaryJS's
Graph provider at that endpoint, so no client token is needed in the page
(samehkhamis, #1324).
@@ -0,0 +1,3 @@
- Evict terrain-height cache entries in fetch-time order without repeatedly
sorting the full cache, while keeping refreshed points newest and ordering
restored entries by their fetch time (daikaginza, #1326).
@@ -0,0 +1,4 @@
- Improve touch selection of aircraft and vessels through the shared contact
picker, reveal tracked aircraft details when opening Context, support downward
swipe dismissal of phone drawers while preserving scrolling and controls, and
enlarge the phone logo (samehkhamis, #1338).
@@ -0,0 +1,3 @@
- Read a hemisphere letter that is separated from its number, so a search for
`40.7128 N 74.0060 W` flies there instead of falling through to the geocoders
(Rudy Celekli, @rudycelekli, #1340).
@@ -0,0 +1,3 @@
- Reject impossible KartaView photo timestamps, so 31 February is unknown
instead of rolling forward into March and passing the imagery date filter
(Rudy Celekli, @rudycelekli, #1341).
@@ -0,0 +1,3 @@
- Keep an impossible wildfire acquisition date off the detection clock, so a
fire is not labeled with a later day it did not happen
(Rudy Celekli, @rudycelekli, #1342).
@@ -0,0 +1,3 @@
- Label NHC cyclone classifications by their published meanings: `PTC` is a
post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical
systems arrive as `STS` and `STD` (rudycelekli, #1349).
@@ -0,0 +1,3 @@
- Convert NHC cyclone movement speed from miles per hour to knots, so
`movement.speedKt` in cyclone query results is no longer about 15% high
(rudycelekli, #1350).
@@ -0,0 +1,6 @@
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
static data arrives; the static message only merges name, type, destination
and IMO (rudycelekli, #1351).
- Apply changed static metadata immediately, retaining known fields when a
later static report omits them, without refreshing the vessel's position
(samehkhamis, #1351).
@@ -0,0 +1,3 @@
- Read a destination heading that rounds to north as 000. 359.6° was shown as
360° on the destination bearing while the compass tape already uses 000
(Rudy Celekli, @rudycelekli, #1352).
@@ -0,0 +1,3 @@
- Count a transit report that is 90 seconds old as one minute. The card kept
seconds through 89s, then rounded 90s up to "Reported 2 min ago"
(Rudy Celekli, @rudycelekli, #1353).
@@ -0,0 +1,3 @@
- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW
detection was labeled 10.0 MW because the one-decimal cutoff ran before
rounding (Rudy Celekli, @rudycelekli, #1354).
@@ -0,0 +1,5 @@
- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's
dynamic environment map, a sky render and GPU readback per model. With
~150 models in the scene at Atlanta on Google 3D, GPU time per frame at
rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison
showed no visible change (quanticsoul4772, #1366).
@@ -0,0 +1,4 @@
- Turning a layer off frees the memory its entities held. Cesium kept
removed layers' draw commands in reused per-pass arrays, so Data Centers
kept 116 MiB after it was switched off, submarine cables 74 MiB and dams
30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367).
@@ -0,0 +1,5 @@
- Increase touch-first globe zoom sensitivity to three times the default while
retaining Cesium collision and distance limits.
- Reuse one delayed display timestamp per civil-aircraft fleet update and cache
extrapolation coordinate frames until their fix coordinates change. Preserve
contact counts, movement cadence, tracked motion and ground corrections.
@@ -0,0 +1,4 @@
- Reduce repeated work in aircraft detection candidates by caching unchanged
callsign/registration text and reusing resolved fleet models. Metadata updates,
live positions and tracked-aircraft/model handoffs remain immediate
(samehkhamis, #1379).
+4
View File
@@ -0,0 +1,4 @@
- The HUD's AI place summary drops a reply that arrives after the camera has
started moving, so a summary asked for before a flight no longer names the
old place mid-flight. Cancelled or partially typed summaries retry even if the place
context stays the same (quanticsoul4772, #1384).
@@ -0,0 +1,3 @@
- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step
cards kept seconds below 60, then rounded 59.6 s up to "60 s"
(Rudy Celekli, @rudycelekli, #1385).
@@ -0,0 +1,3 @@
- Show a route leg of 995 m or more as 1.0 km. The route summary and step
cards kept meters below 1000, then rounded 996 m up to "1000 m"
(Rudy Celekli, @rudycelekli, #1386).
@@ -0,0 +1,3 @@
- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters
below 1000, then rounded 999.6 m up to "1000 m"
(Rudy Celekli, @rudycelekli, #1387).
+3
View File
@@ -0,0 +1,3 @@
- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres
below 1000, then rounded 999.6 m up to "1000 m"
(Rudy Celekli, @rudycelekli, #1388).
+3
View File
@@ -0,0 +1,3 @@
- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6°
printed "N · 360°" on the local wind direction
(Rudy Celekli, @rudycelekli, #1389).
@@ -0,0 +1,6 @@
- Report a fire detection's acquisition time as a UTC timestamp in
`get_active_fires`. The tool joined FIRMS's unpadded date and time, so a
00:45 UTC detection read "2026-01-01 45"; it now reads
`2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain
unknown rather than rolling into another calendar day
(Rudy Celekli, @rudycelekli, #1395).
@@ -0,0 +1,3 @@
- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local
ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°"
(Rudy Celekli, @rudycelekli, #1396).
@@ -0,0 +1,6 @@
- Add a Scope checkbox and extend feathering to 200%, retaining scope settings
across HUD layouts, share links and scenes. First run opens with Scope ON at
100% feather; explicit settings and historical links retain their values. Give
Tactical, Operator and Minimal text black backplates only when Scope is OFF,
including vertical metadata, without changing the original text color, glow or
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1397).
+166 -19
View File
@@ -1,5 +1,21 @@
# God's Eye View Current State
Aircraft detection candidates reuse normalized display text until callsign or
registration changes, including in-place enrichment. Each candidate resolves its
fleet model once per collection. Positions, model ownership and tracked-aircraft
anchors remain live on every collection; contact admission is unchanged.
Civil aircraft share one delayed render timestamp per fleet update. Extrapolated
fixes reuse their local coordinate frame until their coordinates change, including
in-place ground-height corrections. Update cadence, contact counts, interpolation,
turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly
keyed by each position fix and do not retain retired history.
Touch-first devices use three times the default Cesium zoom sensitivity, so
pinching traverses globe and street scales with fewer gestures. Cesium retains
its distance-aware zoom, collision handling and minimum/maximum distance limits.
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
@@ -9,11 +25,15 @@ 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.
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
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,
@@ -166,6 +186,32 @@ Regression coverage lives in `src/tools/area.test.mjs`,
`src/tools/queries/environment.test.mjs` and
`src/layers/perimeters/cards.test.mjs`.
## Scope controls and HUD text readability
Display uses a native, visibly checked Scope checkbox. Turning it off disables
its feather slider while retaining the value. Scope enabled state and feather
are independent of Tactical, Operator, Minimal and Cyber layout changes. Share
links (`sc`/`scf`) and scene visual state restore both choices, including feather
above 100%. The supported feather range is 0–200% of the keyhole radius: 0% is a
hard edge and 200% extends the gradient to the center while keeping its midpoint
on the same keyhole edge. First run opens with Scope ON and feather 100%; links
predating `scf` retain their historical 35% parse fallback.
When Scope is OFF, Tactical, Operator and Minimal add small black backplates
at 70% opacity behind telemetry, vertical metadata, multiline AIS and scene
chrome. They have rounded corners, no halo and no overlap between adjacent lines.
Text keeps its original color, glow and opacity in both Scope states. The engine
publishes the OFF state as `data-scope-mask="off"` on the document root, so direct
setters, share links and scene restoration drive the same treatment. Scope ON
uses the original HUD layout, Operator frames and scene chrome, with no added
backplates. The OFF-only Minimal coordinate offset clears map attribution.
Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` exercises native checkbox click and Space,
feather range and keyboard boundaries, all four layout transitions, share/scene
restore, original Scope ON styling, OFF-only backing coverage and non-overlapping
geometry, multiline AIS, summary clipping and narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots.
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -401,6 +447,9 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound
same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service.
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
Classification labels follow NHC status codes: `PC` is a potential tropical
cyclone, `PTC` a post-tropical cyclone, and `STS`/`STD` a subtropical
storm/depression. Unknown codes remain visible unchanged.
Current positions, advisory issue time and position time stay distinct. NHC may
release an advisory before its nominal time (routinely up to about 30 minutes,
rarely about an hour), so advisory issue and position times up to and including
@@ -413,6 +462,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty,
not storm size or the full hazard area. Successful empty and unavailable states
are distinct. One owned native Cesium data source preserves polygon holes and
geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through.
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
Consult the linked official advisory for safety decisions.
@@ -449,6 +500,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel.
Genuine readings are unchanged, including a stopped vessel's zero and the
highest speed and course the fields can encode.
For an already tracked vessel, AISStream static messages update name, type,
destination and IMO without changing its position, motion, fix timestamps,
position freshness or trail. Non-empty static values replace earlier values;
omitted, empty or unavailable fields retain known metadata across Class B's
separate reports and in the cache used by the next position update. Position
reports still prefer their own metadata name. Invalid Class B parts are ignored;
absent fields prefer the latest live value over the static cache.
Native `<select>` controls declare a dark colour scheme and explicit option
colours, so the browser-painted option list matches the panel it drops out of.
The scheme is declared on the controls rather than on the document root, so
@@ -521,6 +580,9 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
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.
FIRMS and ALPR entity labels remain English in shared context and tool data;
voice-card titles and pointer presentation translate those known labels at display
time, while arbitrary names and callsigns remain unchanged.
The model-free `gods-eye-view/agent/world-packet` export exposes
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
non-voice consumers. It reads the same enabled-only state and feed
@@ -740,6 +802,12 @@ transports and cancellation through response parsing. Stop and application
teardown abort pending connections; reconnect requests a new client secret.
Microphone, radio handoff, tool schemas and default model behavior are preserved.
Coordinate search accepts whitespace-separated decimal degrees with prefix or
suffix hemisphere letters, including `40.7128 N 74.0060 W`. Components require
an actual separator and an unambiguous hemisphere assignment; malformed numeric
fragments and scientific notation fall through to geocoders instead of becoming
exact coordinate results.
Geospatial lookups are composed through `src/search`: forward/reverse geocoding,
text/nearby search and routing use configurable providers/endpoints. Annotation,
HUD and voice consumers share the configured service. Existing Google/Photon
@@ -1386,11 +1454,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below),
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
or Escape closes it, and focus moves into the sheet and back to its tab. Panels
or Escape closes it, and focus moves into the sheet and back to its tab. A
downward swipe of at least 64px on non-control sheet content also closes it,
when every scroll container under the finger starts at the top. Ordinary
vertical scrolling, horizontal gestures, controls and multitouch stay native;
a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels
are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock
as the sheet, and the dock's Location and Presets trays render inline. The voice
control floats above the tab bar while no sheet is open.
The phone logo is 58px wide (about 39px high), aligned with the top controls.
The shared contact picker preserves exact picks. For a coarse primary pointer
that misses onto the map surface, it searches at most eight hits in a 14px square
and accepts the first registered layer hit, stopping at non-map objects. Civil
and military aircraft and vessels share this rule; fine pointers stay exact.
Manually opening Context with a tracked civilian or military aircraft enters
the existing Contacts mode when Context is off. Contacts adopts that aircraft
without restarting its tracking; an active or entering Space Missions mode is
left alone. Programmatic sheet changes and closing the tab do not activate a mode.
Sheets borrow panels. Opening one expands its panels without persisting them or
claiming a user choice, and share links keep reporting each borrowed panel's
state from before the sheet; closing puts back what opening changed, while a
@@ -1644,6 +1727,14 @@ The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
status, cache or upstream work, refusing cross-site browser requests while
preserving same-origin app requests and non-browser local callers. The browser
layers and their rendering remain in their existing modules.
Past the daily tile budget, or while TomTom fails, the proxy answers with its
last copy of a tile (`x-tomtom-cache: STALE-BUDGET` / `STALE-ERROR`) however old
it is, and every tile carries `x-tomtom-fetched-at`, the proxy fetch time
(or the persisted cache file time after restart). The flow source keeps that mark with each decoded tile, so
`get_traffic_flow` returns `stale` and `oldest_tile_at` and says when traffic
may be out of date instead of presenting cached tiles as live. Pending freshness
metadata is released when body reads fail, are cancelled, or exceed their size
limit, as well as when a tile decodes or the source resets.
GBFS null or empty availability counts retain an unknown value through station
parsing and rendering; they use the neutral station color rather than reporting
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
@@ -1891,6 +1982,10 @@ Updated: September 23, 2026
## Aircraft and vessel server modules
OpenSky credential-file failures emit a fixed warning once per process; neither
JSON parser excerpts nor filesystem error messages are logged. The environment
example uses an absolute path compatible with the macOS launchers.
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
state vectors and regional fallback, adsb.lol military positions, ADSBDB
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
@@ -1915,6 +2010,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
including zero-padded hours and minutes; missing or invalid dates and times
remain `null`, including impossible calendar dates from a custom source.
The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry.
@@ -2018,10 +2116,18 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> `localStorage['gev:first-run-mission:v1'] === 'suppressed'`, written **only**
> by the "Don't show this again" checkbox → the per-session
> `sessionStorage['gev:first-run-mission-session:v1'] === 'dismissed'`, written
> by **every** close path (mission, Explore, ESC). So it returns each fresh
> browser session until the visitor ticks the box; clearing storage un-ticks it,
> which is accepted. Both stores fail open — an unreadable store still shows the
> launcher rather than silently swallowing first launch.
> by **every** close path (mission, Explore, ESC, the footer dismiss button). So
> it returns each fresh browser session until the visitor ticks the box; clearing
> storage un-ticks it, which is accepted. Both stores fail open — an unreadable
> store still shows the launcher rather than silently swallowing first launch.
>
> **The footer dismiss button is the keyboardless ESC.** Phones and tablets have
> no ESC key, so the footer hint is a `data-first-run-dismiss` button that calls
> the same `dismiss()` as ESC. It stays visible at every width (phones used to
> hide it, leaving a mission as the only way out) and reads DISMISS on a
> `(hover: none) and (pointer: coarse)` screen, "ESC to dismiss" otherwise. Its
> 44px touch target is a `::before` outside the layout, so the footer row keeps
> its one-line height.
>
> **What a mission may persist (do not "simplify" this).** Layer enablement is
> durable in this app (`gev:layer-state:v2`, written by
@@ -2082,6 +2188,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> always exits non-zero, `1` meaning the control is healthy and `2` meaning it
> is not. Plus the unit pins above.
> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138
> keeps the draw commands past each frame's count in the opaque and other
> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view,
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
> requests one frame through `governorRequestRender` after every layer
> disable and then trims those arrays. It reads private Cesium fields;
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames
> them or changes which passes it truncates.
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
> governor (`src/renderGovernor.js` — hold/release from every per-frame
> animator; discrete mutators call `governorRequestRender`). Any NEW
@@ -3706,6 +3822,10 @@ disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the pr
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Line coverage accepts a provider’s numeric `zoomFor` result or its legacy
`{zoom, kind}` shape. Overview levels and invalid zooms show the zoom-in hint
instead of silently requesting no tiles.
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
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
@@ -4000,6 +4120,10 @@ routing it shows `Routing…`, and a router miss reads
The layer holds continuous render only while a route is drawn (the dashes
animate). Disable clears all state.
At the metre/kilometre boundary, route distance labels use the rounded
ten-metre value to select the unit, switching to kilometres at 995 metres;
a 996-metre leg displays as `1.0 km` rather than `1000 m`.
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
until the operator turns it on.
@@ -4052,6 +4176,9 @@ and as the last line of the list, because the arrival step is among the ones
dropped. A route of exactly 200 is complete, and a roundabout exit after the
200th keeps folding into it.
Route duration labels select their unit after rounding seconds, switching to
minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`.
A step's card opens from its dot on the globe or from its line in the list, and
the list covers every step including departure and arrival, which have no dot.
What a card needs is a resolved ground anchor: a step whose ground cell has not
@@ -4164,7 +4291,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
**Cards and DETECT.** The card presents route, mode/operator, displayed motion,
display delay and newest-report age, reported speed/stop, vehicle ID, and
available trail duration. Its anchor follows the rendered marker; text is
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
count is below 90; otherwise it shows completed minutes calculated from the
rounded second count. Its anchor follows the rendered marker; text is
checked at 4 Hz and published when it changes. DETECT labels retain route,
mode and motion, with the selected label suppressed beside its fuller card.
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
@@ -4192,6 +4321,8 @@ animation. Polls, surface completions and trail fades request individual renders
Disable clears vehicles, detection references, requests, timers and holds;
destruction also releases collections and registrations.
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
**Acceptance harness.** Run against an already running development server:
```sh
@@ -4278,6 +4409,9 @@ silent for five minutes are removed. Credits appear when the feed is used.
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
Contacts distance labels select metres or kilometres after rounding to the
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
At global scale, ambient Radio cluster badges are hard-opacity shared-host
entries: count/category updates and identity replacement do not run keyhole or
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
@@ -4608,7 +4742,7 @@ silently demoting every later lookup for the session.
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
@@ -4711,7 +4845,7 @@ silently demoting every later lookup for the session.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
### Optional Overpass configuration
@@ -4969,7 +5103,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -5012,6 +5146,7 @@ and unreachable upstream (502/504) separately from road geometry.
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers
@@ -5144,6 +5279,7 @@ are omitted rather than framing the wrong part of the globe.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
@@ -5189,6 +5325,7 @@ pins and share links already supply complete boxes.
- **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load.
- **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
@@ -5275,7 +5412,7 @@ pins and share links already supply complete boxes.
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
@@ -5362,7 +5499,8 @@ pins and share links already supply complete boxes.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
@@ -5382,10 +5520,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a
separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D).
**The 2026-08-24 defaults** (superseding the interim 08-23 values of 8%/3%):
**Current defaults** (scope feather revised to 100% on 2026-10-25;
detection and outside opacity retain the 2026-08-24 defaults):
the first-run look is Detection DENSE
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`,
and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather
`100%`, and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
brackets keep their approved brightness exactly at the new OUTSIDE default. Two
terms that must never be conflated: scope FEATHER softens the black scope-mask
edge; detection FADE is the label/card fading band around the keyhole. The
@@ -5455,11 +5594,12 @@ easier to meet (detection is now on more often), but does not create it.
calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
default, and the mapping follows bracket brightness rather than slider
position. The `ko` PARSE fallback stays at `5`.
- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day,
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
- **Scope: `ON`, feather: `100%`** — a broad, soft scope-mask edge (revised on
2026-10-25 from the prior `11%`; earlier defaults were `0%`, `8%` and `35%`).
`SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
mirrored by `#scope-feather-slider`'s markup value AND readout and
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
`_scopeFeatherPct` in `sharelink.js`. The slider spans
0–200, and an explicit `0` is still the hard-crop path — pinned, so moving the
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
at `35`: a link predating `scf` was authored when 35 was what its author saw,
and restoring the author's view is what a share link is for.
@@ -5490,6 +5630,11 @@ easier to meet (detection is now on more often), but does not create it.
short-way-round longitude span and wraps the padded edges, so a dateline-
crossing box stays its true width. Raw subtraction inflated a 0.41° metro box
to 86.7° and a 60° territory to 132°.
- **Point distances take the short way round 180°.** The annotation
resolver's `approximateDistanceM` (voice "point out" proximity checks) and
`places_nearby` distances wrap the longitude difference. Raw subtraction put
Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check
refused it, and sorted a place 2 km across 180° last.
- CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild;
the store was wiped clean, no import from the old `v1` key).
- CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient
@@ -5880,6 +6025,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when
known, altitude, ground speed, track, vertical rate, position age, message
count, "Heard by your receiver"); markers are not camera-followed. Positioned
aircraft join detection boxes, labelled only with a decoded callsign.
The card's `TRK` label wraps after rounding to whole degrees, so a track of
359.5° displays as `TRK 0°` rather than `TRK 360°`.
Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
@@ -5986,7 +6133,7 @@ closed. Performance and live agency reliability require recorded validation.
## Wind forecast models
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary.
### Local Weather review candidate
+15 -9
View File
@@ -3,10 +3,11 @@
God's Eye View uses explicit auth modes for `/api/flights`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
- `OPENSKY_AUTH_MODE=basic`
- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous)
- `OPENSKY_AUTH_MODE=anon`
`OPENSKY_AUTH_MODE=basic` is retired (see below).
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
## Quick Start (OAuth Client JSON)
@@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order:
2. `OPENSKY_CREDENTIALS_FILE`
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
## Basic Auth (Legacy)
The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and
Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any
platform. It reads the file on each token refresh.
Basic auth is still supported for now, but OpenSky is moving away from it.
## Basic Auth (Retired)
```bash
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
```
OpenSky stopped accepting username/password (HTTP Basic) sign-in on
2026-03-18; its API now takes only OAuth2 client credentials. With
`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` /
`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and
logs once what to set instead. Create an API client on your OpenSky
account page and use its `credentials.json` as above.
## Troubleshooting
@@ -61,6 +67,6 @@ Useful reasons:
- `oauth_invalid_or_missing`: OAuth client not found/usable
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
- `basic_invalid_credentials`: username/password rejected
- `missing_basic_creds`: basic mode missing user/pass
- `missing_oauth_creds`: auto mode found no OAuth client
- `basic_retired`: basic mode requested; answered anonymously
- `rate_limited`: OpenSky throttling
+2
View File
@@ -272,6 +272,8 @@
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
"./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js",
"./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
+5 -1
View File
@@ -6,6 +6,7 @@
"scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/qa-layer-visibility.mjs",
"scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs",
@@ -193,6 +194,7 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
@@ -1086,5 +1088,7 @@
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs"
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs"
]
+22 -4
View File
@@ -103,6 +103,9 @@
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/space/cooldown.js",
"server/providers/space/launch-library.js",
"server/providers/common/http.js",
"src/data/spaceProviderRequests.js",
@@ -802,7 +805,9 @@
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
@@ -954,6 +959,7 @@
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/routePlausible.js",
@@ -1006,6 +1012,7 @@
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/trackedModelRegime.js",
@@ -1051,6 +1058,7 @@
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/routePlausible.js",
@@ -1427,7 +1435,9 @@
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source",
"./layers/street-level/google"
"./layers/street-level/google",
"./layers/street-level/kartaview",
"./layers/street-level/panoramax"
],
"modules": [
"src/layers/streetLevel/index.js",
@@ -1867,6 +1877,7 @@
"src/data/militaryInstallations.js",
"src/data/militaryRegistry.js",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/naturalEarthRegions.js",
"src/data/adminBoundaries.js",
@@ -2565,7 +2576,10 @@
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
],
"external": [
"hls.js",
@@ -2676,6 +2690,7 @@
"src/data/installationNames.js",
"src/data/militaryInstallationData.js",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
"src/data/motionModel.js",
"src/data/naturalEarthRegions.js",
"src/data/adminBoundaries.js",
@@ -3286,7 +3301,9 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
"src/i18n/locales/zh-CN.json",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
@@ -3667,6 +3684,7 @@
"src/data/labelArbiter.js",
"src/data/meshFloorSampler.js",
"src/data/pickRegistry.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
"src/data/transit.js",
+27
View File
@@ -18,3 +18,30 @@ export function chromeExecutablePath({
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
/** Restore an owned test window before capture: focus emulation alone does not
* unminimize the native compositor, so CDP screenshots can otherwise stall. */
export async function captureQaScreenshot(
page,
options,
{ headful = true } = {},
) {
if (headful) {
const session = await page.createCDPSession();
try {
const { windowId, bounds } = await session.send(
'Browser.getWindowForTarget',
);
if (bounds.windowState === 'minimized') {
await session.send('Browser.setWindowBounds', {
windowId,
bounds: { windowState: 'normal' },
});
}
await page.bringToFront();
} finally {
await session.detach();
}
}
return page.screenshot(options);
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Real-browser proof for manager-owned periodic work and document visibility.
* Uses the app's development-only layer registration seam; no provider keys.
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
* Hiding uses a real background tab, never a patched document.hidden getter.
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = new URL(
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
process.env.QA_BASE_URL ||
'http://localhost:4173',
);
url.searchParams.set('welcome', '0');
const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (name, actual, expected) => {
assert.equal(actual, expected, name);
console.log(`PASS ${name}: ${actual}`);
};
try {
const page = await browser.newPage();
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
window.__qaVisibilityUpdates = 0;
window.__gevQaRegisterLayer(manager, {
id: 'qa-document-visibility',
name: 'Visibility QA',
updateInterval: 750,
init() {},
enable() {},
disable() {},
destroy() {},
async update() {
window.__qaVisibilityUpdates++;
return true;
},
getStats() {
return { count: window.__qaVisibilityUpdates };
},
});
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
});
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
check('periodic updates run while visible', (await count()) >= 3, true);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
const cover = await browser.newPage();
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
const hiddenCount = await count();
await pause(2200);
check('hidden tab makes no periodic updates', await count(), hiddenCount);
check(
'explicit refresh remains available while hidden',
await page.evaluate(() =>
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
),
true,
);
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.waitForFunction(
() =>
document.visibilityState === 'visible' &&
window.__qaVisibilityUpdates > 0,
);
await pause(200);
check(
'resume performs one overdue refresh without a catch-up burst',
await count(),
hiddenCount + 2,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'qa-document-visibility',
false,
{ origin: 'qa' },
),
);
const stopped = await count();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await pause(1100);
check(
'disabled layer does not resume on visibility change',
await count(),
stopped,
);
check(
'QA layer destroys cleanly',
await page.evaluate(() =>
window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
'qa-document-visibility',
),
),
true,
);
console.log('RESULT: 7 passed');
} finally {
await browser.close();
}
+6 -3
View File
@@ -2759,13 +2759,16 @@ async function main() {
const beforeSpace = radio.getUIState().audioState;
// Simulate an already-started hold-Space session; click-started open mic
// deliberately ignores Space takeover, and short taps never claim voice.
const priorPushToTalkMode = voice.pushToTalkMode;
voice.pushToTalkMode = true;
// The provider facade forwards reads, not writes. Set the mounted
// controller's input owner; assigning on the facade only shadows it.
const controller = voice.session.adapter.controller;
const priorPushToTalkMode = controller.pushToTalkMode;
controller.pushToTalkMode = true;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
await new Promise((resolve) => setTimeout(resolve, 700));
const afterSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
voice.pushToTalkMode = priorPushToTalkMode;
controller.pushToTalkMode = priorPushToTalkMode;
await radio.play();
voice.setVoiceSpeaker('ai');
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env node
/**
* Scope and HUD browser acceptance. Requires the development server.
* Run: node scripts/qa-scope-hud.mjs
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures += 1;
if (detail) console.log(JSON.stringify(detail));
}
};
const readScope = () =>
page.evaluate(() => {
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
const canvas = document.getElementById('scope-mask');
const context = canvas.getContext('2d');
const radialX = Math.round(
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
);
return {
enabled: scope.enabled,
feather: scope.featherPct / 100,
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
radialAlpha: context.getImageData(
radialX,
Math.round(canvas.height / 2),
1,
1,
).data[3],
checked: document.getElementById('scope-toggle').checked,
disabled: document.getElementById('scope-feather-slider').disabled,
value: Number(document.getElementById('scope-feather-slider').value),
readout: document.getElementById('scope-feather-value').textContent,
};
});
const readHudGlyphs = () =>
page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const probe = document.createElement('span');
probe.style.display = 'none';
hud.appendChild(probe);
const baseline = {
color: getComputedStyle(probe).color,
shadow: getComputedStyle(probe).textShadow,
};
probe.remove();
const glyphs = [
'.hud-mode',
'.hud-system',
'.hud-summary-label',
'.hud-summary',
'.hud-orbital',
'#hud-mgrs',
'#hud-coll',
].map((selector) => {
const el = hud.querySelector(selector);
const style = getComputedStyle(el);
let opacity = 1;
for (
let current = el;
current && current !== hud;
current = current.parentElement
)
opacity *= Number(getComputedStyle(current).opacity);
return {
selector,
color: style.color,
shadow: style.textShadow,
opacity,
};
});
return { baseline, glyphs };
});
const readUnexpectedBackings = () =>
page.evaluate(() =>
[
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id ? `#${el.id}` : `.${el.className}`,
background: style.backgroundColor,
shadow: style.boxShadow,
transition: `${style.transitionProperty} ${style.transitionDuration}`,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
),
);
async function settleTextBackings(enabled) {
// The pre-existing active-style value transitions all properties for 300ms.
// Wait for its backing to finish changing before inspecting or capturing it.
await page
.waitForFunction(
(scopeEnabled) => {
const expected = scopeEnabled
? 'rgba(0, 0, 0, 0)'
: 'rgba(0, 0, 0, 0.7)';
return [
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
].every(
(el) =>
getComputedStyle(el).backgroundColor === expected &&
getComputedStyle(el).boxShadow === 'none',
);
},
{ timeout: 2_000 },
enabled,
)
.catch(() => null);
}
async function openApplication(url) {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
// This browser has a fresh profile. Start without authored state so the
// first assertion exercises the defaults rather than a share-link restore.
url.hash = '';
await openApplication(url);
const fresh = await readScope();
check(
'bare-URL fresh boot opens Scope ON with 100% feather',
fresh.enabled &&
fresh.checked &&
!fresh.disabled &&
fresh.feather === 1 &&
fresh.value === 100 &&
fresh.readout === '100%' &&
fresh.cornerAlpha > 0,
fresh,
);
// Use a known city view for canvas and readability checks below.
url.hash =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
// Changing only the hash does not rerun startup restoration. Leave the app
// document first so this fixture is applied by a new application boot.
await page.goto('about:blank');
await openApplication(url);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.hud.setMode('on');
manager.setStyle('normal');
});
const initial = await readScope();
check(
'Austin fixture restores Scope ON and 100% feather',
initial.enabled &&
initial.checked &&
!initial.disabled &&
initial.feather === 1 &&
initial.value === 100 &&
initial.readout === '100%' &&
initial.cornerAlpha > 0,
initial,
);
const name = await page.$eval('#scope-toggle', (el) => ({
type: el.type,
label: el.labels?.[0]?.textContent.trim(),
}));
check(
'Scope uses a native checkbox with a visible label',
name.type === 'checkbox' && name.label?.includes('Scope'),
name,
);
await page.click('#scope-toggle');
const off = await readScope();
check(
'click disables Scope and its feather slider',
!off.enabled &&
!off.checked &&
off.disabled &&
off.feather === 1 &&
off.cornerAlpha === 0,
off,
);
await page.focus('#scope-toggle');
await page.keyboard.press('Space');
const on = await readScope();
check(
'Space re-enables the native checkbox without losing feather',
on.enabled &&
on.checked &&
!on.disabled &&
on.feather === 1 &&
on.cornerAlpha > 0,
on,
);
const paintedAlpha = [];
for (const pct of [0, 11, 100, 200]) {
await page.$eval(
'#scope-feather-slider',
(el, value) => {
el.value = String(value);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
pct,
);
const state = await readScope();
paintedAlpha.push(state.radialAlpha);
check(
`feather ${pct}% reaches the mask and readout`,
state.value === pct &&
state.feather === pct / 100 &&
state.readout === `${pct}%`,
state,
);
}
check(
'painted gradient widens beyond the old 100% ceiling',
paintedAlpha[0] === 0 &&
paintedAlpha[1] === 0 &&
paintedAlpha[2] > 0 &&
paintedAlpha[3] > paintedAlpha[2],
paintedAlpha,
);
await page.focus('#scope-feather-slider');
await page.keyboard.press('Home');
check('native Home reaches the hard edge', (await readScope()).feather === 0);
await page.keyboard.press('End');
check(
'native End reaches the expanded 200% maximum',
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
`Scope OFF and 200% survive ${layout}`,
!state.enabled &&
!state.checked &&
state.disabled &&
state.feather === 2 &&
state.value === 200 &&
state.cornerAlpha === 0,
state,
);
}
const restored = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const links = manager.shareLinkManager;
manager._syncShareState();
const params = links._buildHashParams();
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
await manager._visualSettings.restoreShareState({
scopeEnabled: encoded.enabled === '1',
scopeFeatherPct: Number(encoded.feather),
});
const shared = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: true, featherPct: 200 },
});
const scene = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: false, featherPct: 200 },
});
return { encoded, shared, scene };
});
check(
'share state serializes and restores Scope OFF with 200%',
restored.encoded.enabled === '0' &&
restored.encoded.feather === '200' &&
restored.shared.enabled === false &&
restored.shared.featherPct === 200,
restored,
);
check(
'scene state restores Scope ON with 200%',
restored.scene.enabled === true && restored.scene.featherPct === 200,
restored,
);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true, featherPct: 100 },
}),
);
const transitioning = await readUnexpectedBackings();
if (transitioning.length)
console.log(
`[INFO] ${layout}: waiting for existing style transitions ${JSON.stringify(transitioning)}`,
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
const original = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const wrappers = [...hud.querySelectorAll('.hud-readout')];
const scene = [...document.querySelectorAll('.scene-text-holdout')];
const unbacked = [...wrappers, ...scene].every(
(el) =>
getComputedStyle(el).backgroundColor === 'rgba(0, 0, 0, 0)' &&
getComputedStyle(el).boxShadow === 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked,
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
};
});
original.unexpectedBackings = await readUnexpectedBackings();
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent &&
original.operatorFrame,
original,
);
check(
`${layout}: original HUD color, glow and metadata alpha remain intact`,
originalGlyphs.glyphs.every(
(glyph) =>
glyph.color === originalGlyphs.baseline.color &&
glyph.shadow === originalGlyphs.baseline.shadow,
) &&
originalGlyphs.glyphs.find(
(glyph) => glyph.selector === '.hud-summary-label',
).opacity === 0.55 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
.opacity === 0.7 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
.opacity === 0.5,
originalGlyphs,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-on-${layout}.png`,
),
});
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
const backedGlyphs = await readHudGlyphs();
check(
`${layout}: Scope OFF adds backing without changing glyph styles`,
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
backedGlyphs,
);
const coverage = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
const missing = [];
let visible = 0;
let node;
while ((node = walker.nextNode())) {
if (
!node.textContent.trim() ||
node.parentElement.closest('.hud-bracket')
)
continue;
const range = document.createRange();
range.selectNodeContents(node);
if (
![...range.getClientRects()].some(
(rect) => rect.width > 0 && rect.height > 0,
)
)
continue;
visible += 1;
const backing = node.parentElement.closest('.hud-readout');
const color = backing && getComputedStyle(backing).backgroundColor;
if (
!backing ||
color !== 'rgba(0, 0, 0, 0.7)' ||
getComputedStyle(backing).boxShadow !== 'none' ||
backing.parentElement.closest('.hud-readout')
)
missing.push(node.textContent.trim());
}
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
);
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
const overlaps = [];
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
const siblings = wrapped.filter((el) => el.parentElement === parent);
for (let i = 1; i < siblings.length; i += 1) {
const previous = siblings[i - 1].getBoundingClientRect();
const current = siblings[i].getBoundingClientRect();
const horizontal =
Math.min(previous.right, current.right) -
Math.max(previous.left, current.left);
const vertical =
Math.min(previous.bottom, current.bottom) -
Math.max(previous.top, current.top);
// Horizontal bars share a vertical band. Only intersecting text boxes
// can stack their backing alpha; allow subpixel layout rounding.
if (horizontal > 0.5 && vertical > 0.5) {
overlaps.push({
parent: parent.className,
previous: siblings[i - 1].innerText,
current: siblings[i].innerText,
vertical,
});
}
}
}
const lineIds = [
'hud-mgrs',
'hud-latlon',
'hud-gsd',
'hud-alt',
'hud-timestamp',
'hud-bottom-line',
];
const multiline = lineIds.filter((id) => {
const el = document.getElementById(id);
if (!el.getClientRects().length) return false;
const range = document.createRange();
range.selectNodeContents(el);
return (
range.getBoundingClientRect().height >
parseFloat(getComputedStyle(el).lineHeight) * 1.5
);
});
const edges = [...hud.querySelectorAll('.hud-edge')]
.filter((el) => getComputedStyle(el).display !== 'none')
.map((el) => {
const box = el.querySelector('.hud-readout').getBoundingClientRect();
return (
getComputedStyle(el).transform !== 'none' &&
box.height > box.width &&
Number(getComputedStyle(el).opacity) === 1
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
);
return {
scopeOff: document.documentElement.dataset.scopeMask === 'off',
visible,
missing,
empty,
overlaps,
multiline,
edges,
broadBackings: broadBackings.length,
};
});
check(
`${layout}: all visible HUD text has one backing`,
coverage.scopeOff &&
coverage.visible > 0 &&
coverage.missing.length === 0 &&
coverage.empty === 0 &&
coverage.broadBackings === 0,
coverage,
);
check(
`${layout}: adjacent readout backings do not overlap`,
coverage.overlaps.length === 0,
coverage,
);
check(
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
coverage,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-off-${layout}.png`,
),
});
}
}
const details = await page.evaluate(() => {
const summary = document.getElementById('hud-summary');
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
const ais = document.getElementById('hud-ais-vessel');
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
return {
summaryClipped:
summary.scrollWidth > summary.clientWidth &&
getComputedStyle(summary).textOverflow === 'ellipsis',
aisMultiline:
getComputedStyle(ais).whiteSpace === 'pre-line' &&
ais.textContent.split('\n').length === 2 &&
ais.getBoundingClientRect().height + 0.5 >=
parseFloat(getComputedStyle(ais).lineHeight) * 2,
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
};
});
check(
'summary clipping, multiline AIS and red REC dot retain their behavior',
details.summaryClipped &&
details.aisMultiline &&
details.recColor === 'rgb(255, 51, 51)',
details,
);
await page.setViewport({ width: 390, height: 844 });
await page.select('#hud-layout-select', 'minimal');
const narrow = await page.evaluate(() =>
[...document.querySelectorAll('#intel-hud .hud-readout')]
.filter(
(el) =>
el.getClientRects().length &&
getComputedStyle(el).display !== 'contents',
)
.every((el) => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth;
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+57 -55
View File
@@ -25,12 +25,13 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
* that mode does not qualify live source access or quota accounting.
* Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key;
* that mode does not qualify geographic terrain, live source access or quota accounting.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import { captureQaScreenshot } from './qa-chrome.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -44,10 +45,12 @@ const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4410');
const appUrl = new URL(getOpt('--url', 'http://localhost:4410'));
appUrl.searchParams.set('welcome', '0');
const APP_URL = appUrl.href;
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Enable traffic + teleport, then poll the layer until settled. */
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
// The app deliberately stops rendering hidden pages. Programmatic camera
// changes do not activate a headful window the way a real UI click does.
await page.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'visible');
return page.evaluate(async (v, minC, tS) => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
@@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
});
let s = null;
for (let i = 0; i < tS; i++) {
if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused');
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
// Retained roads from the previous city can satisfy count/loading before
@@ -122,8 +130,8 @@ async function main() {
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exitCode = 2;
@@ -135,8 +143,10 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
'--disable-dev-shm-usage', '--disable-web-security',
'--no-sandbox', '--disable-setuid-sandbox',
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
'--disable-backgrounding-occluded-windows',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
],
@@ -144,24 +154,22 @@ async function main() {
let exitCode = 0;
try {
const page = await browser.newPage();
const onFixture = (request) => {
const response = fixtureResponse(request);
if (response) void request.respond(response); else void request.continue();
};
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
let page = await browser.newPage();
// Keep this visible-page regression deterministic even if the OS starts
// Chrome minimized. Use browser focus emulation, not patched DOM getters.
const focusSession = await page.createCDPSession();
await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.setViewport({ width: 1440, height: 900 });
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
// Track flow-tile requests for the (iii) budget check.
const flowRequests = [];
page.on('request', (req) => {
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
});
const trafficLogs = [];
page.on('console', (msg) => {
const t = msg.text();
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
});
const pageErrors = [];
if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -194,7 +202,7 @@ async function main() {
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
await sleep(1200);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
}
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
@@ -227,7 +235,7 @@ async function main() {
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL });
} else {
record('PARAM: hide mode renders zero sim (white) dots', null,
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
@@ -235,30 +243,26 @@ async function main() {
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
trafficLogs.length = 0;
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
{
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
let ok = false; let detail = 'no fetch log captured';
if (fetchLine) {
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
if (m) {
const [s, w, n, e] = m.slice(1).map(Number);
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
}
const bounds = oblique?.roadBounds;
let ok = false; let detail = 'no road bounds published';
if (bounds) {
const cLat = (bounds.south + bounds.north) / 2;
const cLon = (bounds.west + bounds.east) / 2;
const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180));
ok = dKm > 1 && dKm <= 13;
detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`;
}
record('C4: oblique fetch box centers on the look-at point', ok, detail);
if (!ok) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL });
}
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json());
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
const ok = grew >= 0 && grew <= flowRequests.length;
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
@@ -268,25 +272,22 @@ async function main() {
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
if (FIXTURES) page.off('request', onFixture);
await page.setRequestInterception(true);
// Keyless is an independent fresh-session contract. Close the keyed page
// so its workers, terrain work and intercepted cache cannot leak into it.
await page.close();
page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const keylessFocus = await page.createCDPSession();
await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
const keylessFlowReqs = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/tomtom/status')) {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
return;
}
if (url.includes('/api/tomtom/flow/')) {
keylessFlowReqs.push(url);
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
return;
}
const response = fixtureResponse(req);
if (response) { void req.respond(response); return; }
try { req.continue(); } catch { /* already handled */ }
await installTrafficRoutes(page, {
origin: appUrl.origin, fixtures: FIXTURES, keyless: true,
onFlow: url => keylessFlowReqs.push(url),
onError: error => pageErrors.push(error.message),
});
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
@@ -316,8 +317,9 @@ async function main() {
exitCode = 1;
}
await sleep(1000);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
}
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
+5
View File
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
return {
pageVisibility: document.visibilityState,
total: state._vehicles.size,
visible: state._visible.size,
moving: state._moving.size,
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
};
});
console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.pageVisibility !== 'visible')
throw new Error(
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
);
if (result.renderLoop === false || result.renderErrors.length)
throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+5
View File
@@ -1,6 +1,7 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = {
id: 'boston',
feedId: 'mbta',
label: 'Boston (MBTA)',
lat: 42.3554,
lon: -71.0605,
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
BOSTON,
{
id: 'austin',
feedId: 'capmetro-austin',
label: 'Austin (CapMetro)',
lat: 30.2672,
lon: -97.7431,
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
},
{
id: 'minneapolis',
feedId: 'metrotransit-msp',
label: 'Minneapolis (Metro Transit)',
lat: 44.9778,
lon: -93.265,
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
},
{
id: 'helsinki',
feedId: 'hsl-helsinki',
label: 'Helsinki (HSL)',
lat: 60.1699,
lon: 24.9384,
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
},
{
id: 'oslo',
feedId: 'entur-norway',
label: 'Oslo (Entur)',
lat: 59.9139,
lon: 10.7522,
+2 -2
View File
@@ -1,6 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -307,7 +307,7 @@ try {
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass =
+7 -6
View File
@@ -1,3 +1,4 @@
import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed.
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-fleet-${count}.jpg`,
type: 'jpeg',
quality: 85,
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
JSON.stringify({ pixels, contrast }),
{ unexercised: pixels.length < 6 },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
type: 'jpeg',
quality: 90,
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
target.key,
);
await wait(500);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg',
quality: 90,
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
);
}
await wait(300);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg',
quality: 90,
@@ -579,7 +580,7 @@ export async function runBostonLive({
JSON.stringify(selection),
{ unexercised: !target },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
type: 'jpeg',
quality: 90,
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
stable: pixels.stable[i],
eligible: pixels.eligible[i],
}));
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg',
quality: 95,
+67 -20
View File
@@ -36,10 +36,11 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
import {
reduceAnchor,
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1920,1080',
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
// This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = [];
@@ -286,11 +292,13 @@ page.on('request', (request) => {
});
const shot = (name, caption) =>
page
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
.then(() => {
console.log(` shot ${name} — ${caption}`);
});
captureQaScreenshot(page, {
path: `${SHOTS}/${name}`,
type: 'jpeg',
quality: 82,
}).then(() => {
console.log(` shot ${name} — ${caption}`);
});
const view = (city) =>
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
@@ -389,22 +397,42 @@ try {
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
});
}, city);
await page.waitForFunction(
() =>
(window.__godsEyeView.dataManager.layers
.get('transit')
.module.getStats().count || 0) > 0,
{ timeout: 90_000 },
);
const arrived = await page
.waitForFunction(
(feedId) =>
[
...window.__godsEyeView.dataManager.layers
.get('transit')
.module._transitStateForTest()
._vehicles.keys(),
].some((key) => key.startsWith(`${feedId}:`)),
{ timeout: 90_000 },
city.feedId,
)
.then(
() => true,
() => false,
);
if (!arrived) {
check(
`${city.label}: live feed arrived`,
false,
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
{ unexercised: true },
);
continue;
}
await wait(9_000);
const fleet = await page.evaluate(() => {
const fleet = await page.evaluate((feedId) => {
const layer =
window.__godsEyeView.dataManager.layers.get('transit').module;
return {
count: layer._transitStateForTest()._vehicles.size,
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
(key) => key.startsWith(`${feedId}:`),
).length,
stats: layer.getStats(),
};
});
}, city.feedId);
check(
`${city.label}: vehicles are rendered`,
fleet.count >= 20,
@@ -426,6 +454,7 @@ try {
.module._transitStateForTest();
let best = null;
for (const entry of state._vehicles.values()) {
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
if (!best || d < best.d)
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
@@ -1607,6 +1636,7 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
];
const frames = [];
const observedPolls = new Set();
const publicationsBefore = state._cardPublications || 0;
let headSamples = 0,
headErrorPx = 0,
@@ -1623,6 +1653,13 @@ try {
const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (
entry.marker.show !== false &&
rect &&
Number.isFinite(entry.pollSeq)
) {
observedPolls.add(entry.pollSeq);
}
frames.push({
t: performance.now() - t0,
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
@@ -1668,7 +1705,15 @@ try {
headSamples++;
}
}
if (performance.now() - t0 >= durationMs) finish();
const elapsed = performance.now() - t0;
// Keep at least 20 seconds of motion and require an observed poll
// transition. A fixed 20s wall-clock window can miss a 15s poll
// when an earlier refresh is still settling.
if (
(elapsed >= 20_000 && observedPolls.size >= 2) ||
elapsed >= durationMs
)
finish();
};
let remove;
const finish = () => {
@@ -1676,6 +1721,7 @@ try {
remove?.();
resolve({
selected: true,
elapsedMs: performance.now() - t0,
frames,
textPublishes:
(state._cardPublications || 0) - publicationsBefore,
@@ -1690,7 +1736,7 @@ try {
});
},
target.key,
20_000,
3 * TRANSIT_POLL_MS,
);
}
check(
@@ -1711,8 +1757,9 @@ try {
);
check(
'card text publication stays throttled while the anchor runs at frame rate',
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
`${anchor.textPublishes} text publications in 20 s`,
anchor.textPublishes > 0 &&
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
);
} else {
check(
+21 -4
View File
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
}
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
export function resolveOpenSkyCredentialsFile({
authoritativeEnvironment = false,
environment = process.env,
rootDir = ROOT,
} = {}) {
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
const value = authoritativeEnvironment && environmentDefines
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
return isConfiguredValue(value.trim());
}
function hasKeychainItem(service, account) {
if (process.platform !== 'darwin') return false;
const result = spawnSync('security', [
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
export function buildCapabilitySummary(
credentials,
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
) {
const configured = (name) => credentials[name]?.configured === true;
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
? requestedOpenSkyMode
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
const flights = openSkyMode === 'anon'
? 'OpenSky keyless anonymous access (rate-limited)'
: openSkyMode === 'basic'
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
: openSkyMode === 'auto'
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
: hasOAuthCredentials
: hasClientPair
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: hasOAuthCredentials
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
: 'OpenSky keyless anonymous access (rate-limited)';
const route = selectMapStartupRoute({
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
credentials,
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
}),
};
}
+161 -42
View File
@@ -1,56 +1,175 @@
import { readFileSync } from 'node:fs';
import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js';
import { tilesForBounds } from '../src/data/tomtomTiles.js';
import { PbfWriter } from 'pbf';
const tile = readFileSync(
new URL(
'../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
import.meta.url,
),
);
const corsHeaders = { 'access-control-allow-origin': '*' };
const json = (body) => ({
status: 200,
headers: corsHeaders,
contentType: 'application/json',
body: JSON.stringify(body),
});
/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */
// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2,
// offset -1000, followed by child availability and a uniform land mask.
const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2);
for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2);
/** Synthetic, geographically aligned motor roads at both source zooms. */
export function trafficFixtureTile(zoom, flow = false) {
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation');
layer.writeVarintField(5, 4096);
layer.writeStringField(3, flow ? 'traffic_level' : 'class');
layer.writeMessage(
4,
(_, value) => {
if (flow) value.writeDoubleField(3, 0.6);
else value.writeStringField(1, 'primary');
},
null,
);
if (flow) {
layer.writeStringField(3, 'road_type');
layer.writeMessage(
4,
(_, value) => value.writeStringField(1, 'Major road'),
null,
);
}
// z12 has four times the span of z14. Keep the same world spacing,
// and cover only alternate roads with flow to exercise hide/sim controls.
const step = 256 * 2 ** (zoom - 14);
for (let y = step; y < 4096; y += step) {
if (flow && (y / step) % 2) continue;
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(3, 2);
feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]);
feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]);
},
null,
);
}
},
null,
);
return Buffer.from(writer.finish());
}
/** Intercept the current vector sources; never fall through to live roads. */
export function trafficFixtureResponse(request) {
const url = new URL(request.url());
if (url.hostname === 'terrain.reearth.land') {
if (url.pathname.endsWith('/layer.json'))
return json({
tilejson: '2.1.0',
format: 'heightmap-1.0',
version: 'qa-flat',
scheme: 'tms',
projection: 'EPSG:4326',
minzoom: 0,
maxzoom: 0,
tiles: ['{z}/{x}/{y}.terrain?v={version}'],
available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]],
attribution:
'Synthetic flat terrain for Traffic QA; not live elevation data',
});
if (/\/0\/[01]\/0\.terrain$/.test(url.pathname))
return {
status: 200,
contentType: 'application/octet-stream',
headers: corsHeaders,
body: flatTerrain,
};
throw new Error(`Unexpected terrain fixture request: ${url.pathname}`);
}
if (url.pathname === '/api/tomtom/status')
return json({ hasKey: true, dailyCount: 0 });
if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname))
return { status: 200, contentType: 'application/x-protobuf', body: tile };
if (url.pathname !== '/api/overpass') return null;
const query = new URLSearchParams(request.postData()).get('data') || '';
if (!query.includes('highway')) return null;
const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/);
if (!match) throw new Error('Missing road fixture viewport');
const [south, west, north, east] = match.slice(1).map(Number);
const inside = ([lon, lat]) =>
lat >= south && lat <= north && lon >= west && lon <= east;
const segments = tilesForBounds({ south, west, north, east }, 12).flatMap(
({ z, x, y }) => decodeFlowTile(tile, z, x, y),
const flow = url.pathname.match(
/^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/,
);
const elements = segments
.filter((segment) => segment.coords.some(inside))
.slice(0, 240)
.map((segment, index) => ({
type: 'way',
id: index + 1,
tags: { highway: 'primary', oneway: 'yes' },
geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })),
}));
const latitude = (south + north) / 2,
longitude = (west + east) / 2;
elements.push({
type: 'way',
id: 99999,
tags: { highway: 'residential' },
geometry: [
{ lat: latitude, lon: longitude },
{ lat: latitude + 0.003, lon: longitude + 0.003 },
],
if (flow)
return {
status: 200,
contentType: 'application/x-protobuf',
headers: corsHeaders,
body: trafficFixtureTile(Number(flow[1]), true),
};
if (url.hostname !== 'tiles.openfreemap.org') return null;
if (url.pathname === '/planet')
return json({
tilejson: '3.0.0',
minzoom: 0,
maxzoom: 14,
tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'],
});
const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/);
if (!road)
throw new Error(`Unrecognized road fixture request: ${url.pathname}`);
return {
status: 200,
contentType: 'application/x-protobuf',
headers: corsHeaders,
body: trafficFixtureTile(Number(road[1])),
};
}
/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */
export async function installTrafficRoutes(
page,
{
origin,
fixtures = false,
keyless = false,
onFlow = () => {},
onError = () => {},
},
) {
const session = await page.createCDPSession();
const patterns = [
`${origin}/api/tomtom/status*`,
`${origin}/api/tomtom/flow/*`,
...(fixtures
? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*']
: []),
].map((urlPattern) => ({ urlPattern, requestStage: 'Request' }));
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
try {
const url = new URL(request.url);
let response;
if (keyless && url.pathname === '/api/tomtom/status') {
response = json({ hasKey: false });
} else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) {
onFlow(request.url);
response = { ...json({ error: 'no_key' }), status: 503 };
} else if (fixtures) {
response = trafficFixtureResponse({ url: () => request.url });
}
if (!response) {
await session.send('Fetch.continueRequest', { requestId });
return;
}
await session.send('Fetch.fulfillRequest', {
requestId,
responseCode: response.status,
responseHeaders: Object.entries({
'content-type': response.contentType,
...response.headers,
}).map(([name, value]) => ({ name, value })),
body: Buffer.from(response.body).toString('base64'),
});
} catch (error) {
if (page.isClosed()) return;
onError(error);
await session
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
.catch(() => {});
}
});
return json({ elements });
await session.send('Fetch.enable', { patterns });
return session;
}
+3
View File
@@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([
// — camera verbs (tools #23/#24) —
{ phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] },
{ phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] },
{ phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] },
{ phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] },
{ phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] },
{ phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] },
{ phrase: 'Fly the route we just drew', expect: 'fly_route' },
+72 -59
View File
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import os from 'node:os';
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import {
coalesceProxyRequest,
@@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) {
}
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
let _openskyAuthWarned = false;
/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */
let _openskyCredentialsFileWarned = false;
/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */
let _openskyBasicRetiredWarned = false;
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
let _openskyAuthModeWarned = false;
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
@@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// current instead of coasting a stale worldwide frame indefinitely.
const OPENSKY_SOURCE_STALE_MS = 120_000;
/**
* The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and
* OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account
* page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home
* directory). The file is read on each token refresh, so a replaced file is
* picked up without a restart.
*
* @param {Record<string, string|undefined>} [env]
* @param {(path: string) => string} [readFile]
* @returns {{ clientId: string, clientSecret: string } | null}
*/
export function openSkyClientCredentials(
env = process.env,
readFile = (path) => fs.readFileSync(path, 'utf8'),
) {
const clientId = String(env.OPENSKY_CLIENT_ID || '').trim();
const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim();
if (clientId && clientSecret) return { clientId, clientSecret };
const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim();
if (!file) return null;
const path = file.replace(/^~(?=$|[\\/])/, os.homedir());
let raw;
try {
raw = JSON.parse(readFile(path));
} catch {
if (!_openskyCredentialsFileWarned) {
console.warn(
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
);
_openskyCredentialsFileWarned = true;
}
return null;
}
const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim();
const fileSecret = String(
raw?.clientSecret ?? raw?.client_secret ?? '',
).trim();
return fileId && fileSecret
? { clientId: fileId, clientSecret: fileSecret }
: null;
}
/**
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
*
@@ -174,9 +222,9 @@ export async function getOpenSkyToken() {
// return the same promise instead of issuing a duplicate token request
if (_openskyTokenPromise) return _openskyTokenPromise;
const clientId = process.env.OPENSKY_CLIENT_ID;
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
const credentials = openSkyClientCredentials();
if (!credentials) return null;
const { clientId, clientSecret } = credentials;
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
_openskyTokenPromise = (async () => {
@@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
* - 'oauth' (default) — client_credentials bearer token
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
* - 'auto' — try OAuth first, fall back to Basic, then anon
* - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and
* password) on 2026-03-18, so this requests anonymously and
* warns once
* - 'auto' — try OAuth first, then anon
* - 'anon' — no credentials
*
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
@@ -524,21 +574,21 @@ export function openSkyProxy() {
_openskySnapshotInFlight,
'global',
async () => {
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
// OpenSky no longer accepts a Basic header, so sending
// OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask
// anonymously and say once what to configure instead.
if (!_openskyBasicRetiredWarned) {
console.warn(
'[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
);
_openskyBasicRetiredWarned = true;
}
reason = 'basic_retired';
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
@@ -554,31 +604,12 @@ export function openSkyProxy() {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
reason = 'missing_oauth_creds';
}
}
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
const { upstream, body } = await fetchOpenSkyStates(headers);
const sourceEpochMs = upstream.ok
? openSkySourceEpochMs(body)
@@ -586,12 +617,6 @@ export function openSkyProxy() {
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
@@ -627,12 +652,11 @@ export function openSkyProxy() {
body,
usedMode,
reason,
hasBasicCreds,
sourceEpochMs,
};
},
);
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
let { upstream, body, usedMode, reason, sourceEpochMs } =
await refresh.promise;
if (
upstream.ok &&
@@ -684,35 +708,24 @@ export function openSkyProxy() {
}
if (upstream.status === 401 || upstream.status === 403) {
if (requestedMode === 'basic' && !hasBasicCreds) {
if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
});
reason = 'missing_basic_creds';
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
});
reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') {
body = JSON.stringify({
error: 'OpenSky auth invalid. Username/password were rejected.',
});
reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth client credentials were rejected.',
});
reason = 'oauth_invalid_credentials';
} else if (requestedMode === 'auto' && !hasBasicCreds) {
} else if (requestedMode === 'auto') {
body = JSON.stringify({
error:
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.',
});
reason = 'missing_oauth_and_basic_creds';
reason = 'missing_oauth_creds';
} else {
body = JSON.stringify({
error: 'OpenSky auth required.',
@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { openSkyClientCredentials } from './opensky.js';
const file = (contents) => (filePath) => {
file.read = filePath;
if (contents instanceof Error) throw contents;
return contents;
};
test('OpenSky client env vars come first', () => {
const read = () => assert.fail('the file is not read when env vars are set');
assert.deepEqual(
openSkyClientCredentials(
{
OPENSKY_CLIENT_ID: ' id ',
OPENSKY_CLIENT_SECRET: 'secret',
OPENSKY_CREDENTIALS_FILE: 'credentials.json',
},
read,
),
{ clientId: 'id', clientSecret: 'secret' },
);
});
test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => {
const env = {
OPENSKY_CLIENT_ID: '',
OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json',
};
// The file OpenSky's account page downloads uses camelCase keys.
assert.deepEqual(
openSkyClientCredentials(
env,
file('{"clientId":"cid","clientSecret":"csecret"}'),
),
{ clientId: 'cid', clientSecret: 'csecret' },
);
assert.equal(
path.normalize(file.read),
path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')),
);
assert.deepEqual(
openSkyClientCredentials(
env,
file('{"client_id":"cid","client_secret":"csecret"}'),
),
{ clientId: 'cid', clientSecret: 'csecret' },
);
});
test('a missing, unreadable or incomplete credentials file means no client', () => {
assert.equal(openSkyClientCredentials({}, file('{}')), null);
const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' };
const missing = Object.assign(new Error('nope'), { code: 'ENOENT' });
assert.equal(openSkyClientCredentials(env, file(missing)), null);
assert.equal(openSkyClientCredentials(env, file('not json')), null);
assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null);
});
test('credential file warnings never expose file contents or read errors', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (message) => warnings.push(message));
for (const contents of [
'{"clientId":"id","clientSecret":FAKE_SECRET_123}',
new Error('could not read FAKE_SECRET_123'),
]) {
const { openSkyClientCredentials: resolve } = await import(
`./opensky.js?warning=${warnings.length}`
);
const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' };
assert.equal(resolve(env, file(contents)), null);
assert.equal(resolve(env, file(contents)), null);
}
assert.deepEqual(warnings, [
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
'[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.',
]);
});
+13 -8
View File
@@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js';
* own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently.
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI.
* /api/military/track?hex=<hex> — compatibility refusal for the retired,
* undocumented ADSB.lol trace route. The standalone military source uses
* locally accumulated trails; provider-supplied history remains optional.
*/
export function trackBackfillProxies() {
const TRACK_CACHE_MS = 60000;
@@ -107,10 +106,16 @@ export function trackBackfillProxies() {
);
return;
}
await proxyJson(
res,
`lol:${hex}`,
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`,
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Retry-After', '1800');
res.end(
JSON.stringify({
error:
'Military history unavailable; locally accumulated trails remain available',
code: 'history_unavailable',
}),
);
} catch (error) {
res.statusCode = 502;
+6 -1
View File
@@ -43,6 +43,11 @@ function number(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in
// knots); convert so `speedKt` means what it says.
function knotsFromMph(mph) {
return mph === null ? null : Math.round(mph * 0.868976);
}
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
// up to about half an hour before; allow that, but not a clock far ahead.
const EARLY_ADVISORY_MS = 90 * 60_000;
@@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) {
pressureHpa: number(raw.pressure, 800, 1100),
movement: {
directionDegrees: number(raw.movementDir, 0, 360),
speedKt: number(raw.movementSpeed, 0, 200),
speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)),
},
advisoryUrl: officialLink(forecast.url),
outlookUrl: raw.id.startsWith('al')
+1
View File
@@ -16,6 +16,7 @@ function realtimeInstructions(
'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.',
'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.',
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.',
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
+3 -3
View File
@@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = {
},
move_camera: {
description:
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
$position: 1,
parameters: {
properties: {
direction: {
description:
'Required except for orbit (defaults right/clockwise) and stop.',
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
$position: 2,
},
mode: {
description:
'once = bounded eased nudge (default); continuous = until stop/manual input.',
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
$position: 2,
},
},
+32 -59
View File
@@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) {
readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
loadUpstream: async () => {
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
return {
status: 503,
contentType: 'application/json',
retryAfterMs: 2000,
body: JSON.stringify({
error: 'Overpass proxy busy — try again shortly',
}),
};
_overpassConcurrent++;
try {
const payload = await fetchOverpassPayload(safeBody);
if (overpassPayloadIsData(payload)) {
const entry = { ...payload, cachedAt: Date.now() };
_overpassCache.set(cacheKey, entry);
trimOverpassCache();
writeOverpassDisk(cacheKey, entry);
}
return payload;
} finally {
_overpassConcurrent--;
}
},
});
if (preflight.source === 'RATE_LIMITED') {
res.writeHead(429, {
@@ -173,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) {
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
if (preflight.source !== 'UPSTREAM') {
// A coalesced caller sees the same failure as the original request
// and must get the same last-good fallback, not the raw refusal.
if (!overpassPayloadIsData(preflight.payload)) {
const stale = await readStaleOverpass(cacheKey);
if (stale) {
sendOverpassResponse(res, stale, 'STALE');
return;
}
}
if (preflight.source === 'DISK') {
_overpassCache.set(cacheKey, preflight.payload);
trimOverpassCache();
}
sendOverpassResponse(res, preflight.payload, preflight.source);
return;
}
// From here onward the request is genuinely upstream-bound and has
// consumed one local limiter slot. Cache and dedupe hits above do not.
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
res.writeHead(503, {
'Content-Type': 'application/json',
'Retry-After': '2',
});
res.end(
JSON.stringify({
error: 'Overpass proxy busy — try again shortly',
}),
);
return;
}
_overpassConcurrent += 1;
const requestPromise = fetchOverpassPayload(safeBody)
.then((payload) => {
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
// refusal was written to memory AND disk — and boundary-class
// queries hold a month-long TTL, so a single 406 outlived the
// outage that caused it.
if (overpassPayloadIsData(payload)) {
const entry = { ...payload, cachedAt: Date.now() };
_overpassCache.set(cacheKey, entry);
trimOverpassCache();
writeOverpassDisk(cacheKey, entry);
}
return payload;
})
.finally(() => {
_overpassConcurrent -= 1;
_overpassInFlight.delete(cacheKey);
});
_overpassInFlight.set(cacheKey, requestPromise);
const payload = await requestPromise;
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
// error): last-good roads beat an empty layer — serve stale from
// memory or disk at ANY age before surfacing the failure.
if (!overpassPayloadIsData(payload)) {
if (!overpassPayloadIsData(preflight.payload)) {
const stale = await readStaleOverpass(cacheKey);
if (stale) {
sendOverpassResponse(res, stale, 'STALE');
return;
}
}
sendOverpassResponse(res, payload, 'MISS');
// Preflight already published the disk hit to memory; only trim.
if (preflight.source === 'DISK') trimOverpassCache();
sendOverpassResponse(
res,
preflight.payload,
preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source,
);
} catch (e) {
// Every mirror threw (network-level). Same serve-stale rule.
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
+29 -13
View File
@@ -1,3 +1,4 @@
import { coalesceProxyRequest } from '../common/http.js';
import { isOverpassBoundaryQuery } from './query.js';
import {
OVERPASS_BOUNDARY_DISK_TTL_MS,
@@ -78,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) {
/**
* Resolve every cache/coalescing layer before admitting a request to the local
* upstream rate limiter. The injected limiter callback is invoked exactly once
* for a complete cache miss and never for memory, in-flight, or disk hits.
* for a complete cache miss and never for memory, in-flight, or disk hits. A
* caller that joined a reservation rejected by another client's limiter
* retries with its own limiter instead of inheriting that 429.
* Exported so the admission ordering can be tested without a Vite server.
*
* @param {object} options
@@ -87,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) {
* @param {Map<string, Promise<object>>} options.inFlight
* @param {()=>Promise<object|null>} options.readDisk
* @param {()=>boolean} options.allowUpstream
* @param {()=>Promise<object>} options.loadUpstream Fetch and publish the admitted result.
* @param {number} [options.now]
* @param {number} [options.cacheMs]
* @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>}
@@ -97,22 +101,34 @@ async function resolveOverpassPreflight({
inFlight,
readDisk,
allowUpstream,
loadUpstream = async () => null,
now = Date.now(),
cacheMs = OVERPASS_CACHE_MS,
}) {
const cached = memoryCache.get(cacheKey);
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
return { source: 'HIT', payload: cached };
for (;;) {
const cached = memoryCache.get(cacheKey);
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
return { source: 'HIT', payload: cached };
const pending = inFlight.get(cacheKey);
if (pending) return { source: 'INFLIGHT', payload: await pending };
const disk = await readDisk();
if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk };
return allowUpstream()
? { source: 'UPSTREAM', payload: null }
: { source: 'RATE_LIMITED', payload: null };
// Reserve synchronously before disk I/O. The owner retains the reservation
// through its own upstream admission, fetch and cache publication.
const request = coalesceProxyRequest(inFlight, cacheKey, async () => {
const disk = await readDisk();
if (overpassPayloadIsData(disk)) {
memoryCache.set(cacheKey, disk);
return { source: 'DISK', payload: disk };
}
if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null };
return { source: 'UPSTREAM', payload: await loadUpstream() };
});
const result = await request.promise;
if (!request.shared) return result;
// The owner's quota rejection belongs to the owner's client. The rejected
// reservation is released before joiners resume, so retry and let this
// caller own the next reservation (or join an admitted one) on its quota.
if (result.source === 'RATE_LIMITED') continue;
return { ...result, source: 'INFLIGHT' };
}
}
/** Return only last-good Overpass data, regardless of its age. */
+29 -23
View File
@@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js';
import {
normalizeRadioBrowserStation,
publicRadioStation,
cleanRadioText,
normalizeRadioTag,
} from './stations.js';
import {
radioMirrorOrigin,
@@ -25,6 +25,7 @@ import {
RADIO_USER_AGENT,
RADIO_UUID_RE,
RADIO_FALLBACK_MIRRORS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './constants.js';
async function mapRadioConcurrent(values, concurrency, mapper) {
const results = new Array(values.length);
@@ -177,29 +178,25 @@ export function createRadioProxyMiddleware({
const rows = await fetchPath(`/json/stations/search?${params}`);
if (!Array.isArray(rows))
throw new Error('Radio Browser catalog payload was not an array');
if (
!rows.every(
(row) =>
row &&
typeof row === 'object' &&
!Array.isArray(row) &&
typeof row.stationuuid === 'string' &&
typeof row.name === 'string' &&
(typeof row.url_resolved === 'string' ||
typeof row.url === 'string'),
)
)
throw new Error(
'Radio Browser catalog contained a malformed station row',
);
const stations = rows
const structuralRows = rows.filter(
(row) =>
row &&
typeof row === 'object' &&
!Array.isArray(row) &&
typeof row.stationuuid === 'string' &&
typeof row.name === 'string' &&
(typeof row.url_resolved === 'string' ||
typeof row.url === 'string'),
);
// Malformed rows are skipped individually, but counted so the
// catalog keeps a diagnostic for upstream schema drift. Policy
// rejections in normalization (offline, non-HTTPS, codec) are normal
// filtering and are not counted.
const rejectedRows = rows.length - structuralRows.length;
const stations = structuralRows
.map(normalizeRadioBrowserStation)
.filter(Boolean);
const requestedTag = cleanRadioText(tag, 80)
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const requestedTag = normalizeRadioTag(tag);
const requestedTagCovered =
!requestedTag ||
stations.some((station) =>
@@ -215,9 +212,10 @@ export function createRadioProxyMiddleware({
// also contain an accepted station tagged for the requested category.
succeeded: stations.length > 0 && requestedTagCovered,
stations,
rejectedRows,
};
} catch {
return { succeeded: false, stations: [] };
return { succeeded: false, stations: [], rejectedRows: 0 };
}
},
);
@@ -257,6 +255,10 @@ export function createRadioProxyMiddleware({
if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS)
healthReasons.push('station-coverage-below-policy');
const degraded = healthReasons.length > 0;
const rejectedStationCount = Math.min(
RADIO_REJECTED_STATION_COUNT_MAX,
outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0),
);
const coverage = {
successfulQueries,
totalQueries: queries.length,
@@ -271,6 +273,9 @@ export function createRadioProxyMiddleware({
degraded,
degradedReason: degraded ? healthReasons.join(',') : null,
coverage,
// Diagnostic only: it never fails health policy, so a usable generation
// with a few skipped rows is still admitted. The client reports it.
rejectedStationCount,
};
if (degraded && catalogCache) {
const error = new Error(
@@ -360,6 +365,7 @@ export function createRadioProxyMiddleware({
coverage: catalog.coverage || null,
acceptedGeneration: catalog.acceptedGeneration ?? null,
catalogInstance,
rejectedStationCount: catalog.rejectedStationCount ?? 0,
});
} catch (error) {
sendJson(res, 503, {
+3
View File
@@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil(
export const RADIO_USER_AGENT =
'GodsEyeView/1.0 (Radio Browser directory client)';
export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js';
// Upper bound for the structurally malformed upstream rows reported in the
// /stations envelope. The client layer validates against the same bound.
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
export const RADIO_FALLBACK_MIRRORS = Object.freeze([
'https://de1.api.radio-browser.info',
'https://de2.api.radio-browser.info',
+1
View File
@@ -1,5 +1,6 @@
export {
cleanRadioText,
normalizeRadioTag,
isNonGlobalIpv4,
publicRadioHttpsUrl,
normalizeRadioBrowserStation,
+59 -6
View File
@@ -1,6 +1,8 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import { providerCooldownMs } from './cooldown.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { readResponseTextCapped } from '../common/http.js';
/**
@@ -24,6 +26,18 @@ import { readResponseTextCapped } from '../common/http.js';
export function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const groups = new Set([
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]);
let cooldownUntil = 0;
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
@@ -64,7 +78,16 @@ export function celestrakProxy() {
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
// Group refreshes run in parallel: a later failure without Retry-After
// must never shorten a longer deadline an earlier one already set.
cooldownUntil = Math.max(
cooldownUntil,
Date.now() + providerCooldownMs(res),
);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
@@ -80,12 +103,20 @@ export function celestrakProxy() {
const group = String(req.url || '')
.replace(/^\//, '')
.split('?')[0];
if (!/^[a-z0-9-]+$/i.test(group)) {
// Keyless and needed to render the satellites layer: refuse cross-site
// browsers but keep the documented reverse-proxy deployment working.
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') {
res.writeHead(405);
res.end();
return;
}
if (!groups.has(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
}
const send = (status, body, cacheStatus) => {
const send = (status, body, cacheStatus, entry) => {
// Guard against a double-send (e.g. a throw AFTER a response already
// went out routing into the catch's send): writeHead after headersSent
// throws "Cannot set headers after they are sent".
@@ -93,6 +124,17 @@ export function celestrakProxy() {
res.writeHead(status, {
'Content-Type': 'text/plain',
'x-tle-cache': cacheStatus,
'Cache-Control': 'no-store',
...(entry
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
: {}),
...(cooldownUntil > 0 && Date.now() < cooldownUntil
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
),
}
: {}),
});
res.end(body);
};
@@ -104,7 +146,16 @@ export function celestrakProxy() {
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
send(200, entry.body, 'HIT');
send(200, entry.body, 'HIT', entry);
return;
}
if (Date.now() < cooldownUntil) {
send(
entry ? 200 : 502,
entry?.body || 'celestrak upstream cooling down',
entry ? 'STALE-ERROR' : 'NONE',
entry,
);
return;
}
// Stale or missing → refresh, single-flight per group.
@@ -118,6 +169,8 @@ export function celestrakProxy() {
return fresh;
})
.catch((err) => {
if (Date.now() >= cooldownUntil)
cooldownUntil = Date.now() + 60000;
console.warn(
'[celestrak-proxy] refresh failed — serving cache if any',
);
@@ -128,9 +181,9 @@ export function celestrakProxy() {
}
const fresh = await inflight.get(group);
if (fresh) {
send(200, fresh.body, 'MISS');
send(200, fresh.body, 'MISS', fresh);
} else if (entry) {
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty
} else {
send(502, 'celestrak fetch failed and no cache available', 'NONE');
}
+12
View File
@@ -0,0 +1,12 @@
/** Bound an upstream Retry-After delay (seconds or HTTP date). */
export function providerCooldownMs(response, now = Date.now()) {
const raw = response?.headers?.get('retry-after')?.trim();
const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN;
const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN;
const delay = Number.isFinite(seconds)
? seconds * 1000
: Number.isFinite(deadline)
? deadline - now
: 60000;
return Math.min(30 * 60000, Math.max(5000, delay));
}
+38 -2
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import { providerCooldownMs } from './cooldown.js';
import { promises as fsp } from 'node:fs';
import {
readResponseTextCapped,
@@ -30,6 +31,8 @@ export function rocketLaunchesProxy() {
'launch-library-2-v2.3.json',
);
let cache = null;
let cooldownUntil = 0;
let cooldownStatus = 502;
let diskLoaded = false;
const inFlight = new Map();
@@ -62,7 +65,18 @@ export function rocketLaunchesProxy() {
function send(res, status, body, cacheState) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store',
'Cache-Control':
status === 200 && cacheState !== 'STALE-ERROR'
? 'public, max-age=900'
: 'no-store',
...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}),
...(Date.now() < cooldownUntil
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
),
}
: {}),
'X-GEV-Cache': cacheState,
});
res.end(body);
@@ -75,17 +89,25 @@ export function rocketLaunchesProxy() {
signal: AbortSignal.timeout(20000),
headers: launchLibraryRequestHeaders(),
});
const body = await readResponseTextCapped(upstream, maxResponseBytes);
if (!upstream.ok) {
const deadline = Date.now() + providerCooldownMs(upstream);
// Never shorten a longer cooldown that is already in force.
if (deadline >= cooldownUntil) {
cooldownUntil = deadline;
cooldownStatus = upstream.status;
}
void upstream.body?.cancel().catch(() => {});
const error = new Error(`upstream HTTP ${upstream.status}`);
error.upstreamStatus = upstream.status;
throw error;
}
const body = await readResponseTextCapped(upstream, maxResponseBytes);
const parsed = JSON.parse(body);
if (!Array.isArray(parsed?.results))
throw new Error('malformed upstream response');
const fresh = { at: Date.now(), body };
cache = fresh;
cooldownUntil = 0;
void saveDiskCache(fresh);
return fresh;
}
@@ -103,6 +125,16 @@ export function rocketLaunchesProxy() {
return;
}
const stale = cache;
if (now < cooldownUntil) {
send(
res,
stale ? 200 : cooldownStatus,
stale?.body ||
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
stale ? 'STALE-ERROR' : 'NONE',
);
return;
}
const request = coalesceProxyRequest(
inFlight,
'recent-launches',
@@ -112,6 +144,10 @@ export function rocketLaunchesProxy() {
const fresh = await request.promise;
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
} catch (error) {
if (Date.now() >= cooldownUntil) {
cooldownUntil = Date.now() + 60000;
cooldownStatus = 502;
}
// Log only a bounded status, never upstream bodies, URLs, or credentials.
const status = Number.isInteger(error?.upstreamStatus)
? error.upstreamStatus
+15 -6
View File
@@ -90,15 +90,23 @@ export function terrainHeightsProxy(options = {}) {
/**
* 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.
* The map is kept in fetch-time order (sorted once on load, and the resolver
* re-inserts every point it refetches), so the oldest are at the front and
* no write sorts the map. 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;
for (const key of mem.keys()) {
if (mem.size <= CACHE_MAX_ENTRIES) return;
mem.delete(key);
}
}
/** Put the map in fetch-time order; a file is not guaranteed to be. */
function orderByAge() {
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
mem.delete(byAge[i][0]);
mem.clear();
for (const [key, entry] of byAge) mem.set(key, entry);
}
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
const inflight = new Map();
@@ -165,6 +173,7 @@ export function terrainHeightsProxy(options = {}) {
}
diskDirty = mem.size > 0;
}
orderByAge();
evictOverflow();
} catch {
/* no disk cache yet */
+9 -6
View File
@@ -182,14 +182,17 @@ export function tomtomProxy() {
});
res.end(JSON.stringify(obj));
};
const sendTile = (buf, cacheStatus) => {
// x-tomtom-fetched-at is when the proxy fetched the tile (ms since the
// epoch), so a client handed a STALE-* copy can say how old it is.
const sendTile = (entry, cacheStatus) => {
if (res.headersSent) return;
res.writeHead(200, {
'Content-Type': 'application/x-protobuf',
'Cache-Control': 'no-store',
'x-tomtom-cache': cacheStatus,
'x-tomtom-fetched-at': String(Math.round(entry.at)),
});
res.end(buf);
res.end(entry.buf);
};
try {
@@ -235,14 +238,14 @@ export function tomtomProxy() {
}
// Fresh cache hit — never counts against the budget.
if (entry && now - entry.at < TILE_TTL_MS) {
sendTile(entry.buf, 'HIT');
sendTile(entry, 'HIT');
return;
}
// Budget governor: over the soft cap, last-good data beats a dead layer.
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
if (entry) {
sendTile(entry.buf, 'STALE-BUDGET');
sendTile(entry, 'STALE-BUDGET');
} else {
sendJson(429, { error: 'budget' });
}
@@ -271,9 +274,9 @@ export function tomtomProxy() {
}
const fresh = await inflight.get(key);
if (fresh) {
sendTile(fresh.buf, 'MISS');
sendTile(fresh, 'MISS');
} else if (entry) {
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty
} else {
sendJson(502, { error: 'upstream' });
}
+55 -10
View File
@@ -43,14 +43,55 @@ export function ingestAisStreamEnvelope(envelope) {
if (!mmsi) return false;
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
const previous = _aisStreamStatic.get(mmsi) || {};
const live = _aisStreamVessels.get(mmsi);
const liveName = live?.name === `MMSI ${mmsi}` ? '' : live?.name;
const fields =
messageType === 'StaticDataReport'
? {
Name:
message.ReportA?.Valid === false
? undefined
: message.ReportA?.Name,
Type:
message.ReportB?.Valid === false
? undefined
: message.ReportB?.ShipType,
}
: message;
// Static reports may be partial (including Class B's separate A/B parts).
// Keep known values in the cache as well as in the current live row.
const staticData = {
name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)),
type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)),
destination: stringValue(message.Destination),
imo: stringValue(message.ImoNumber ?? message.IMO),
// The envelope repeats a cached name even on Class B's type-only part.
// Only an actual static name report replaces a known live name.
name:
stringValue(fields.Name ?? fields.ShipName) ||
liveName ||
previous.name ||
stringValue(metadata.ShipName),
type:
staticCode(fields.Type ?? fields.ShipType) ||
staticCode(live?.type) ||
staticCode(previous.type) ||
'',
destination:
stringValue(fields.Destination) ||
live?.destination ||
previous.destination ||
'',
imo:
staticCode(fields.ImoNumber ?? fields.IMO) ||
staticCode(live?.imo) ||
staticCode(previous.imo) ||
'',
};
_aisStreamStatic.set(mmsi, staticData);
mergeAisStaticIntoLiveVessel(mmsi, staticData);
// AISStream repeats the last known position in a static message's
// MetaData, but the message has no speed, course or heading and is not a
// new fix. Rebuilding a tracked vessel from it would blank its motion and
// restamp its position time, so only merge the static fields.
if (_aisStreamVessels.has(mmsi)) return true;
}
const lat = numberValue(
@@ -191,12 +232,10 @@ function approxMetersBetween(lat1, lon1, lat2, lon2) {
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
const existing = _aisStreamVessels.get(mmsi);
if (!existing) return;
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`))
existing.name = staticData.name;
if (staticData.type && !existing.type) existing.type = staticData.type;
if (staticData.destination && !existing.destination)
existing.destination = staticData.destination;
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
if (staticData.name) existing.name = staticData.name;
if (staticData.type) existing.type = staticData.type;
if (staticData.destination) existing.destination = staticData.destination;
if (staticData.imo) existing.imo = staticData.imo;
}
function vesselNameFromAis(metadata, message, staticData = {}) {
@@ -266,6 +305,12 @@ function stringValue(value) {
return String(value).trim();
}
// AIS uses zero for unavailable ship type and IMO, not as a replacement value.
function staticCode(value) {
const number = numberValue(value);
return Number.isInteger(number) && number > 0 ? String(number) : '';
}
function numberValue(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
+4 -3
View File
@@ -208,9 +208,10 @@ export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
+15
View File
@@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const lat = 999.6 / metresPerDegree;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat });
const metres = pathLengthM(line.vertices);
assert.ok(
metres >= 999.5 && metres < 1000,
`length should round to 1000 m, got ${metres}`,
);
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+14
View File
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
test('every built-in provider is exported, so hosts can choose their own list', async () => {
const google = await import('gods-eye-view/layers/street-level/google');
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
const layer = createApplicationStreetLevel({
providers: [
google.createGoogleProvider({ getApiKey: () => null }),
kartaview.createKartaViewProvider(),
panoramax.createPanoramaxProvider(),
],
});
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
});
+10
View File
@@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
@@ -110,6 +111,15 @@ export function createApplicationTools({
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable.
defer(
installLayerReleaseTrim({
scene: viewer.scene,
dataManager,
requestRender: governorRequestRender,
}),
);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
+5
View File
@@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) {
);
try {
viewer.targetFrameRate = 60;
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
// Keep Cesium's distance-aware zoom and collision limits, with less travel
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
+300 -12
View File
@@ -4,7 +4,7 @@
*
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
* starts (the shared UI navigation policy releases this owner before the new
* destination mutates the camera). fly_route is a
* continuous motion in the same slot: a cinematic dolly along an existing
@@ -13,14 +13,24 @@
* Orbit circles the current screen-center ground target holding range and
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
* transform is cleared on every stop so manual control returns cleanly).
* Globe rotation turns the world-frame camera about its screen-up axis through
* Earth's center, preserving range and framing. Left/right name the visible
* globe motion, opposite to the camera's travel around Earth.
*/
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} from './celestialRing.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
@@ -952,6 +962,7 @@ let _viewer = null;
let _getTarget = null;
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
let _tickRemover = null;
let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
@@ -1162,7 +1173,11 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
@@ -1172,7 +1187,38 @@ function onTick() {
(scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'tilt') {
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
// Cesium rotates both position and basis about the reference-frame
// origin. The captured screen-up axis keeps left/right horizontal even
// in a tilted or rolled view; a positive angle moves the globe right.
const angle = m.direction === 'left' ? -stepRad : stepRad;
if (globeRotationFitsKeyhole() !== true) {
interruptCameraMotion('globe-framing-changed');
return;
}
if (
!globeRotationHasClearance(cam.positionWC) ||
Math.abs(
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
) > 0.01
) {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
@@ -1206,6 +1252,133 @@ function onTick() {
}
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const { minimumRadius, maximumRadius } = ellipsoid;
const radius = Cesium.Cartesian3.magnitude(position);
return (
Number.isFinite(minimumRadius) &&
minimumRadius > 0 &&
Number.isFinite(maximumRadius) &&
maximumRadius >= minimumRadius &&
Number.isFinite(radius) &&
radius > maximumRadius * (1 + 1e-12)
);
}
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
function globeRotationFitsKeyhole(camera = _viewer.camera) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
return null;
const canvas = _viewer.scene.canvas;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const frustum = camera.frustum;
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0 ||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
!Number.isFinite(frustum.near) ||
frustum.near <= 0 ||
!Number.isFinite(frustum.far) ||
frustum.far <= frustum.near ||
frustum.xOffset !== 0 ||
frustum.yOffset !== 0
)
return null;
try {
const cam = camera;
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
const sphereRadius = ellipsoid.maximumRadius;
if (!Number.isFinite(distance) || distance <= 0) return null;
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
if (!radiusPx) return null;
const keyhole = getKeyholeGeometry(width, height);
// The known nadir reset probe uses the same exact cone below; projecting
// through SceneTransforms would read the live scene.camera instead.
if (
camera === _viewer.camera &&
!projectEarthDiscToViewport(_viewer, width, height)
) {
// A near-surface horizon view cannot fit even when pointed at Earth;
// an orbital camera looking away is unsupported, not a close-up offer.
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
? false
: null;
}
// The shared disc is an overlay approximation off-axis. Bound the sphere
// against the existing keyhole cone. Canvas-edge clipping does not change
// framing during rigid globe rotation and is not a clearance condition,
// without changing the UI projection or defining another keyhole/mask.
const toCenter = Cesium.Cartesian3.negate(
cam.positionWC,
new Cesium.Cartesian3(),
);
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
const focalX =
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
if (
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
focalX <= 0 ||
focalY <= 0
)
return null;
const halfAngle = Math.asin(sphereRadius / distance);
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
const framed = centerAngle + halfAngle <= keyholeAngle;
if (!framed) return false;
// Projection alone does not prove that a tiny globe is within depth planes.
if (
depth - sphereRadius < frustum.near ||
depth + sphereRadius > frustum.far
)
return null;
return true;
} catch {
return null;
}
}
/** Check the unchanged reset destination without moving the live camera. */
function globeResetFitsKeyhole() {
try {
const camera = Cesium.Camera.clone(_viewer.camera);
const { longitude, latitude } = _viewer.camera.positionCartographic;
camera.setView({
destination: Cesium.Cartesian3.fromRadians(
longitude,
latitude,
GLOBE_VIEW.heightM,
),
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
roll: 0,
},
endTransform: Cesium.Matrix4.IDENTITY,
});
return globeRotationFitsKeyhole(camera) === true;
} catch {
return false;
}
}
/** Release only globe rotation when a new camera intent takes ownership. */
export function interruptGlobeRotation(reason = 'camera-intent') {
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
return { wasActive: false, reason, leveled: false };
}
/**
* Wire the module to the viewer once (idempotent). The view-target getter is
* optional so a non-voice caller (the Directions FLY chip) can arm route
@@ -1218,24 +1391,42 @@ function onTick() {
* call brings none, so a callback from the old viewer can never answer for the
* new one.
*/
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
export function initCameraVerbs(
viewer,
getViewTargetCartesian = null,
{ isCockpitActive } = {},
) {
const nextTarget =
typeof getViewTargetCartesian === 'function'
? getViewTargetCartesian
: null;
if (_viewer === viewer) {
if (nextTarget) _getTarget = nextTarget;
if (typeof isCockpitActive === 'function')
_isCockpitActive = isCockpitActive;
return;
}
// A globe motion belongs to the viewer whose world pose it captured.
if (_active?.kind === 'rotate_globe')
interruptCameraMotion('viewer-replaced');
_getTarget = nextTarget;
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'wheel']) {
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
const mode =
args.mode === 'continuous' ||
(motion === 'rotate_globe' && args.mode === undefined)
? 'continuous'
: 'once';
if (motion === 'stop') {
const wasActiveBeforeHandoff = Boolean(_active);
const stop = () => {
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
}
}
if (
motion === 'orbit' &&
_viewer?.trackedEntity &&
typeof runNavigation !== 'function'
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity
) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
return {
ok: false,
action: 'move_camera',
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
};
}
let globePose;
if (motion === 'rotate_globe') {
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
return {
ok: false,
action: 'move_camera',
error: 'Globe rotation requires a 3D view.',
};
}
const cam = _viewer.camera;
globePose = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
right: Cesium.Cartesian3.clone(cam.rightWC),
};
if (_isCockpitActive?.())
return {
ok: false,
action: 'move_camera',
reason: 'camera-owned',
error: 'Exit Cockpit before rotating the globe.',
};
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!(
Number.isFinite(ellipsoid.minimumRadius) &&
ellipsoid.minimumRadius > 0 &&
Number.isFinite(ellipsoid.maximumRadius) &&
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error:
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
};
const fits = globeRotationFitsKeyhole();
if (fits === null)
return {
ok: false,
action: 'move_camera',
reason: 'unsupported-view',
error: 'Globe rotation requires a supported perspective view.',
};
if (!fits) {
const canReset = globeResetFitsKeyhole();
return {
ok: false,
action: 'move_camera',
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
error: canReset
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
};
}
if (!globeRotationHasClearance(globePose.position))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
const start = () => {
interruptCameraMotion('replaced');
const state = {
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
speed,
mode,
};
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
Cesium.Cartesian3.clone(globePose.position, cam.position);
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
Cesium.Cartesian3.clone(globePose.up, cam.up);
Cesium.Cartesian3.clone(globePose.right, cam.right);
state.rotationAxis = globePose.up;
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
+9
View File
@@ -6,6 +6,15 @@ export function normalizeHeading(value) {
return ((value % 360) + 360) % 360;
}
/**
* Three-digit heading readout, 000–359.
* Rounding 359.5° and above lands on north (000), not 360.
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
export function slewHeading(current, target, maxStepDeg) {
const from = normalizeHeading(current);
+9
View File
@@ -15,6 +15,7 @@ import {
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(30), '030');
});
test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.6), '000');
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
test('great-circle bearing resolves cardinal directions', () => {
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
+17
View File
@@ -0,0 +1,17 @@
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
*
* Cesium gives each Model a dynamic environment map: it renders the sky around
* the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour
* (`colorBlendAmount`), so that lighting contributes little; without it they
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false,
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
/**
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
* map (a sky render and GPU readback per model). The preload calls only warm
* the glTF cache and are never added to the scene, so they are left out:
* drawn models are the calls that set a minimum pixel size.
*/
const SOURCES = [
{
name: 'flights',
source: readLayerSource(new URL('./flights.js', import.meta.url)),
},
{
name: 'militaryFlights',
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
},
{
name: 'localAdsb',
source: readFileSync(
new URL('../layers/localAdsb/models.js', import.meta.url),
'utf8',
),
},
];
test('the shared option turns the dynamic environment map off', () => {
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
});
for (const { name, source } of SOURCES) {
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
const calls = [
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
]
.map((match) => match[1])
.filter((options) => /\bminimumPixelSize:/.test(options));
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
for (const [index, options] of calls.entries())
assert.match(
options,
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
);
});
}
+28
View File
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
}
});
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
Object.defineProperty(globalThis, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
});
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
const tile = { primitive: { isCesium3DTileset: true } };
const harness = installWireHarness(tile);
let drills = 0;
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
drills++;
assert.deepEqual([limit, width, height], [8, 14, 14]);
return [tile, { id: 'a1b2c3' }];
};
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(drills, 1);
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
} finally {
harness.cleanup();
unregisterPickOwner('flights');
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
else delete globalThis.matchMedia;
}
});
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
const harness = installWireHarness({ id: 'atx-cam-3' });
+195
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
+25
View File
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
} from './alprCameras.js';
import { createAlprTileSource } from '../layers/alpr/source.js';
import { DATA_CREDITS } from './dataCredits.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import {
clearSelectedEntityContextForLayer,
getContextStore,
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
}
});
// The voice model reads the label as data; the voice card translates it.
test('the camera label stays English data in a translated interface', async () => {
const h = cameraHarness();
try {
setLocale('ru');
h.setFetch(async () =>
cameraResponse([
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
]),
);
await alprCamerasLayer.update();
h.click(h.source.entities.values[0].id);
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Камера ALPR',
);
} finally {
setLocale('en');
h.restore();
}
});
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
+2 -1
View File
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more