Compare commits

..
Author SHA1 Message Date
Sameh Khamis 72eb3fdc73 Merge pull request #1439 from bilawalsidhu/release/v0.3.0
chore(release): prepare v0.3.0
2026-10-27 22:17:54 -07:00
Sameh Khamis 237684ba86 chore(release): prepare v0.3.0 2026-10-27 22:08:29 -07:00
Said Turksever 9448c49acd Merge pull request #1431 from Rohilalala/fix/b-street-bearing-north
fix(street-level): show a 359.6° bearing as 0°, not 360°
2026-10-28 00:36:53 +00:00
Aditya 09cef19829 docs(changelog): add #1431 fragment 2026-10-27 21:56:52 +05:30
Aditya 6d6b2e8fe9 fix(street-level): show a 359.6° bearing as 0°, not 360°
The Street Level viewer caption rounded the photo's bearing without
wrapping, so a view facing 359.5° or more read "360°". The rounded
bearing now wraps to 0°.
2026-10-27 15:52:27 +05:30
Sameh Khamis 15de9d738c fix(i18n): mark style labels without loading the catalogs (#1421)
The style shaders and visualPresets imported msg() from src/i18n/index.js,
which loads every locale catalog. The share-view codec reads the styles'
defaults, so a server bundling the codec carried about 0.9 MB of
translations it never uses. They now import the catalog-free marker, as
other portable modules do; the codec's bundle drops from about 1 MB to
130 KB.
2026-10-26 16:54:01 -07:00
Sameh Khamis 6152f9f311 fix(search): keep a missed location query in the field so it can be corrected (#1420)
A location search that found nothing or failed now keeps its query, the open field and focus (text selected), and shows a persistent role=alert message under the search row instead of a toast. Typing, a new search or closing the emptied field clears it. HAL-727.
2026-10-26 16:44:34 -07:00
Sameh Khamis 3d9f153bc0 feat(share): export the share-view codec and link-path helpers (#1419)
* feat(share): export the share-view codec for host servers

Add `@gods-eye-view/core/share/view-codec` (src/share/viewCodec.js) as a
package export with its own boundary group. The codec is pure (no DOM, no
Cesium, no external dependencies), so a host's server can decode a
/share/<code> link, for example to describe the view in link previews.

* feat(share): export the share-link address helpers

Add `@gods-eye-view/core/share/link-path` (src/share/shareLinkPath.js), so a
host's server reads /share/<code> addresses the way the app does.
2026-10-26 16:20:23 -07:00
Sameh Khamis da02fd8645 feat(share): short /share/<code> links with a compact versioned view format (#1418)
* feat(share): add compact versioned share-view codec

Add a standalone codec for `#s=<base64url>` share links: a version byte
followed by protobuf-style tag-length-value fields. Settings equal to
their default are omitted and refilled from the same table on decode;
unknown fields are skipped by wire type, so new fields never break
older apps. Views are a place (camera) or a thing (an aircraft,
military aircraft, vessel or satellite to follow, with camera offset
and last-known fix); an unknown kind falls back to the camera.

The decoder bounds its input and rejects malformed payloads with a
reason instead of throwing. A converter maps the long form's parsed
state to and from the codec state; the long form is unchanged.

* fix(share): tighten codec decoding after review

Unknown detection mode and allocation names now fall back to their
defaults instead of reaching the lenient long-form migration. Future
panel values and style-parameter tokens are skipped rather than applied
or rejected, and layer options are accepted only under their owner
layer. Wrap longitudes before scaling, normalize negative zero, reuse
the layer-state tracking ID grammar, and align the format doc with the
decoder.

* feat(share): let a host share the link its own way

ShareLinkManager.buildLink() returns the current share link without
copying it, and copyLink() now copies exactly that. The share button
passes the link to an optional services.shareLink(url), which resolves
to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast
and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the
service the button copies as before.

* feat(share): open and make short /share/<code> links

The share button now makes `<app>/share/<code>` links with the compact
share-view code, and the app opens them at start-up through the same
restore pipeline as long links. Once the app next writes the address, the
share path is replaced (no new history entry) by the app's own address and
the live long-form hash, so a reload follows the live view. A host can pass
the code as `initialShareCode` instead. A code that fails to decode restores
nothing and shows "This share link can't be opened".

The code gains a trailing CRC-8 check byte, so any single mistyped
character is rejected instead of opening a different view. Thing views no
longer store a camera: it is derived from the entity's last-known fix and
the follow offset on a sphere, the entity kind is implied by the id's
field, and the followed entity's layer bit is implied; an aircraft followed
from the app's own camera is a 42-character code. The `#s=` hash form is
gone.

While the camera follows an aircraft, military aircraft or satellite, the
link is a thing view with the camera's offset and the entity's position.
Opening one turns the layer on and follows the entity through the existing
shared-follow restore, then swings the camera round to the shared offset; a
vessel view selects the vessel once the vessel feed has it. If the entity
is not reporting within its window, the view stays at its last-known fix
and says so. The panel and style-parameter tables now come from the codec,
and a host share hook that returns a plain value or throws still gets a
toast.

* fix(share): encode style settings against the style's own look

Settings a style applies when selected (bloom, sharpen, HUD, detection,
celestial ring and the style's shader parameters) now default, per field,
to what that style applies in a fresh session, read from the tables the
app applies (the first-load baseline, the style presets and the shader
defaults). The encoder compares against the view's style and the decoder
fills omitted settings from the decoded style, so a view that keeps its
style's look costs only its camera and style. The previous defaults did not
match a fresh session (sharpen, HUD and detection were off), so every link
paid for them, and a style's own shader parameters were always written.

A test pins the per-style defaults as frozen v1 meanings. A followed
aircraft in Noir is now 44 characters and the real long-link example 39.

* docs: changelog for #1418
2026-10-26 15:31:51 -07:00
Sameh Khamis fefd5cdcf1 fix(layers): make Clear All clear the saved and shared layer selection (#1417)
* fix(layers): make Clear All clear the saved and shared layer selection

After Clear All every layer control was off, but the share URL could keep
listing layers (for example l=e.f) and a reload switched them back on.

The URL and local preferences are written from the committed layer
state, which only explicit visibility changes update. Clear All issued an
explicit OFF only for layers it found on at the start of the batch and
whose turn was not superseded. A layer already off for a passive reason
(isolated by Contacts or another context mode, or released by Contacts'
own teardown while the batch ran) never received one, so it stayed in the
committed state. Clear All also discards the context snapshot that would
have restored those layers, leaving the screen and the URL out of step.

Clear All now publishes the set of layers still on once its batch
settles, and the layer-state coordinator commits that selection: every
saved layer not in it is dropped and its passive restore is cancelled,
while a layer a newer explicit request turned back on is kept. A context
restore interrupted by Clear All no longer brings its snapshot back after
Contacts settles.

* docs: changelog for #1417
2026-10-26 14:07:58 -07:00
Sameh Khamis b7ece1d4d1 fix(share): restore a share link opened in an already-open tab (#1416)
* fix(share): restore a share link opened by hash navigation

Opening a different share link in an already-open tab (pasting it into
the address bar, following a link, or Back/Forward) changed the URL but
left the camera, visual style, map, panels and layers on the old view
until a reload.

The share manager now listens for hashchange and popstate. A new hash
that parses as a share and differs from the last hash the app read or
wrote runs the start-up restore pipeline: lane authority is reserved, URL
writes are held (including a pending debounced write of the old view),
the camera restore takes a new navigation generation, and the layer
coordinator replaces any in-flight layer restore with the new share's
layers through the passive share origin. A newer navigation supersedes an
older restore end to end; only the newest one releases URL writes. The
app's own replaceState writes never restore. Listeners are removed on
destroy.

Returning to the Normal style and resetting an already-active preset now
also apply when a share is restored after start-up, so the result matches
a fresh load of the same link.

* docs: changelog for #1416
2026-10-26 13:56:19 -07:00
Sameh Khamis 301bc2e0b3 feat(voice): add preview and settings panel (#1299)
Add the shared Voice Preview and Settings panel with browser-local preferences, inactivity controls and visible cost warnings.

Contribution by @manjunath22466.
2026-10-26 09:03:04 -07:00
Sameh Khamis c533b1994c Merge main and preserve voice panel and GhostMaps schema coverage 2026-10-26 08:52:03 -07:00
Sameh Khamis ddbcd2053b fix(voice): preserve idle evidence through recoverable response errors 2026-10-26 08:47:54 -07:00
Mazeyar Moeini Feizabadi 42e68352e8 Merge pull request #1297 from GPTmadeit/feat/s2-ghostmaps-layer
Add S2 GhostMaps live intel layer
2026-10-26 16:46:47 +01:00
Sameh Khamis 074e272356 fix(voice): recover idle timers and retire panel audio observers 2026-10-26 08:42:41 -07:00
Sameh Khamis aeee3652ce Merge PR #1299 with current main for validation 2026-10-26 08:39:16 -07:00
Mazeyar Moeini Feizabadi 9496ee4d64 fix: complete GhostMaps locale and voice integration with current main 2026-10-26 16:29:48 +01:00
Mazeyar Moeini Feizabadi 4da002bd42 Merge current main into S2 GhostMaps and preserve updated import and voice behavior 2026-10-26 16:25:49 +01:00
Mazeyar Moeini Feizabadi 4a18af5426 fix: address GhostMaps resource and source ownership review findings 2026-10-26 16:24:34 +01:00
Bilawal SidhuandBilawal Sidhu a3a19883b4 feat: add Scope controls and conditional HUD text backplates (#1410)
* feat: add scope controls and conditional HUD backplates

* docs: assign scope changelog to PR 1410

* fix: stabilize title backplate and brighten secondary labels

---------

Co-authored-by: Bilawal Sidhu <[email protected]>
2026-10-26 00:24:27 -05:00
Jenil Patel a7fcb3c232 fix(maps): avoid 404 on keyless Google tiles-token probe (#1066)
Return an uncached unavailable-token response for keyless Google tile-token requests.

Contribution by @dvilrgamerz.
2026-10-25 19:13:38 -07:00
nu-stats 06c51d3438 perf(weather): wake a staged frame on a 150 ms timer, not every render (#1345)
Wake staged weather imagery on a bounded timer instead of requesting every render.

Contribution by @nu-stats.
2026-10-25 19:07:12 -07:00
Sameh Khamis 2b2dad5bbe Harden Vite fs.deny for local logs and cache (#948)
Deny local logs, cache, and repository build output from Vite development file serving.

Contribution by @blowing-a-raspberry.
2026-10-25 19:00:20 -07:00
Sameh Khamis 292a7a9d08 Merge main into PR #948 2026-10-25 18:53:39 -07:00
NukeThemAII 3a724f055a fix(pinokio): open the app in the system browser (#1222)
Open the Pinokio app in the system browser while retaining application framing protection.

Contribution by @NukeThemAII.
2026-10-25 18:53:32 -07:00
Sameh Khamis 1663733b58 style: format Vite deny-list test imports 2026-10-25 18:49:31 -07:00
Sameh Khamis 0dce2f8d04 fix: complete PR #948 integration and documentation 2026-10-25 18:47:57 -07:00
Sameh Khamis 80449af6bd Merge main into PR #948 2026-10-25 18:47:57 -07:00
Rudy Mizrahi Celekli a15fdd3134 fix(hud): carry rounded DMS seconds into the minutes (#1339)
Carry rounded HUD coordinate seconds into minutes and degrees.

Contribution by @rudycelekli.
2026-10-25 17:59:09 -07:00
Anıl Güvenç 2a17491c8b fix(docker): let SELinux hosts read the bind-mounted checkout (#1391)
Allow the Docker checkout bind mount on SELinux hosts.

Contribution by @anilguvenc.
2026-10-25 17:54:16 -07:00
Sameh Khamis 76311e36d4 feat(docker): add a HEALTHCHECK to the image (#1394)
Report Docker application health using a bounded loopback HTTP probe.

Contribution by @14traits.
2026-10-25 17:46:52 -07:00
Sameh Khamis 3a28dcab07 fix(docker): align health probe port parsing and document health status 2026-10-25 17:32:15 -07:00
Sameh Khamis e03e58135d Merge main into PR #1394 2026-10-25 17:32:15 -07:00
Sameh Khamis 2006c035d9 chore: remove four modules nothing in the app uses (#1403)
* chore: remove four modules nothing in the app uses

src/app/sourceSlot.js re-exported src/sources/sourceSlot.js, which every
caller imports directly. src/data/flowTiles.js and src/data/issPass.js were
reached only by their own tests. src/data/localGeojson.js wrapped
localGeojsonCore.js with the app's services, which the catalog already
passes itself. The local GeoJSON layer tests now build layers the way the
catalog does, so they keep covering localGeojsonCore.js; only the test of
the wrapper's own default host goes. Boundary lists, format scope and docs
drop the removed paths.

* docs: changelog for #1403
2026-10-25 17:21:41 -07:00
Said Turksever a43ff8619e fix(search): normalize geocoder bias across the antimeridian (#1272)
Keep geocoder viewport bias near the antimeridian and omit misleading wrapped viewboxes.

Contribution by @asturksever.
2026-10-25 17:04:43 -07:00
quanticsoul4772 ba292ce475 fix(flights): keep ground-snap sampling after the clock steps back (#1399)
Recover aircraft ground-height sampling after backward wall-clock changes while preserving sampling budgets.

Contribution by @quanticsoul4772.
2026-10-25 16:58:14 -07:00
quanticsoul4772 a7da90fdfc fix(effects): run no sharpen pass when its slider is at 0 (#1398)
Disable the sharpen pass when its intensity is zero.

Contribution by @quanticsoul4772.
2026-10-25 16:51:56 -07:00
Sameh Khamis 5103ff672c fix(gbfs): exclude stations with invalid coordinates (#1232)
Reject invalid bike-share station coordinates while preserving genuine zero coordinates and numeric aliases.

Contribution by @Bortlesboat.
2026-10-25 16:47:51 -07:00
Sameh Khamis a07b5a65fd Merge current public main with PR #1232 2026-10-25 16:40:20 -07:00
Rudy Mizrahi Celekli 7bc86224e9 fix(route): phrase ramp and lane modifiers as directions (#1343)
Correct ramp and lane instructions consistently in server output and localized browser text.

Contribution by @rudycelekli.
2026-10-25 16:28:09 -07:00
Sameh Khamis 5ae22ac333 fix(awareness): wrap north-bound bearings (#1390)
Wrap awareness bearing and course labels at north and register the formatting helper in package boundaries.

Contribution by @rudycelekli.
2026-10-25 16:21:50 -07:00
Sameh Khamis f4b3517e9b Merge main into PR #1390 2026-10-25 16:15:38 -07: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 1ad472fecf fix(awareness): register and document bearing presentation 2026-10-25 14:08:32 -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 d987149f17 Merge PR #1390 contribution for review 2026-10-25 14:07:14 -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
John Rogers f81ef406f7 (っ▀¯▀)つ -- Issue #1240: added docker health checks 2026-10-25 14:08:14 -05:00
Said Turksever 769ecbfe98 Merge branch 'main' into fix/cockpit-wind-north 2026-10-25 18:51:44 +00:00
Rudy Celekli 63866c201a fix(awareness): wrap north-bound bearings 2026-10-25 13:28:23 -04: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
manjunath 5c3b66c263 feat(voice): reconcile preview and settings with main
add the shared Voice Preview and Settings panel with browser-local preview
and inactivity preferences, keyboard controls and responsive positioning

preserve main’s Rotation and Context behavior, clarify panel routing and
refresh schema pins within the unchanged prompt limit
2026-10-24 15:20:15 +05:30
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
Sameh Khamis d48b9c4df9 Merge pull request #1325 from bilawalsidhu/feat/phone-drawer
feat(ui): phone layout with a bottom tab bar and one panel sheet at a time
2026-10-22 22:26:49 -07:00
Sameh Khamis e7cdd47d8d chore: changelog entry for the phone layout 2026-10-22 22:12:36 -07:00
Sameh Khamis 9e0c0fe960 docs: describe the phone layout 2026-10-22 22:09:37 -07:00
Sameh Khamis 10bb2c6a36 test(qa): open the phone tab sheets in phone-width harness checks
At 720px and narrower the panels live in the phone shell's tab sheets, so the
camera, scene and map-source tray harnesses open the matching tab (from the
keyboard where they check focus rings) before measuring, wait for the shell to
follow a viewport change, measure the container that actually scrolls, and run
the pin and compact-wing checks at 800px where the narrow desktop dock still
exists.
2026-10-22 22:09:37 -07:00
Sameh Khamis fff16f3884 feat(ui): phone layout with a bottom tab bar and one panel sheet at a time
At 720px and narrower the globe stays full screen and a bottom tab bar (Layers,
Cameras, Context, Search, More) opens one bottom sheet at a time, presenting the
existing rail and dock panels in place. Sheets borrow panels without persisting
or sharing them and put back what they changed. Cockpit, recording and clean
view close the sheet; scene playback keeps STOP reachable. Focus moves into the
sheet and back to its tab, the required attribution stays visible above the
voice control and any sheet, and the page uses safe-area insets. Wider
viewports are unchanged.

Adapted from the phone tab-shell proposed in #203 by @blearymoth.
2026-10-22 22:09:37 -07: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
Sameh Khamis 224745bd0a Merge pull request #1321 from bilawalsidhu/chore/dead-code-cleanup
chore: remove dead code across the repository
2026-10-22 20:54:09 -07:00
Sameh Khamis 04f8c7fad0 chore: credit the changelog entry to the GitHub handle 2026-10-22 20:47:34 -07:00
Sameh Khamis f6a4b73459 docs: point remaining src/ui.js references at their owners
The README source tree now lists src/ui/ instead of the removed
src/ui.js. docs/CODE-BOUNDARIES.md names src/ui/applicationShell.js as
the owner of panel layout, persistence and share restoration, and
docs/CURRENT-STATE.md places GLOBAL_POST_DEFAULTS in
src/ui/visualPresets.js. QA script comments and test assertion messages
that still named ui.js are updated the same way.
2026-10-22 19:26:09 -07:00
Sameh Khamis 9623bc4d58 docs(changelog): add the dead-code cleanup entry 2026-10-22 19:19:03 -07:00
quanticsoul4772 ba2dda5beb Merge branch 'main' into upstream/wind-stale 2026-10-22 19:18:02 -07:00
Sameh Khamis f3a87777ee chore: remove the remaining unused local bindings
An ESLint no-unused-vars scan over src, server, scripts, tools, build
and pinokio, repeated until it is stable, reported these.

Runtime modules keep every call and drop only the unread binding:
- src/ui/cockpitInput.js: the unread result of enter()/exit().
- src/layers/launches/rendering.js: the unread entity returned by
  entities.add().
- src/layers/radio/rendering.js: the unread map key in the horizon scan,
  which now iterates the map's values.

QA scripts and tools drop an unused helper, two unused in-page module
imports, unused counters and destructured fields, and an unread wait
result. Tests drop source files read into variables that no assertion
uses, redundant StyleManager imports (the shell-source helper already
imports it), and unused destructured fields.

The one remaining report is the `_` loop variable in src/tools/schema.js,
which counts iterations and is intentional.
2026-10-22 19:08:10 -07:00
Sameh Khamis 46f7b74c8c chore: drop unused exports from modules QA harnesses load
QA harnesses import some modules in the page by path, for example
import('/src/data/radio.js'). Those calls name exactly which bindings
they use, and every other export of these modules has no importer:

- src/data/radio.js: 20 aliases of radio layer methods and two
  re-exported altitude constants. The radio layer keeps the methods.
- src/layers/transit/movement.js: tuning constants and course helpers
  used only inside the module lose `export`; FIX_HISTORY_MAX,
  DISPLAY_LAG_MIN_MS, DISPLAY_LAG_RESET_MS, FIX_NOISE_M, STOPPED_FIXES
  and courseBetween, which nothing reads, are deleted.
- src/overlays/worldOverlay.js, src/splitFlap.js,
  src/data/contactPlayback.js and src/voice/cloudVoiceAuth.js: helpers
  and constants used only locally lose `export`.
2026-10-22 19:07:46 -07:00
Sameh Khamis 86f271a78f chore: remove unused data and model assets
- src/data/local_data/dams/dams.geojson held the same 704 features as
  dams.geojsonl, feature for feature. Only the JSONL file is loaded
  (src/sources/infrastructureData.js), so the GeoJSON copy is dropped
  and the dataset README no longer mentions it.
- public/models/ship.glb is not loaded anywhere: no module, template,
  stylesheet or script names it. Its row in public/models/README.md
  goes with it.

Together these remove 960,345 bytes of tracked data.
2026-10-22 19:01:09 -07:00
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00
Sameh Khamis 504f9342aa chore: remove unreachable legacy entry modules
Delete three modules that no entry point, package export, test or script
imports:

- src/ui.js, a StyleManager subclass that pre-selected the standalone
  catalog. Application controls are built through src/app/controls.js
  and src/standalone/controls.js.
- src/standalone/ui.js, a bare re-export of StyleManager.
- src/data/localLayers.js, a list of default infrastructure, cable and
  FIRMS layer instances. The catalog builds these layers itself.

With src/ui.js gone, the import-direction check keeps a single
compatibility exception (server/providers/local.js to
server/standalone/key-setup.js), and its test now asserts that a
src/ui.js importing standalone code is rejected. The boundary and
format registries and the docs that named these files are updated.
2026-10-22 18:53:56 -07:00
Sameh Khamis dbb20c72fe chore: drop unused local bindings
Remove locals that are assigned but never read: two unused
JulianDate.now() calls in the flights and military nearby queries, an
unread record lookup in the military snapshot renderer, unused axes from
the CCTV gizmo, an unused vessel state alias, and two unused ALPR
presentation handles. Each removed expression is side-effect free.
2026-10-22 18:09:40 -07:00
Sameh Khamis f60b9ef7c8 chore: remove unused imports and local helpers
Drop module-local helpers that nothing calls (linkAbort, summarize,
stringOption, summarizeEntity and the polygonCenter, propertyObject and
unwrapProperties helpers only it used, CALLOUT_DOT_SIZE_PX) and unused
import specifiers in runtime modules, tests, QA scripts and tools.

None of the removed bindings is exported. Re-exports such as the ALPR
policy constants and every package export are unchanged.
2026-10-22 18:09:37 -07:00
Sameh Khamis e479b6e91d chore(camera): remove unused camera presets
CAMERA_PRESETS and flyToPreset have no callers. Startup uses flyToAustin,
which stays, and city navigation uses flyToPresetLocation from
src/locations.js. Neither removed symbol is a package export.
2026-10-22 17:46:57 -07:00
Said Turksever 5d95e9adc4 Merge pull request #1276 from bilawalsidhu/codex/fix-terrain-request-bound
fix(terrain): bound each height request to one upstream chunk
2026-10-23 00:00:08 +01:00
Said Turksever 2abae6ee41 Merge branch 'main' into codex/fix-terrain-request-bound 2026-10-22 23:54:47 +01:00
Said Turksever 6e589a293d Merge pull request #1275 from bilawalsidhu/codex/fix-initial-forecast-supersession
fix(weather): preserve enable during first-load replacement
2026-10-22 23:48:26 +01:00
Said Turksever 1c4515731a Merge branch 'main' into codex/fix-initial-forecast-supersession 2026-10-22 23:40:04 +01:00
Said Turksever 62c319b575 Merge pull request #1271 from bilawalsidhu/codex/fix-share-style-keys
fix(share): reject inherited style keys during restore
2026-10-22 23:30:16 +01:00
Said Turksever 5cc250495f Merge branch 'main' into codex/fix-share-style-keys 2026-10-22 23:23:49 +01:00
Said Turksever a18dd29262 Merge pull request #1269 from bilawalsidhu/codex/fix-aircraft-refresh
fix(aircraft): coalesce refreshes and cool down OpenSky failures
2026-10-22 23:23:12 +01:00
Said Turksever 98d9b54bcd Merge branch 'main' into codex/fix-aircraft-refresh 2026-10-22 23:15:48 +01:00
Said Turksever 5bb0a210b7 Merge pull request #1265 from bilawalsidhu/codex/fix-docker-trusted-peers
fix(docker): honor trusted-peer environment overrides
2026-10-22 23:15:17 +01:00
Said Turksever 5b63bd5f7f Merge branch 'main' into codex/fix-docker-trusted-peers 2026-10-22 22:51:20 +01:00
43e8bfd257 Merge pull request #1298 from bilawalsidhu/german/hal-643-zh-cn
i18n: Simplified Chinese (zh-CN)

Add a complete Simplified Chinese translation and language picker support.
Improve Chinese typography, date formatting, and localized readouts.
Include translation guidelines and regression tests.

Based on translations by abiyeenzo (#914) and EDIT-Aeterna (#979).

Co-authored-by: German Khokhlov <[email protected]>
Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: EDIT-Aeterna <[email protected]>
2026-10-22 22:40:52 +04:00
German Khokhlov 87369eaf0f Merge remote-tracking branch 'origin/main' into german/hal-643-zh-cn
# Conflicts:
#	scripts/package-boundaries.json
#	style.css
2026-10-22 22:31:23 +04:00
German Khokhlov 2c3b364069 docs: simplify Chinese localization notes 2026-10-22 22:29:24 +04:00
German Khokhlov 1416266bcb fix(i18n): avoid duplicate Chinese FIRMS age suffix 2026-10-22 22:07:09 +04:00
quanticsoul4772 a94677aed0 Merge branch 'main' into upstream/wind-stale 2026-10-22 06:18:13 -07:00
German Khokhlov 4bf8522985 i18n(zh-CN): apply the second-pass review findings
A fresh full review of the catalog (52 changes in 48 entries):

- Age readouts that render as bare durations got their missing 前
  (LIVE · updated {age}, POSITION {age}).
- Unified the rate-limit family on 已被限流 (12 entries had 已限流,
  正在限制请求频率 or 受到频率限制).
- Unified photorealistic on 照片级逼真 (planet strings, 3D Tiles).
- Round-1 stragglers: 括标 → 括号标记, FLIGHT/VESSEL WINDOW →
  监视窗口, mapped-ALPR loading strings, plain Talk → 脱口秀, the
  hardcoded Current style twin.
- Collocations: 与…同源, 不超过…上限, 单击放置顶点, 随语音分享.
- Mainland usage and consistency polish: 气象高度, 地面风, 标签页,
  已获取航迹 (acquire ≠ lock), 缩放级别 6, 发布时间, 独立视角,
  视野中心, 播报当前视野, and similar one-word fixes.

Kept deliberately: the 图像 (image) vs 影像 (imagery) distinction,
which mirrors the English and is consistent across the catalog.
2026-10-22 13:50:19 +04:00
German Khokhlov f651769110 i18n(zh-CN): apply the catalog review findings
Review of all 2,677 zh-CN entries (scripted checks + full meaning
review) confirmed the catalog fully Simplified and mechanically clean.
83 fixes in two passes:

Meaning and terms — PARTLY CLOUDY lost 'partly' (多云 → 晴间多云);
AIR/SEA WINDOW said scheduled flights (航班 → 空/海监视窗口); Mapped
ALPR Cameras collided with ALPR Cameras; globe terms unified on 地球
(球体地图/球体影像); later/earlier observation buttons read as future
actions (稍后/早前 → 较晚/较早); 从未叠加… misread as 'never'; 风流线
→ 风场流线; 访问飞行器 → 来访航天器; 目击 → 目击者; downrange is
落点距离 not 航程.

Polish — 加载 over 载入; 预警 for advisories; {amount}K/M/B 美元;
空载; 窗口 over 视窗; 分享的 {subject} matches its siblings; missing
CJK↔Latin spaces restored around {store} and {mode}; 拦截点击,
已被限流, 精选, 逼真世界, and other one-word mainland-usage fixes.
2026-10-22 13:14:40 +04:00
Mazeyar Moeini Feizabadi cb11e7dac8 fix: clear GhostMaps outage state on unchanged-head recovery 2026-10-22 02:42:26 +02:00
Mazeyar Moeini Feizabadi d5c49d35e8 Merge latest main into S2 GhostMaps contribution 2026-10-22 02:41:26 +02:00
Sameh Khamis 7be5937fc3 Merge pull request #1317 from bilawalsidhu/fix/provider-response-hardening
Harden provider response handling and camera source validation
2026-10-21 16:18:50 -07:00
Sameh Khamis 9f5acf14db Name the changelog fragment for #1317 2026-10-21 16:07:42 -07:00
Sameh Khamis b75db874c6 Read API paths and forwarded requests as the router does
The router reads an absolute-form target's path without resolving dot
segments, so a target such as http://host/api/../x still reaches /api
middleware. Take that path the same way when deciding whether the API
response headers apply.

The Street View fallback now treats any forwarding header as forwarded, even
an empty one, before it spends the key.
2026-10-21 15:43:29 -07:00
Sameh Khamis afe05454d9 Require a directory prefix for camera URL pins
The URL parser already turns backslashes into separators, so only encoded
separators need refusing; a pin prefix without a trailing slash is treated as
a directory.
2026-10-21 15:33:14 -07:00
Sameh Khamis b1ed77c26c Keep camera routes reachable behind a reverse proxy
The CCTV routes now use the keyless cross-site checks, which refuse cross-site
browser requests but accept forwarding headers, so cameras work behind the
documented reverse proxy. The Street View fallback is the one step that spends
a key; a request with forwarding headers skips it and gets the placeholder
frame, as when no key is configured.
2026-10-21 15:33:14 -07:00
Sameh Khamis d80162bea2 Match API paths the way the router does
The router mounts /api without regard to letter case, accepts a '.' as well
as '/' after the prefix, and routes absolute-form request targets, so the
API response headers were missing on those spellings. Match the same way, on
both the raw and the parsed path. The test now runs real dev and preview
servers.
2026-10-21 15:33:13 -07:00
Sameh Khamis 3e80afe713 Pin camera sources by parsed URL
Camera catalog image URLs were pinned by string prefix, so a URL that only
began with the agency's directory before dot segments or an encoded separator
were resolved still counted as inside it. The pin now parses the URL and
requires the same scheme, the exact host, no credentials, the default port and
a normalized path inside the pinned directory. Redirects on a pinned host must
stay inside the same directory.

Document the response format checks and API response headers alongside the
existing server hardening notes.
2026-10-21 15:07:40 -07:00
Sameh Khamis 0ad75095a7 Validate provider response formats
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
AVIF, parsed from the media type essence and sent under the canonical type;
any other type is cancelled and falls through to the next fallback as a
failed upstream does. Live media must match its feed kind (video/mp4,
video/webm, or multipart/x-mixed-replace with a boundary) before the camera
is reported healthy or any byte is relayed, and its redirects now stay on the
registered origin like frames. Every /api/cctv route uses the same-site gate.

The GBFS proxy relays only bodies that parse as JSON, re-serialized under a
fixed application/json type.
2026-10-21 15:07:20 -07:00
Sameh Khamis 72e1469968 Apply consistent API response headers
Every /api response, including errors, partial responses, Host refusals and
unknown routes, now carries a sandboxing Content-Security-Policy and
X-Content-Type-Options: nosniff on the dev and preview servers. Vite's server
and preview headers only reach the documents and assets it serves itself, so
a small plugin sets them ahead of the Host check. A route's own policy is sent
as an additional policy rather than replacing it.
2026-10-21 15:07:00 -07:00
German Khokhlov 275513bf2c Merge pull request #1243 from bilawalsidhu/claude/hal-627-starfield-cb6fa2
feat(sky): catalogue stars and a soft Milky Way instead of the blocky sky box
2026-10-21 21:59:02 +04:00
German Khokhlov a1a9a36f54 Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2 2026-10-21 21:39:46 +04:00
Mazeyar Moeini Feizabadi e84e74ca3a Merge pull request #1309 from bilawalsidhu/codex/shared-control-css-ownership
Clarify CSS ownership for shared native fields
2026-10-21 18:52:01 +02:00
Mazeyar Moeini Feizabadi 4116082cb9 docs: add release note for field CSS ownership 2026-10-21 18:41:39 +02:00
Mazeyar Moeini Feizabadi 7103c29194 refactor(ui): clarify shared field CSS ownership 2026-10-21 18:41:17 +02:00
Said TurkseverandClaude Opus 5.5 82c722e6af chore(format): keep format-scope in step with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:40:49 +01:00
Said TurkseverandClaude Opus 5.5 eaf14e2937 Merge branch 'main' into codex/fix-docker-trusted-peers
Resolve scripts/format-scope.json by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:39:47 +01:00
Mazeyar Moeini Feizabadi a20673e78d Merge pull request #1306 from bilawalsidhu/codex/shared-status-badges
Unify read-only status badges in CCTV and Weather
2026-10-21 18:18:47 +02:00
Mazeyar Moeini Feizabadi 83471ea6d7 docs: add release note for shared status badges 2026-10-21 18:10:46 +02:00
Mazeyar Moeini Feizabadi f70bc0cabb refactor(ui): share read-only status badges 2026-10-21 18:10:09 +02:00
Mazeyar Moeini Feizabadi 8ed1bbbd0f Merge pull request #1305 from bilawalsidhu/codex/shared-form-controls
refactor(ui): share native dropdowns and numeric fields
2026-10-21 17:31:57 +02:00
Mazeyar Moeini Feizabadi 3f903f0b18 Merge main and preserve shared control surfaces
# Conflicts:
#	src/ui/templates/layer-panels.html
#	style.css
2026-10-21 17:25:25 +02:00
Mazeyar Moeini Feizabadi 4deb32a9a3 Merge pull request #1302 from bilawalsidhu/codex/shared-action-buttons
refactor(ui): share ordinary Scene and CCTV action buttons
2026-10-21 17:25:05 +02:00
Mazeyar Moeini Feizabadi 49735d9307 Merge main for shared action button integration 2026-10-21 17:18:18 +02:00
Mazeyar Moeini Feizabadi b6623f65cd docs: add shared native fields changelog entry 2026-10-21 17:11:56 +02: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
Said Turksever f3bb697ed4 Merge pull request #1264 from bilawalsidhu/codex/fix-overpass-request-admission
fix(overpass): bound request validation and reject cross-site POSTs
2026-10-21 15:20:25 +01: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 c192c3b8b5 Merge branch 'main' into codex/fix-terrain-request-bound
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:39 +01:00
Said TurkseverandClaude Opus 5.5 3d75e0a731 Merge branch 'main' into codex/fix-initial-forecast-supersession
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:36 +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 a451c1ba6f Merge branch 'main' into codex/fix-share-style-keys
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:26 +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 f07a52cb00 Merge branch 'main' into codex/fix-aircraft-refresh
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:21 +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
Said TurkseverandClaude Opus 5.5 2cf36534d9 Merge branch 'main' into codex/fix-docker-trusted-peers
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:15 +01:00
Said Turksever 54e7dc7c30 Merge branch 'main' into codex/fix-overpass-request-admission 2026-10-21 15:00:44 +01:00
Said Turksever ee1e91781a Merge branch 'main' into claude/hal-627-starfield-cb6fa2 2026-10-21 14:54:23 +01:00
Said Turksever 2da8ced642 Merge pull request #1286 from asturksever/fix/since-note-inline
fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
2026-10-21 14:50:45 +01:00
Said Turksever a397c94892 Merge branch 'main' into fix/since-note-inline 2026-10-21 14:36:24 +01:00
Mazeyar Moeini Feizabadi 7932259dc4 refactor(ui): share native dropdown and numeric field surfaces 2026-10-21 15:35:49 +02:00
Mazeyar Moeini Feizabadi 5a11bd9515 docs: add shared action buttons changelog entry 2026-10-21 15:22:21 +02:00
Mazeyar Moeini Feizabadi 538f2f1bc0 refactor(ui): share Scene and CCTV action buttons 2026-10-21 15:22:00 +02:00
Mazeyar Moeini Feizabadi 5a1598fd9e Merge pull request #1287 from bilawalsidhu/codex/shared-sliders
refactor(ui): unify ordinary slider components and styling
2026-10-21 14:19:55 +02:00
Mazeyar Moeini Feizabadi bf0e1d7c79 Merge current main into shared sliders 2026-10-21 14:10:40 +02:00
Mazeyar Moeini Feizabadi 415edbcfdf Merge pull request #1301 from bilawalsidhu/codex/shared-segmented-selectors
Unify segmented selectors and fix compact control spacing
2026-10-21 13:27:12 +02:00
Mazeyar Moeini Feizabadi 4bfee31fdb Reserve full height for imagery segmented controls 2026-10-21 13:21:23 +02:00
Mazeyar Moeini Feizabadi 7df8b369e4 Add changelog for shared segmented selectors 2026-10-21 13:08:35 +02:00
Mazeyar Moeini Feizabadi 40394ff76e Unify segmented selectors and fix compact panel spacing 2026-10-21 13:07:51 +02:00
German KhokhlovandGPT-6 27d4c0ba9c fix(sky): preserve dark model lighting and address review
Merge origin/main and retain both format-scope additions.
Give StarrySky a separate black environment cube map and release it on destroy.
Guard anime updates when StarrySky is absent, require a visible Normal sky
before anime QA, and compare a lit airplane against black and white controls.
Document the sky background contract and its verification.

Co-Authored-By: GPT-6 (Codex) <[email protected]>
2026-10-21 14:21:14 +04:00
German KhokhlovandCodex fd78bc3884 fix(zh-CN): localize readouts and keep short labels intact
Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-21 13:36:38 +04:00
German KhokhlovandCodex d6c4d58a2d i18n(zh-CN): apply the language review
Correct weather, imagery, scene, voice, and status wording after the Chinese UI review. Align the translation glossary with the revised terminology.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-21 13:35:52 +04:00
6a40b30d12 docs(changelog): credit the zh-CN translators by GitHub handle
Enzo's commit email is not linked to his GitHub account, so credit his #914
translation here as well.

Co-authored-by: Enzo Abyie <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:02:34 +04:00
German KhokhlovandClaude Opus 5.5 da1dd928ef docs(changelog): Simplified Chinese (#1298)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:55:21 +04:00
German KhokhlovandClaude Opus 5.5 f3fa1fa022 i18n(zh-CN): translate the strings from the latest main
Gemini Live voice and the street-level coverage hints; drop the entries the
code no longer uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:54:55 +04:00
German Khokhlov c75007bb13 Merge remote-tracking branch 'origin/main' into german/hal-643-zh-cn
# Conflicts:
#	scripts/package-boundaries.json
#	src/i18n/locales/es.json
#	src/i18n/locales/ru.json
2026-10-21 12:53:43 +04:00
German KhokhlovandClaude Opus 5.5 c4cc499d58 i18n(zh-CN): complete and ship Simplified Chinese
- Translate the remaining 910 strings and review the whole catalog (meaning,
  terms, punctuation, label length) against the screens.
- Register zh-CN: any zh tag maps to it except Traditional ones (Hant, TW,
  HK, MO), which keep English until a Traditional catalog ships.
- Hemisphere letters get their own context so the HUD sector line reads
  21.33N, not a compass word; the Bhote Koshi imagery dates follow
  year-first languages (2021年10月16日).
- cjk.css: tight tracking for Chinese text and a 9px floor on the dock voice
  labels and layer buttons; Latin HUD readouts keep their look.
- docs/translations/zh-CN.md: style, plurals and glossary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:52:34 +04:00
EDIT-Aeterna db16d8ed4c i18n(zh-CN): Simplified Chinese strings from #979
The Simplified Chinese localization by EDIT-Aeterna (#979), converted to the
English-keyed catalog: 823 more entries for UI text that #914 did not cover.
2026-10-21 12:52:34 +04:00
Enzo Abyie 3eb585fe49 i18n(zh-CN): Simplified Chinese strings from #914
The Simplified Chinese locale by abiyeenzo (#914), converted from its
namespaced JS catalogs to the English-keyed zh-CN.json: 891 entries whose
English still matches the current UI.
2026-10-21 12:52:34 +04:00
GPTmadeitandClaude Opus 5.5 661a6cca02 docs(changelog): name S2 GhostMaps fragment after #1297
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:25:12 -04: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
GPTmadeitandClaude Opus 5.5 1f77d2290c feat: add S2 GhostMaps live intel layer
Adds an "S2 GhostMaps" layer (Events group) that renders S2 Underground's
GhostMaps KMZ exports (Common Intelligence Picture, Kinetic 30d, Border
Crisis, Iran CIP/Kinetic, Yemen FLOT, latest incident drop).

- server/providers/ghostMaps.js: watcher polls the repo's git smart-HTTP
  ref advertisement (default every 15s, GHOSTMAPS_POLL_SECONDS), fetches
  the tree only on a new head, caches commit-pinned files by blob sha and
  pushes catalog updates to browsers over SSE (/api/ghostmaps/events).
- src/layers/ghostMaps: KML reader, ArcGIS popup-table parsing, per-product
  data sources, product chips, horizon-culled pins, report cards.
- Unit tests, QA gate (scripts/qa-ghostmaps.mjs), credits (CC BY-NC-SA 4.0),
  i18n, voice manifest, layer-state token 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 16:24:53 -04:00
Sameh Khamis 713285b554 Merge pull request #1205 from manjunath22466/fix/cctv-tool-routing
fix(voice): report actual cctv toggle results
2026-10-20 10:15:33 -07:00
Sameh Khamis 13b474d302 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 10:05:39 -07:00
Sameh Khamis 23feb16b0f Merge pull request #1235 from manjunath22466/fix/location-resolution
fix(analyst): disambiguate region names and retain boundary provenance
2026-10-20 10:05:27 -07:00
Sameh Khamis 3d75b9e7ee Merge branch 'main' into fix/location-resolution 2026-10-20 09:53:07 -07:00
Sameh Khamis da9beb9324 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 09:49:00 -07:00
Sameh Khamis 803cddd5cb docs(analyst): describe region resolution as current state 2026-10-20 09:48:49 -07:00
Sameh Khamis 5f99cd25ec docs(changelog): credit the GitHub handle for #1205 2026-10-20 09:48:49 -07:00
Said 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 Turksever 064d51f7be Merge pull request #1237 from 419vive/feat/taiwan-freeway-cctv-pack
feat(cctv): add Taiwan Freeway Bureau camera pack
2026-10-20 15:11:05 +01:00
Said 116f28a525 test: isolate keyed and keyless Traffic browser sessions 2026-10-20 14:55:18 +01:00
Said Turksever 1ec21c59ce Merge remote-tracking branch 'upstream/main' into fix-1237
# Conflicts:
#	README.md
#	docs/CURRENT-STATE.md
2026-10-20 14:54:57 +01:00
Said 77ed0a7c31 test: run Traffic fixtures with normal browser security 2026-10-20 14:53:08 +01:00
Said TurkseverandClaude Opus 5.5 ce5f69b586 fix(cctv): back off the Taiwan catalog when unreachable; review fixes
- taiwan.js: a failed download serves the last good table (up to 24 h)
  and is not retried for an hour. The table host does not answer from
  some networks outside Taiwan, and each attempt held the shared catalog
  refresh for the ~10 s connect timeout.
- Own server/providers/cctv/taiwan.js in package-boundaries.json
  (check:boundaries failed).
- Decode numeric character references; name falls back to the CCTVID
  when a row has no road details.
- Credit names the dataset and links the OGDL Taiwan licence.
- Document CCTV_TAIWAN_URL; list Taiwan in the CURRENT-STATE source table.
- Tests: isolate catalog env, reset loader state, cover the byte cap,
  failed fetch, backoff, last-good fallback, cap clamping, redirect with
  a valid body, name fallback, numeric entities and UUID camera ids.

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

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

Co-authored-by: Cursor <[email protected]>
2026-10-20 14:40:01 +01:00
Said 5b9c726f33 test: prove hidden-tab polling in a real browser 2026-10-20 14:37:57 +01:00
Said eb2ac9197d test: keep weather teardown browser actively rendering 2026-10-20 14:37:05 +01:00
Said Turksever f882626612 Merge pull request #1228 from quanticsoul4772/upstream/ontario-511-key
fix(cctv): Ontario 511 cameras take the developer key the API now requires
2026-10-20 14:28:09 +01:00
Said Turksever 72983215fc Merge branch 'main' into upstream/ontario-511-key 2026-10-20 14:21:35 +01:00
Said 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 d6f89e3cfd fix: release weather camera listeners while disabled 2026-10-20 14:01:22 +01:00
Said 9fd434438b test: target mounted voice input in radio takeover gate 2026-10-20 13:50:42 +01:00
Mazeyar Moeini Feizabadi 64fd9ba1c2 docs: add shared slider changelog fragment 2026-10-20 14:49:06 +02:00
Mazeyar Moeini Feizabadi f1c617ad27 refactor(ui): share ordinary slider components 2026-10-20 14:48:42 +02:00
Said TurkseverandClaude Opus 5.5 f6006358ed fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
The slim-down in #974 dropped --sl-text-small, which .sl-since-note still
used, so with KartaView lit "KartaView: by upload date" rendered on its
own line larger than the value. The note now sits beside the value in the
same mono type and letter spacing, a size smaller (0.5rem), as
"(by upload date)". One source is lit at a time, so the provider name is
dropped. es/ru catalogs, tests, README, CURRENT-STATE and the registry
JSDoc follow.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 14:02:09 +04:00
German KhokhlovandClaude Opus 5.5 8c33c6f349 docs(sky): shorten the sky comments and README, fix three stale ones
The far-plane comment said z = w (the code puts z just under w), the Milky
Way constant is a brightness, not an opacity, and the CDS acknowledgement
lives in the Data attribution popover, not DATA_SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:56:44 +04:00
German KhokhlovandClaude Opus 5.5 b00c7c12c8 refactor(sky): cut what the sky does not need
- qa-anime-sky keeps only the Milky Way case; the satellites and keyless
  cases checked an anime shader this branch no longer changes.
- Drop qa-sky-sun-moon.mjs: the viewer-options unit test covers it.
- VisualEffects sets the sky's visibility directly: the viewer always
  carries a StarrySky and never outlives the effects.
- Drop the unused star intensity knob, the malformed-row tolerance for
  our own pack, the test-only Transforms parameter and a private-name test.
- DATA_SOURCES points to sky/README.md instead of repeating it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:45:36 +04:00
German KhokhlovandClaude Opus 5.5 d94cc35c81 fix(sky): keep the Milky Way out of model lighting, test the sky wiring
Scene reads skyBox._cubeMap as the environment map that lights models, in
the TEME, flipY layout of Cesium's own sky box; the ICRF Milky Way would
light them rotated. Rename the field so models keep Cesium's default.

Test the anime fade on a real StarrySky and that the viewer gets the sky
as an option (skyBox: false would drop the Sun and Moon). Correct three
comments on the star limit and the Milky Way levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:37:43 +04:00
German KhokhlovandClaude Opus 5.5 86ae5e9cf4 fix(sky): blur the faint Milky Way glow wider so unresolved stars leave no clumps
The fine 0.4 degree blur kept the residual glow of unresolved stars as
mottled patches at the band's edges. Blend towards a 2 degree blur where
the coarse copy is faint (smoothstep over 0.5-8 % of the range), so the
core keeps its dust lanes and the faint sky goes smooth. Faces 85 -> 62 kB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:02:48 +04:00
Rudy Celekli 140c18c55a docs: add scheduled route plausibility changelog 2026-10-20 03:35:27 -04:00
Rudy Celekli 876cd5e4a5 fix(flights): bound route labels to the scheduled leg 2026-10-20 03:22:18 -04:00
manjunath b48fcc42ae fix(voice): refuse direct cctv toggles outside the shared lifecycle 2026-10-20 10:00:24 +05:30
manjunath b920d5f404 merge main into fix/cctv-tool-routing 2026-10-20 10:00:13 +05:30
Rudy Celekli 57983fb4c2 docs: add satellite cache provenance changelog 2026-10-19 22:16:37 -04:00
Rudy Celekli b43970eecc fix(satellites): retain cached catalog provenance 2026-10-19 21:58:22 -04:00
manjunath b0e0945360 fix(analyst): restore exact region matches and remove search rescue 2026-10-20 06:31:50 +05:30
Said 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 f436d72d1b docs: record terrain fix 2026-10-20 00:57:54 +01:00
Said 7860cbf59e fix(terrain): bound each height request to one upstream chunk 2026-10-20 00:57:24 +01:00
Said 2f23bbe87f docs: record forecast fix 2026-10-20 00:56:00 +01:00
Said 0cb6857229 fix(weather): preserve enable during first-load replacement 2026-10-20 00:55:40 +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 Turksever 69a87fe238 test: observe aircraft arrival before idle refresh 2026-10-20 00:44:51 +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 53b6eae122 docs: record share fix 2026-10-20 00:38:18 +01:00
Said d49877ad2e fix(share): reject inherited style keys during restore 2026-10-20 00:38:09 +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 e20a56bcb0 docs: record aircraft fix 2026-10-20 00:25:43 +01:00
Said ed2f0685e0 fix(aircraft): coalesce refreshes and cool down OpenSky failures 2026-10-20 00:25:17 +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
Said Turksever 84d96971df fix: let keyless browser validation settle 2026-10-20 00:15:49 +01:00
Said Turksever 2f25ac0784 Merge branch 'main' into codex/fix-overpass-request-admission 2026-10-20 00:06:07 +01:00
Said f11fd3cb05 docs: add changelog for Docker trusted-peer fix 2026-10-19 23:49:59 +01:00
Said Turksever de2138e84c Merge pull request #1233 from yaka0007/fix/tarktee-datex-catalog-hardening
fix(cctv): harden the Tarktee DATEX catalog fetches and dedupe before the cap
2026-10-19 23:45:41 +01:00
Said Turksever f0e0ea42ce test(cctv): verify DEM placement in the keyless globe gate 2026-10-19 23:31:52 +01:00
Said Turksever 89ef90af04 Merge remote-tracking branch 'upstream/main' into codex/pr1233-tarktee-cleanup 2026-10-19 23:27:29 +01:00
Said Turksever 5597c81e7c Merge pull request #945 from trygvehhh/feat/cctv-vegvesen-kartverket-heights
feat: Kartverket ground heights for Statens vegvesen cameras
2026-10-19 23:24:35 +01:00
Said 37acb4842f fix(docker): honor trusted-peer environment overrides 2026-10-19 23:23:52 +01:00
Said Turksever 97630a598a fix(cctv): release Tarktee sibling on fetch rejection 2026-10-19 23:15:43 +01:00
Said Turksever 3744a97f00 Merge branch 'main' into feat/cctv-vegvesen-kartverket-heights 2026-10-19 23:15:41 +01:00
Said c5086f8d6d docs: add changelog entry for PR 1264 2026-10-19 23:14:52 +01:00
Said ac9cd4ab84 fix(overpass): bound query validation and reject cross-site requests 2026-10-19 23:02:46 +01:00
Said TurkseverandClaude Opus 5.5 59646e52f9 Merge upstream main into fix/tarktee-datex-catalog-hardening
Resolve conflicts with #1234: keep redirect: 'manual' on both DATEX
fetches and keep main's 30 s TARKTEE_FETCH_TIMEOUT_MS. Drop this
branch's 15 s constant and its import, which auto-merged into a
duplicate export. Keep both sets of Estonia tests and drop the 15 s
claim from the changelog fragment.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:47:41 +04:00
German KhokhlovandClaude Opus 5.5 e8fd1f2d01 fix(sky): ease the Milky Way black level in at the median sky
Away from the galactic plane the ESO panorama keeps a mottled glow of
unresolved stars and dust. With the black level at the darkest quarter of
the sky and a hard cut, it showed as grey clouds with black holes around
the globe. The black level now sits at the median sky and eases in with a
soft knee on luminance, keeping each pixel's hue: high galactic latitudes
are black, the band keeps its glow. Faces shrink from 247 to 85 KB.

The changelog fragment also notes the anime fade and the new asset weight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:42:23 +04:00
German KhokhlovandClaude Opus 5.5 8deb47560e fix(sky): restore the Milky Way on teardown, tidy the anime QA script
- VisualEffects.destroy() puts skyBox.milkyWayVisibility back to 1, as it
  already restores the borrowed bloom stage, so a surviving viewer does not
  keep a faded sky.
- Rename scripts/qa-anime-space-mask.mjs to qa-anime-sky.mjs: rewrite the
  header for the current approach, drop --space-expr and --repo, import
  puppeteer as a package, reject unknown cases and count the milkyway case
  as reached only while the anime stage is on.
- Put both sky QA scripts under format:check.

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

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

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

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

Each comes with the test that reproduced it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:14:01 +04:00
German Khokhlov 65b98b4971 Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2 2026-10-19 23:06:49 +04:00
German KhokhlovandClaude Opus 5.5 e652e50d5c fix(anime): fade the Milky Way out instead of masking the sky by depth
The far-plane mask in the anime shader read the post-process depth, which
cannot tell the sky from far geometry: with a layer that clamps the near
plane to 0.1 m (satellites) the whole Earth lost its cel bands, keyless
terrain above the ellipsoid horizon did too, and the atmosphere, Sun and
Moon stopped banding.

Restore the anime shader from main and fade the Milky Way glow with the
style instead: VisualEffects sets skyBox.milkyWayVisibility to
1 - anime intensity. scripts/qa-anime-space-mask.mjs checks all three
cases headless and that the anime sky shows no bands.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:50:21 +01:00
German KhokhlovandClaude Opus 5.5 fb81e155b7 fix(sky): keep the Sun and Moon, settle outside the ICRF tables
- Pass the custom sky box to the Viewer instead of `skyBox: false`, which
  also stopped CesiumWidget from creating the Sun and the Moon. Guarded by
  scripts/qa-sky-sun-moon.mjs.
- Outside the IAU 2006 tables (before 1975, after ~2050) the preload
  resolves without data; remember that span and stay on TEME instead of
  preloading and rendering every frame.
- The anime style no longer cel-shades the far plane, where four bands
  turned the faint Milky Way into blotches.
- Paint the planets out after the star-removing opening and with the
  ring median, so no brighter plateau is left; faces rebuilt.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:51:45 +04:00
German KhokhlovandClaude Opus 5.5 6549344946 feat(sky): replace the blocky default sky box with catalogue stars
Cesium's default Tycho-2 sky box is six 1024² JPEGs stretched 3–6× around
the whole-globe view, so space read as an over-compressed JPEG (HAL-627).

The scene now draws its own sky in the Scene#skyBox slot: the 9,096 stars
of the Yale Bright Star Catalogue as point sprites sized in CSS pixels
(magnitude → width and opacity, B−V → colour), over a soft, star-free
Milky Way cube map built from ESO/S. Brunier's panorama (CC BY 4.0). Both
layers share one ICRF→Earth-fixed rotation at the scene clock, with the
TEME rotation filling in until Cesium's IAU 2006 tables load.

Assets total 482 KB, built by scripts/build-bright-stars.mjs and
scripts/build-milky-way.mjs. Frame time and the idle render governor are
unchanged.

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

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

Live: 175 cameras in 3.3 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:04:52 -07:00
Bortlesboat dd6e01353a docs(gbfs): assign changelog fragment to PR 1232
Signed-off-by: Bortlesboat <[email protected]>
2026-10-18 23:58:01 -04:00
Bortlesboat d2c80cffdb fix(gbfs): exclude stations with invalid coordinates
Reject non-numeric and out-of-range feed coordinates before they reach the
map or bike-share queries. Preserve numeric strings, aliases and zero.

Cover malformed records at the parser and public query boundaries.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:40:22 -07:00
blowing-a-raspberry e3e4fc7284 test: cover Vite fs deny hardening 2026-10-18 15:55:56 -05:00
blowing-a-raspberry 1b641fde9f fix: restore escaped repo root after rebase 2026-10-18 15:02:13 -05:00
Blowing Raspberries 0c1bb5a71b docs: note Vite fs deny hardening
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
.git/COMMIT_EDITMSG [unix] (16:23 16/10/2026)                                                                                       1,1 All"/d/AI-Lab/Repos/gods-eye-view/.git/COMMIT_EDITMSG" [unix] 19L, 662B                                                               docs: note Vite fs deny hardening
2026-10-18 15:02:13 -05:00
Blowing Raspberries 509ab57e4b fix: escape repo root in Vite deny glob 2026-10-18 15:02:13 -05:00
blowing-a-raspberry ccb7b7333d fix: scope Vite dist deny to repository root 2026-10-18 15:02:13 -05:00
blowing-a-raspberry 2379d20616 Harden Vite fs deny for local logs and cache 2026-10-18 15:02:13 -05:00
Trygve Hemminghytt 59db4ac227 docs(cctv): state the measured cold lookup time and what a slow first run leaves 2026-10-18 20:22:21 +02:00
Trygve Hemminghytt 15208b5c40 docs: move the changelog entry to changelog.d 2026-10-18 20:03:24 +02:00
Trygve Hemminghytt 80a0954030 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 20:02:40 +02:00
manjunath 8d3c929740 docs: add numbered changelog for typed send rejection 2026-10-18 16:16:39 +05:30
manjunath 7688e53424 fix(voice): skip replies for rejected typed sends 2026-10-18 16:15:06 +05:30
daikaginza 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 558b0275a4 docs(changelog): move the entry into a changelog.d fragment
Main now takes pending entries as changelog.d/<pr>-<slug>.md
files (#787), so the entry leaves CHANGELOG.md for changelog.d/686-terrain-heights-bounds.md.
2026-10-18 13:36:06 +07:00
daikaginza 50559723a5 Merge upstream/main 93ea088f4 2026-10-18 13:36:06 +07:00
manjunath e20e858ba8 docs(changelog): record cctv routing fix 2026-10-18 11:15:09 +05:30
manjunath 9b1967a59e fix(voice): report actual cctv toggle results 2026-10-18 11:13:30 +05:30
quanticsoul4772andClaude Opus 5.5 d39256e700 fix(tools): ship types read as their family; letterless destinations are unknown
AIS sends ship types as ITU-R M.1371 codes, so vessels_in_area grouped
"70", "74" and "79" as three types, and type: "tanker" matched nothing on
feeds that send codes. Codes now read as the family the vessel cards already
show (CARGO, TANKER, TUG), for rows, group_by and the type filter. The code
table moves to src/data/vesselTypes.js, which has no i18n import, so the
server-side tools can use it; vesselLabels re-exports it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Follow-up to #929.

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

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

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

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

No files matched your search

+23 -4
View File
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
# keep them while debugging; delete the log when you are done.
GEV_VOICE_LOG_CONTENT=
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
GEMINI_API_KEY=
GEMINI_LIVE_MODEL=gemini-3.8-live
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
# GEV_RATELIMIT_GEMINI_PER_MIN=30
# Per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
@@ -109,7 +115,8 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# 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
@@ -118,11 +125,13 @@ OPENSKY_AUTH_MODE=oauth
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# Optional: higher Launch Library 2 request allowance. Public access works
# without a token.
# Launch Library 2: leave blank for standard public access (no key needed).
# Optional paid Patreon tokens raise request limits; there is no free API key.
LL2_API_TOKEN=
# Optional: OpenSky credentials JSON file path (alternative to above)
# 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
@@ -201,6 +210,7 @@ AISSTREAM_API_KEY=
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
@@ -221,6 +231,9 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
@@ -228,6 +241,7 @@ AISSTREAM_API_KEY=
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead)
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
@@ -237,6 +251,11 @@ AISSTREAM_API_KEY=
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+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
-6
View File
@@ -67,8 +67,6 @@ jobs:
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -104,10 +102,6 @@ jobs:
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
- name: Portable panel gate
if: success() || failure()
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Preview server log
if: failure()
run: cat preview.log
+4
View File
@@ -16,6 +16,10 @@ output/
.gev-cache/
# Browser QA gate evidence (screenshots, console and network dumps).
qa-artifacts/
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
.playwright-mcp/
# Local Claude Code settings and preview configs.
.claude/
.DS_Store
.gstack/
3d-models/
+734 -49
View File
@@ -1,11 +1,707 @@
# Changelog
## [Unreleased]
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
rounded the bearing without wrapping, so a view facing 359.6° read "360°"
(Aditya, @Rohilalala, #1431).
- The visual styles and their presets mark their labels with the catalog-free
`msg()`, so the share-link codec, and any server that loads it, no longer
bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421).
- Keep a location search that found nothing, or failed, in the field so it can
be corrected: the query stays, focused and selected, and a persistent
announced message under the search row says what to do next, instead of a
toast followed by a cleared and closed field (samehkhamis, #1420).
- Hosts can read share links on their server: the share-link codec and the
`/share/<code>` address helpers are now package exports,
`@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path`
(samehkhamis, #1419).
- Share links are now short: the share button makes `/share/<code>` links
that open on any copy of the app, follow the aircraft, military aircraft or
satellite you were following, and fall back to its last-known position with
a notice if it isn't reporting. A mistyped link is refused instead of
opening a different view. Long `#v=2` links keep working (samehkhamis, #1418).
- Make Clear All clear the saved and shared layer selection. Layers that a
context mode had already switched off no longer stay in the share link and
come back on reload (samehkhamis, #1417).
- Restore a share link opened in an already-open tab. Pasting a link into the
address bar, following one, or using Back/Forward now moves the camera and
restores the style, map, panels and layers instead of changing only the URL
(samehkhamis, #1416).
- Add a Scope checkbox and extend feathering to 200%, retaining scope settings
across HUD layouts, share links and scenes. First run opens with Scope ON at
100% feather; explicit settings and historical links retain their values. Give
Tactical, Operator and Minimal text black backplates only when Scope is OFF,
including vertical metadata, without changing the original HUD text color, glow or
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410).
Keep title and tagline positions stable when toggling Scope, include the eye
in the title backing, and brighten the two secondary labels only with Scope OFF.
- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`,
`src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`.
The local GeoJSON layer tests now build layers the way the catalog does, so
they keep covering `localGeojsonCore.js`. Package exports are unchanged
(samehkhamis, #1403).
- Aircraft on the ground keep getting ground-height samples after the
computer's clock steps back, instead of waiting for it to catch up
(quanticsoul4772, #1399).
- The Sharpen slider at 0 now turns the sharpen pass off instead of still
sharpening at a low amount (quanticsoul4772, #1398).
- 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).
- 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).
- Report Docker container health with a local HTTP check using Node, with the
same port parsing as the launcher (John Rogers, @14traits, #1394).
- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now
mounts the checkout with `:z`; without it the container could not read `/app`
and stopped with "Cannot find module '/app/scripts/docker-start.mjs'"
(Anıl Güvenç, @anilguvenc, #1391).
- Render awareness-overlay bearings and courses that round through north as
`000°`, rather than `360°` (Rudy Celekli, #1390).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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.
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- Wake staged weather imagery on a 150 ms timer while waiting for readiness,
avoiding a continuous render loop and cancelling the wake when work ends
(@nu-stats, #1345).
- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left"
instead of "on the slightly left", and a lane names its side in both
the proxy response and localized browser instructions
(Rudy Celekli, @rudycelekli, #1343).
- 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).
- 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).
- 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).
- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and
degrees, so longitude -122.35 reads `122°21'00.00"W` instead of
`122°20'60.00"W` (rudycelekli, #1339).
- 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).
- 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).
- Add a phone layout for viewports 720px and narrower: the globe stays full
screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one
panel sheet at a time, keeping attribution visible and scene STOP reachable.
Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325).
- 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).
- Remove dead code across the repository: three modules nothing imports
(`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports
and locals nothing reads, a duplicate copy of the bundled dams data and an
unused ship model. The app no longer builds an unused default wind layer
and Bhote Koshi locator layer when it loads. Package exports are unchanged
(samehkhamis, #1321).
- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317).
- Clarify shared field CSS ownership: CCTV owns calibration editor sizing,
while Radio and Cyber supply explicit field palettes without changing the
current appearance or native control behavior (mazy1998, #1309).
- Share read-only status badge presentation across CCTV source/frame labels,
calibration provenance and Weather coverage, preserving status meanings,
palettes, translations and announcements while preparing the view contract
for a future Vue migration (mazy1998, #1306).
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
and Radio controls, with a shared CCTV number-input builder that preserves
existing precision, validation and commit behavior (mazy1998, #1305).
- Point distances wrap longitude differences across the antimeridian, in voice
proximity checks and `places_nearby` (quanticsoul4772, #1304).
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
cached model run (quanticsoul4772, #1303).
- Give ordinary Scene and CCTV actions shared native buttons with consistent
default, destructive and compact styling while preserving their existing
behavior and specialized controls (mazy1998, #1302).
- Unify segmented selectors for model coverage, detection allocation, drawing
shape, Street View and imagery comparison while preserving their existing
behavior. Keep compact labels such as FILL and FLAT on one line, and let
collapsed Weather release space to the open rail panel. Reserve the full mode
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
- Add a shared Voice Preview and Settings panel with browser-local preferences,
inactivity controls and accessible floating provider controls on compact layouts;
recover inactivity after confirmed audio-less replies, release retired audio
observers and keep cost warnings visible outside Settings
(manjunath22466, #1299).
- Add Simplified Chinese (zh-CN) to the language picker, with localized dates
and readable Chinese labels. Based on translations by @abiyeenzo (#914)
and @EDIT-Aeterna (#979).
(German, @gekh, #1298).
- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products
(Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran
and Yemen products and the newest incident drop) from
github.com/s2underground/GhostMaps, with one chip per product and report
cards on click. `/api/ghostmaps` watches the repository's branch head every
15 s and pushes each new commit to open pages, which reload only the
products that changed (Black Pine Defense).
- Clear a temporary GhostMaps upstream error and notify open pages when the
repository becomes reachable again, including when no new edition was published.
- Release packed-image resources when a load is cancelled or fails, respect
injected or omitted GhostMaps sources, and wait for background cache warm-up
before removing provider test fixtures.
- Include Simplified Chinese translations and keep the combined voice prompt
within its existing size limit while retaining all spoken aliases.
- 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).
- Give ordinary sliders a shared native control and consistent track, thumb,
value, focus and disabled styling while preserving feature behavior and
specialized controls (mazy1998, #1287).
- The Street Level SINCE note sits on the value's line, in its type and a
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
had lost its size variable in the Street Level slim-down and rendered
larger than the value, on a line of its own (asturksever, #1286).
- Keep aircraft route labels on the scheduled airport leg instead of accepting
its distant great-circle extensions, preserving generous endpoint tolerance
across the signed-angle branch cut (rudycelekli, #1284).
- Report cached CelesTrak satellite records as stale in the Data Layers row,
preserving separate core and dense catalog provenance through refresh,
removal and lifecycle cleanup (rudycelekli, #1283).
- 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).
- 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).
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
- Reserve singleflight before disk cache reads (asturksever, #1278).
- Bound each height request to one upstream chunk (asturksever, #1276).
- Preserve enable during first-load replacement (asturksever, #1275).
- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275).
- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275).
- Scan the kml root delimiter in bounded time (asturksever, #1274).
- Retire unavailable history and keep local trails (asturksever, #1273).
- Normalize geocoder bias across the antimeridian (asturksever, #1272).
- Reject inherited style keys during restore (asturksever, #1271).
- Respect provider cooldowns and bound tle groups (asturksever, #1270).
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
- 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).
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
access to Provider Settings before Vite starts (asturksever, #1265).
- Prevent malformed Overpass filters from blocking the local server, and reject
cross-site or excessive query requests before reading their bodies
(asturksever, #1264; fixes #1248).
- Release unread responses from the optional Google-token probe so keyless
browser validation can settle; the Overpass gate recognizes only that probe's
expected 404 as an unavailable capability (asturksever, #1264).
- Make tracking arrival checks respect the aircraft update cadence while checking
orientation on the first reveal, avoiding false failures at faster render
rates (asturksever, #1264).
- 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).
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
Yale Bright Star Catalogue, drawn as sharp points where they are at the
scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun
and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh,
#1243).
- `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).
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
with on-demand snapshots and attribution. No API key is required; set
`CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237).
- Require a geocoded containment anchor for ambiguous analyst region names,
preserve boundary provenance and cancellation safety, and retain exact-name
precedence for natural outlines (manjunath22466, #1235).
- Serve the camera catalog while it refreshes. An expired catalog made every
camera request wait for the slowest pack to reload; it is now answered at
once while one background refresh runs, so only the first load waits. Estonia's
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
on about a third of loads (quanticsoul4772, #1234).
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
cancel both response streams when either response fails or overflows the 4 MB
byte cap, and deduplicate camera IDs before per-pack prioritization
(yaka0007, #1233).
- Skip bike-share stations with missing, blank or invalid coordinates instead
of placing them at coerced zero coordinates or accepting out-of-range values.
Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232).
- 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.
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
otherwise skipped with one log line saying where to get a key
(quanticsoul4772, #1228).
- 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).
- Add continuous globe rotation with direction and speed controls and a globe-fit
check before starting (manjunath22466, #1225).
- Open God's Eye View from Pinokio in the system browser using the supported
`popout` menu option. The application's framing policy is unchanged
(NukeThemAII, #1222).
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
command, while preserving an earlier accepted deferred input
(Manjunath, #1220).
- 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).
- Report actual outcomes when voice enables or disables CCTV through the shared
layer lifecycle, including failed and cancelled changes (manjunath22466,
#1205).
- Draw CCTV stills at their source aspect on the monitor plane instead of
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
every still was widened; they are now centered and pillarboxed on the dark
the placeholder already ends on (Danielslay86, #1201).
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
video camera could only be found by opening it. Stills stay cyan and the
active camera stays amber, and focus de-emphasis fades the violet the same
way (Danielslay86, #1200).
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
launch data, and return to NOMINAL after a fresh response without discarding
retained missions (rudycelekli, #1199).
- Share Launch Library request cache keys across installations refreshing in the
same UTC minute, identify the app to the provider, and explain that standard
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
upgrades; existing local cache and server-only token handling are unchanged
(rudycelekli, #1197).
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
BicinRivas, about 1,840 stations. Station names read underscores as word
breaks, and station capacity counts docks rather than PBSC's geofenced area
(ulihhhh, #1190).
- Keep missing launch-pad coordinates unknown so launches use the location
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
flight numbers no longer show "FLIGHT 0"
(Rudy Celekli, @rudycelekli, #1186).
- Flights' ground floor works across the antimeridian. The viewer-distance
check behind the 150 km floor radius and its copy in the photoreal mesh-floor
sampler subtracted longitudes without wrapping, so two points 21 km apart
across 180° measured about 38,000 km and low or grounded aircraft just across
the line from the viewer were never floored. A contact's display corridor
walked a segment across 180° the long way round, spending its cell budget on
ground behind the contact, and a contact on the seam lost its cell hysteresis.
SEARCH NEARBY SITES on a view across 180° searched Google Places around
longitude 0, on the far side of the globe, with a radius sized from the view's
height alone; it now searches the view (Aditya, @Rohilalala, #1173).
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
turns null, blank strings and booleans into 0 or 1, and the camera source
list sends an unknown number as JSON null, so a source-pack camera that left
out its pose reached the browser as zeros clamped to each field's floor —
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
city's ground); a null or blank position placed the camera at 0,0. The
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
blank and boolean values as missing. The same conversion also stops a
Tallinn row with a null heading reading as a confident north-facing camera,
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
or on the zero meridian (omerbberk, #1172).
- Director shot actions land on the requested shot. A seek to a shot's start
could round a hair below it and select the end of the previous shot for some
shot counts and durations (Aditya, @Rohilalala, #1171).
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
areas enclosed near ±180° are included, areas at the opposite meridian are
excluded, and returned perimeter coordinates stay near the dateline. Incident
cards use the same circular longitude mean and compare multipart outline
areas along short longitude edges, keeping the card on the largest polygon
(Arianna Method, @ariannamethod, #1142).
- Scenes keep the "Scene not saved" status when a capture, update, append or
import cannot be saved, and an unreadable saved project no longer overwrites
the recovery checkpoint. `startScene()` resolves to its documented result,
`afterShotId` works without an explicit scene, and `scenesRun` counts the
distinct scenes queued (djabhi31, #1135).
- Label camera-tool Street View fallbacks as historical Google imagery rather
than the current camera view; keep unknown frame provenance explicit
(rudycelekli, #1118).
- Release superseded Google Street View open waits and suppress metadata
requests whose viewer was unmounted while the service loaded (rudycelekli,
#1117).
- Exclude aircraft without a reported altitude from requested altitude bands;
unfiltered aircraft queries still retain them (rudycelekli, #1115).
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
at once, but no longer lasts the whole session: while the fallback is on
screen, the real provider is retried in the background (30 seconds, doubling
to a 10-minute cap) and replaces it live once it loads. Map switches never
wait for a retry (Aditya, @Rohilalala, #1111).
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
spliced catalog cannot pair one satellite's elements with another's; the next
valid set still parses (VG31OP, #1101).
- Overpass data whose element tags contain a failure phrase ("timed out",
"runtime error", "too many requests" and similar, e.g. a place named "Timed
Out Escape Rooms") is returned as data instead of being refused and putting
the endpoint on cooldown; upstream failures are read from the remark (Aditya,
@Rohilalala, #1097).
- Military aircraft keep their last heading and speed when an adsb.lol row omits
`track` or `gs`, instead of turning north and stopping; a grounded row without
a speed still reads 0 (Aditya, @Rohilalala, #1096).
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
#1094).
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
the existing coordinate framing and label. Unsupported URI fields are passed
to the remaining search providers unchanged (cj-vana, #1071).
- Keyless standalone startup answers an unavailable Google tile-token endpoint
with uncached 204 instead of a browser-visible 404. Existing providers take
precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066)
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
DEP0190 warning ("Passing args to a child process with shell option true can
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
the shell command string instead of an argument array (quanticsoul4772, #1057).
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
MCP client can ask which airlines, countries, aircraft types or ship types
dominate an area, or the average altitude or speed, over every match instead
of estimating from the returned rows. Airlines come from the ICAO designator
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
that report no operator; operator and type are counted only where the feed
reports them. Ship types count by family, so AIS codes 70 to 79 are all
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
unknown. Altitude and speed stats cover airborne aircraft only, so parked
aircraft do not pull the means down. Every vessel answer, `find_vessel`
included, now reads AIS ship-type codes as their family and a destination
with no letters as unknown (quanticsoul4772, #1056).
- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV
source pack: the public DATA.GOV.HK English locations XML, frames pinned to
`tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By
default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads,
prioritized across Hong Kong Island, Kowloon and the New Territories. Headings
use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets
the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990).
- Cross-site pages can no longer fill the terrain-height cache or spend the
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
the cost-bearing routes it still answers same-origin requests that arrive
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
#975).
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
- Cross-site browser requests can no longer spend the TomTom traffic-tile
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
cache, and upstream work; same-origin app requests and non-browser local
callers remain supported (Sunil56224972, #973).
- Recent Imagery: opening DETAILS scrolls its card into view when it opens below
the fold. The panel used to restore the previous scroll position right after
the reveal, so the card stayed out of sight (Ian Blenke, #970).
- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`,
`.gev-logs/`, and `.gev-cache/` are denied without blocking dependency
`dist/` directories, and checkout paths containing glob metacharacters are
escaped before building the root-anchored pattern (blowing-a-raspberry, #948).
- Give each Statens vegvesen camera its own ground height from Kartverket's
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
mountain-pass cameras no longer sit hundreds of metres below the terrain
where neither the point-height prior nor the ground snap resolves. About 850
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
points, the most the API answers from its 1 m terrain model, eight at a
time; a cold lookup takes about 6–13 s. Cameras over water use the water
surface rather than the sea-floor depth the API returns there; for bridge
cameras that is still below the deck. Heights are cached for good and misses
for 6 h, so a refresh only asks about new or moved cameras. After a
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
an unreadable body) the lookup stops and backs off (Retry-After, else
30 min, longer than the refresh). The lane waits at most 12 s from its
start, and only on a cold cache; a slower first lookup leaves the cameras it
had not reached on the flat prior until the next 15-minute refresh, and
keeps filling the cache meanwhile.
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
- Extract a reusable model-free World Packet from the existing voice
`get_current_view_state` owner without restoring disabled layers to the
model context. Keep the enabled-only payload, disabled-layer count, source
provenance, action result and view-state contract; read the layer roster once
per packet; voice retains the speech/card envelope at its own boundary.
Credits: original GEV voice/layer contributors and #880 implementation.
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
server-held API key, microphone streaming, shared map actions and captions.
Switching providers ends the old conversation and preserves the input mode
(Manjunath, #872).
- Close the compact Radio disclosure when an adaptive tactical right-rail
restore directly expands Context, before Space Missions can be covered.
A repeated Context-open request also clears stale disclosure state.
Real PanelChrome constructor wiring and the rail reflow path are covered
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
#804 report, asturksever's review, and quanticsoul4772's independent
Windows diagnosis.
- Changelog entries are now one file per pull request. A change adds
`changelog.d/<pull request>-<slug>.md` instead of editing the top of
`CHANGELOG.md`, which two pull requests in flight could not share — it was the
most frequent merge conflict in the open queue, and for six of them the only
one. `npm run changelog` splices the directory into `CHANGELOG.md` at release
time and empties it; `npm run changelog:check` reports the batch and changes
nothing. Fragment text is copied through unchanged (daikaginza, #787).
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
numbers above 99999: a letter for the first two digits, skipping I and O
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
but the first were dropped as duplicates; the satellite tools read the same
field as no number, so such a satellite could not be found by it. The layer
and the tools now decode the field in `src/sources/tle.js`, and reject a
malformed one (a sign, decimal point, hex, embedded space or stray letter)
instead of reading part of it as a number. This is Alpha-5 TLE
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
cache files and logs there while the app runs, and none of it is source, so
the watcher now skips it; this guards against the DNS-lookup delays heavy
cache writes have caused, though no difference was measured on current main
(monwiss, #761).
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
5-decimal point was a permanent in-memory entry, a permanent line in an
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
nothing limiting the rate. The point cache now holds at most 50,000 entries
and evicts oldest-first, a cache file above
24 MB is not read back, and the route is limited to 90 requests/minute per
client (270 global). Evicting a point costs one refetch, never a wrong
answer, since terrain does not move. Ordinary use is unaffected: a full
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
the statewide ArcGIS catalog, read a page at a time under a byte cap with
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
FL511's terms limit its content to individual, personal use, and any other
use (a public deployment included) needs FDOT's express written consent, so
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
catalog's direction codes, and camera ids come from the image channel, since
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
sets the cap (Danielslay86, #605).
- The client's great-circle distances now come from one shared
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
points). The Earth radius is standardized on 6371.0088 km, so distances shift
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the
civil and military layers, so a consumer reading `getTrackedInfo()` no longer
has to reach past it into the raw poll record for climb/descent. Reported,
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
@@ -282,8 +978,6 @@
point. A slow render gets one bounded second frame request, while hidden,
stale, moved-camera, invalid and oversized captures still fail closed.
## [Unreleased]
- Add a keyless Air quality layer to the Weather group, with Environment and
Climate Change Canada's AQHI as the first registered provider. Readings carry
`{ provider, scale, value, band }` so another network can be added without
@@ -317,9 +1011,8 @@
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
(orange; off by default, one provider at a time): below about 60 km the
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
line opens that sequence's picture nearest the click without moving the
camera, flat or 360°, with cones along the sequence around it and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
@@ -377,10 +1070,10 @@
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps and FOLLOW. Share links carry it in the provider switches
to Google Maps. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
are all 360°, so the IMAGERY and SINCE filters apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
@@ -419,21 +1112,16 @@
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Street Level: street-level imagery from providers, Mapillary first (free
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
360°/flat and captured-since filters and an embedded viewer with EXPAND and
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
Google 3D tiles; opening a photo frames it with one camera flight. Share
links carry the provider switches, the filters and the panel's collapsed
state. A server proxy adds the token to Mapillary coverage tiles, trims and
caches them for 24 h, refuses cross-site requests and redirects, and limits
load per IP and upstream. The panel reads KEY REQUIRED without a token and
KEY REJECTED when Mapillary refuses it.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
@@ -504,6 +1192,28 @@
needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
fallback order are unchanged.
- Voice can switch on every shipped data layer (the two scene-driven
Bhote Koshi layers stay scene-only), including transit, Recent
Imagery, wind, the weather layers, cyclones and mapped installations, and
understands their spoken names ("buses", "hurricanes", "military bases").
One voice manifest (`src/voice/layerManifest.js`) generates the tool enums
and aliases, and a unit test fails when a new layer has no entry.
- `analyst_query` counts up to 250,000 loaded records per layer instead of the
first 2,000, and says when a layer went past that (`complete: false`). It
also answers over bikeshare stations, transit vehicles, rocket launches,
cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire
records carry their sensor (VIIRS or MODIS).
- Unknown fields, operators, units, value types, scope kinds and invalid
centres are refused with the allowed values instead of answering zero or widening to anywhere. A
layer that is off, still loading or unavailable is reported as such, not
as a count of zero; a partly answerable query is marked `partial`.
Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)`
converts, and large rankings no longer sort the whole set.
- Results carry `lat`/`lon` for each item and a `display` object with the
scope, the feed window (earthquakes and fires cover the last 24 h) and any
caveat for the screen. The model is asked to answer in one short phrase.
- The weather and Recent Imagery panels can be opened by voice.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
@@ -786,32 +1496,6 @@
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
`MapSourceController.subscribe()` reports every settled map switch.
## Unreleased — voice reaches every layer; honest analyst answers
- Voice can switch on every shipped data layer (the two scene-driven
Bhote Koshi layers stay scene-only), including transit, Recent
Imagery, wind, the weather layers, cyclones and mapped installations, and
understands their spoken names ("buses", "hurricanes", "military bases").
One voice manifest (`src/voice/layerManifest.js`) generates the tool enums
and aliases, and a unit test fails when a new layer has no entry.
- `analyst_query` counts up to 250,000 loaded records per layer instead of the
first 2,000, and says when a layer went past that (`complete: false`). It
also answers over bikeshare stations, transit vehicles, rocket launches,
cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire
records carry their sensor (VIIRS or MODIS).
- Unknown fields, operators, units, value types, scope kinds and invalid
centres are refused with the allowed values instead of answering zero or widening to anywhere. A
layer that is off, still loading or unavailable is reported as such, not
as a count of zero; a partly answerable query is marked `partial`.
Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)`
converts, and large rankings no longer sort the whole set.
- Results carry `lat`/`lon` for each item and a `display` object with the
scope, the feed window (earthquakes and fires cover the last 24 h) and any
caveat for the screen. The model is asked to answer in one short phrase.
- The weather and Recent Imagery panels can be opened by voice.
## Unreleased — local receiver feeds
### Local receiver feeds
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
@@ -954,7 +1638,6 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public
@@ -1231,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
@@ -2039,3 +2723,4 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09
- Initial project version.
+35 -10
View File
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
`qa:transit`.
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -66,11 +65,10 @@ gate for the feature you changed.
> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
> production build, plus a Windows onboarding job, and against the built preview
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
> server and a browser. Include the applicable local run in your PR's
> validation evidence.
> production build, plus a Windows onboarding job. It runs neither
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
> gate (`street-level-browser` job) — they need a live dev server and a
> browser. Include the applicable local run in your PR's validation evidence.
## Share-link layer tokens
@@ -110,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).
@@ -118,7 +116,7 @@ The highest-leverage places to jump in:
## Architecture in one minute
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
@@ -155,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
+19 -4
View File
@@ -20,10 +20,10 @@ How to read this:
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -38,6 +38,7 @@ How to read this:
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
@@ -48,6 +49,7 @@ How to read this:
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
@@ -62,7 +64,9 @@ How to read this:
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
@@ -110,6 +114,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
@@ -152,7 +157,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
@@ -162,15 +167,18 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **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.
@@ -198,6 +206,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
@@ -270,6 +280,11 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
wiring (see below).
### Sky background (`sky/`)
Sources, licences, the changes CC BY asks to state, hashes and build commands
are in [`sky/README.md`](src/data/local_data/sky/README.md).
### DataSF Analysis Neighborhoods (`neighborhoods/`)
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
+5
View File
@@ -39,6 +39,11 @@ COPY --chown=node:node . .
ENV HOST=0.0.0.0
ENV PORT=4173
EXPOSE 4173
# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is
# always on the Host allowlist, and the generous start period covers Vite's
# first-run dependency optimisation.
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"]
# The launcher is `npm run dev` plus one step: it trusts the container's
# gateway address for Provider Settings, because that is where Docker delivers
# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs.
+33 -9
View File
@@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports
cross-platform testing of the fixed installer. The launcher installs the
locked dependencies, finds a free local port, and opens the app.
locked dependencies, finds a free local port, and opens the app in your system
browser. The Server menu item keeps the launcher console available.
**Tried before and installation failed?** Update Pinokio and try again.
Version 8.2 fixes the launcher installation issue;
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
**Pinokio opens but the globe is very choppy?** Before treating that as a GPU
**Using an older launcher or opening the embedded view manually?** If the globe
is very choppy, before treating that as a GPU
compatibility or minimum-hardware problem, copy the local server URL shown by
Pinokio and open the same URL in your normal hardware-accelerated browser. One
Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went
@@ -177,6 +179,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in,
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
```bash
GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up
```
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your
shell or the project's `.env`; an exported shell value takes precedence. You can
also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting.
Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly
disables it. The startup log confirms that no additional peers are trusted.
<details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
@@ -197,6 +209,8 @@ env UID=1026 GID=100 docker compose up -d
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
</details>
@@ -309,6 +323,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
## 🎙️ Talk to It
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
@@ -329,6 +347,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:
@@ -377,15 +399,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
@@ -421,9 +443,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
@@ -474,7 +496,7 @@ How the globe handles live data:
```
src/
├── main.js # Bootstrap: Google 3D tiles, layer registration
├── ui.js # Runtime UI — panels, HUD, styles, control facade
├── ui/ # Runtime UI — panels, HUD, styles, controls
├── hud.js # Intelligence HUD + AI scene summary
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
@@ -527,9 +549,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
| | Key | Why | Get it |
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
Add these if you need higher polling allowances.
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
paid Patreon upgrades for higher limits; there is no free API-key signup.
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
where each configured provider was found without printing credential values.
+14 -2
View File
@@ -2,6 +2,11 @@
God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly.
Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and
the package checkout's root `dist/` directory. Dependency `dist/` directories
remain available. This filesystem deny list does not filter `public/` assets or
preview output; never put private artifacts in a public asset directory.
## Reporting a vulnerability
Please report security issues **privately** — do not open a public issue for anything exploitable.
@@ -29,7 +34,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
@@ -69,11 +74,13 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
- **Response-size caps and timeouts** on proxied responses.
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
@@ -98,6 +105,11 @@ The dev server is a **key broker**: every server-side key above is spendable by
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
client arrives from the same gateway address, which hands them Provider
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from
the shell or project `.env` before the launcher runs. Set trusted peers to
`none` when widening the publish: it becomes an explicit empty list before
Vite loads configuration, so the gateway cannot regain Provider Settings
access. Unset or blank values preserve automatic gateway discovery.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
-2
View File
@@ -26,8 +26,6 @@ export function panelBuildConfig(config) {
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
// The panel is the app alone: one page, so one inlined script.
input: undefined,
output: { inlineDynamicImports: true },
},
},
+104 -11
View File
@@ -5,11 +5,24 @@ import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
/** HTML documents a production build emits: the app and its mobile shell. */
export const BROWSER_PAGES = Object.freeze({
main: fileURLToPath(new URL('../index.html', import.meta.url)),
mobile: fileURLToPath(new URL('../mobile/index.html', import.meta.url)),
});
/**
* The repository root (this file lives at `<root>/build/vite.js`), as a
* forward-slash path for glob matching. Vite's `fs.deny` patterns are
* matched against the full absolute file path, and a bare `**\/dist/**`
* matches a `dist` directory at *any* depth — including dependencies that
* ship their own, such as `node_modules/vite/dist/client/client.mjs` (the
* dev client itself). Anchoring to this repository's root denies only the
* project's own build output.
*/
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url))
.replace(/\\/g, '/')
.replace(/\/$/, '');
export function escapeFsDenyPath(path) {
return path.replace(/[()[\]{}*?!+@|]/g, '\\$&');
}
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
/**
* Content-Security-Policy for every document the dev/preview server serves.
@@ -54,6 +67,75 @@ export const BROWSER_HEADERS = Object.freeze({
'X-Content-Type-Options': 'nosniff',
});
/**
* Content-Security-Policy for every `/api` response. API routes return data,
* never a document, so a response opened directly in a tab gets no origin and
* can load nothing.
*/
export const API_CSP = "default-src 'none'; sandbox";
/** Response headers every `/api` response carries. */
export const API_HEADERS = Object.freeze({
'Content-Security-Policy': API_CSP,
'X-Content-Type-Options': 'nosniff',
});
/**
* Whether a request reaches an `/api` mount, by the router's own rule: the
* prefix compared without case and followed by the end, `/` or `.`. The router
* reads an absolute-form target's path without resolving dot segments, so the
* path is taken here the same way (scheme and authority dropped, backslashes as
* slashes), alongside the raw and the fully parsed path; any match counts.
*/
function isApiPath(url) {
const raw = String(url || '/');
const target = raw
.trim()
.split(/[?#]/, 1)[0]
.replace(/\\/g, '/')
.replace(/^[a-z][a-z0-9+.-]*:(?:\/\/[^/]*)?/i, '');
let parsed = '';
try {
parsed = new URL(raw, 'http://localhost').pathname;
} catch {
/* the unparsed paths still decide */
}
return [raw.split(/[?#]/, 1)[0], target, parsed].some((path) =>
/^\/api(?:$|[/.])/i.test(path),
);
}
/**
* Vite plugin that sets API_HEADERS on every API response before any other
* middleware runs, so success, error, partial, refused and unknown-route
* answers all carry them. Vite's `server.headers` and `preview.headers` only
* reach the documents and assets Vite serves itself. A route may still add
* headers; a route's own Content-Security-Policy is sent as an additional
* policy, which can only restrict further, never replace this one.
*/
export function apiResponseHeadersPlugin() {
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isApiPath(req.url)) return next();
for (const [name, value] of Object.entries(API_HEADERS))
res.setHeader(name, value);
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === 'content-security-policy' &&
String(value) !== API_CSP
? setHeader(name, `${API_CSP}, ${value}`)
: setHeader(name, value);
next();
});
};
return {
name: 'api-response-headers',
enforce: 'pre',
configureServer: install,
configurePreviewServer: install,
};
}
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
@@ -68,7 +150,9 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
// First, so no provider route answers a Host the server does not allow.
// First, so every API response, including a Host refusal, carries them.
apiResponseHeadersPlugin(),
// Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
@@ -101,7 +185,19 @@ export function createBrowserViteConfig({
// build/allowedHosts.js); IP addresses are always accepted.
allowedHosts: [...allowedHosts],
fs: {
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
deny: [
'.env',
'.env.*',
'*.{crt,pem}',
'**/.git/**',
'**/ENVIRONMENT',
'**/.gev-logs/**',
'**/.gev-cache/**',
// Root-anchored: a bare '**/dist/**' would also deny every
// dependency's own dist/ (e.g. node_modules/vite/dist/client),
// breaking /@vite/client and other dev-client assets.
`${REPO_ROOT_GLOB}/dist/**`,
],
},
// These headers protect the document containing Provider Settings and
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
@@ -120,9 +216,6 @@ export function createBrowserViteConfig({
mapillaryToken ?? '',
),
},
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: { input: { ...BROWSER_PAGES } },
},
build: { chunkSizeWarningLimit: 1500 },
};
}
-4
View File
@@ -1,4 +0,0 @@
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
DEP0190 warning ("Passing args to a child process with shell option true can
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
the shell command string instead of an argument array (quanticsoul4772, #1057).
-3
View File
@@ -1,3 +0,0 @@
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
the existing coordinate framing and label. Unsupported URI fields are passed
to the remaining search providers unchanged (cj-vana, #1071).
@@ -1,4 +0,0 @@
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
#1094).
@@ -1,3 +0,0 @@
- Military aircraft keep their last heading and speed when an adsb.lol row omits
`track` or `gs`, instead of turning north and stopping; a grounded row without
a speed still reads 0 (Aditya, @Rohilalala, #1096).
@@ -1,5 +0,0 @@
- Overpass data whose element tags contain a failure phrase ("timed out",
"runtime error", "too many requests" and similar, e.g. a place named "Timed
Out Escape Rooms") is returned as data instead of being refused and putting
the endpoint on cooldown; upstream failures are read from the remark (Aditya,
@Rohilalala, #1097).
@@ -1,3 +0,0 @@
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
spliced catalog cannot pair one satellite's elements with another's; the next
valid set still parses (VG31OP, #1101).
-5
View File
@@ -1,5 +0,0 @@
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
at once, but no longer lasts the whole session: while the fallback is on
screen, the real provider is retried in the background (30 seconds, doubling
to a 10-minute cap) and replaces it live once it loads. Map switches never
wait for a retry (Aditya, @Rohilalala, #1111).
@@ -1,2 +0,0 @@
- Exclude aircraft without a reported altitude from requested altitude bands;
unfiltered aircraft queries still retain them (rudycelekli, #1115).
@@ -1,3 +0,0 @@
- Release superseded Google Street View open waits and suppress metadata
requests whose viewer was unmounted while the service loaded (rudycelekli,
#1117).
@@ -1,3 +0,0 @@
- Label camera-tool Street View fallbacks as historical Google imagery rather
than the current camera view; keep unknown frame provenance explicit
(rudycelekli, #1118).
@@ -1,5 +0,0 @@
- Scenes keep the "Scene not saved" status when a capture, update, append or
import cannot be saved, and an unreadable saved project no longer overwrites
the recovery checkpoint. `startScene()` resolves to its documented result,
`afterShotId` works without an explicit scene, and `scenesRun` counts the
distinct scenes queued (djabhi31, #1135).
@@ -1,6 +0,0 @@
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
areas enclosed near ±180° are included, areas at the opposite meridian are
excluded, and returned perimeter coordinates stay near the dateline. Incident
cards use the same circular longitude mean and compare multipart outline
areas along short longitude edges, keeping the card on the largest polygon
(Arianna Method, @ariannamethod, #1142).
-3
View File
@@ -1,3 +0,0 @@
- Director shot actions land on the requested shot. A seek to a shot's start
could round a hair below it and select the end of the previous shot for some
shot counts and durations (Aditya, @Rohilalala, #1171).
-13
View File
@@ -1,13 +0,0 @@
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
turns null, blank strings and booleans into 0 or 1, and the camera source
list sends an unknown number as JSON null, so a source-pack camera that left
out its pose reached the browser as zeros clamped to each field's floor —
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
city's ground); a null or blank position placed the camera at 0,0. The
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
blank and boolean values as missing. The same conversion also stops a
Tallinn row with a null heading reading as a confident north-facing camera,
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
or on the zero meridian (omerbberk, #1172).
@@ -1,10 +0,0 @@
- Flights' ground floor works across the antimeridian. The viewer-distance
check behind the 150 km floor radius and its copy in the photoreal mesh-floor
sampler subtracted longitudes without wrapping, so two points 21 km apart
across 180° measured about 38,000 km and low or grounded aircraft just across
the line from the viewer were never floored. A contact's display corridor
walked a segment across 180° the long way round, spending its cell budget on
ground behind the contact, and a contact on the seam lost its cell hysteresis.
SEARCH NEARBY SITES on a view across 180° searched Google Places around
longitude 0, on the far side of the globe, with a radius sized from the view's
height alone; it now searches the view (Aditya, @Rohilalala, #1173).
@@ -1,4 +0,0 @@
- Keep missing launch-pad coordinates unknown so launches use the location
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
flight numbers no longer show "FLIGHT 0"
(Rudy Celekli, @rudycelekli, #1186).
@@ -1,6 +0,0 @@
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
BicinRivas, about 1,840 stations. Station names read underscores as word
breaks, and station capacity counts docks rather than PBSC's geofenced area
(ulihhhh, #1190).
@@ -1,5 +0,0 @@
- The client's great-circle distances now come from one shared
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
points). The Earth radius is standardized on 6371.0088 km, so distances shift
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
@@ -1,5 +0,0 @@
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
cache files and logs there while the app runs, and none of it is source, so
the watcher now skips it; this guards against the DNS-lookup delays heavy
cache writes have caused, though no difference was measured on current main
(monwiss, #761).
-11
View File
@@ -1,11 +0,0 @@
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
numbers above 99999: a letter for the first two digits, skipping I and O
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
but the first were dropped as duplicates; the satellite tools read the same
field as no number, so such a satellite could not be found by it. The layer
and the tools now decode the field in `src/sources/tle.js`, and reject a
malformed one (a sign, decimal point, hex, embedded space or stray letter)
instead of reading part of it as a number. This is Alpha-5 TLE
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
-7
View File
@@ -1,7 +0,0 @@
- Changelog entries are now one file per pull request. A change adds
`changelog.d/<pull request>-<slug>.md` instead of editing the top of
`CHANGELOG.md`, which two pull requests in flight could not share — it was the
most frequent merge conflict in the open queue, and for six of them the only
one. `npm run changelog` splices the directory into `CHANGELOG.md` at release
time and empties it; `npm run changelog:check` reports the batch and changes
nothing. Fragment text is copied through unchanged (daikaginza, #787).
@@ -1,3 +0,0 @@
- Recent Imagery: opening DETAILS scrolls its card into view when it opens below
the fold. The panel used to restore the previous scroll position right after
the reveal, so the card stayed out of sight (Ian Blenke, #970).
@@ -1,4 +0,0 @@
- Cross-site browser requests can no longer spend the TomTom traffic-tile
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
cache, and upstream work; same-origin app requests and non-browser local
callers remain supported (Sunil56224972, #973).
+9 -1
View File
@@ -5,6 +5,11 @@ services:
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
user: "${UID:-1000}:${GID:-1000}"
# Forward explicit shell/.env policy before the launcher discovers a gateway.
# Unset values stay unset so the default loopback-only install still works.
environment:
- GEV_KEY_SETUP_TRUSTED_PEERS
- GEV_ALLOWED_HOSTS
ports:
# Publish to loopback only. Docker's default ("4173:4173") would bind
# every host interface, which quietly turns the LAN warning in the README
@@ -17,5 +22,8 @@ services:
# UI land in the .env on your host instead of vanishing with the
# container. node_modules stays in the image: sharp and friends are
# compiled for Linux and must not be shadowed by a host install.
- .:/app
# `:z` relabels the checkout for containers on SELinux hosts (Fedora,
# RHEL); without it the container cannot read /app at all and Node
# reports the launcher as "Cannot find module". Elsewhere it is ignored.
- .:/app:z
- /app/node_modules
+49 -11
View File
@@ -79,17 +79,13 @@ moves it. Do not label all ingestion modules platform-independent by filename.
| Retained entry | Owner and current reason |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` |
| `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately |
| `src/data/<layer>.js` | Legacy default instances; new assembly calls layer factories with supplied sources |
| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly |
| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` |
| `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances |
| `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports |
The two exact standalone-import exceptions are `src/ui.js` to
`src/standalone/catalog.js` and `server/providers/local.js` to
`server/standalone/key-setup.js`. They do not authorize new compatibility
The one exact standalone-import exception is `server/providers/local.js` to
`server/standalone/key-setup.js`. It does not authorize new compatibility
back-edges. Voice actions use plain `src/data/feedState.js`, not the manager
facade. Settings filesystem hardening belongs in
`server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged.
@@ -237,7 +233,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()`
removes owned listeners and cancels pending work without changing saved state
or moving focus. Call it before removing or replacing the controls.
`src/ui.js` retains panel layout, persistence, share restoration and application
`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application
reactions to state changes. Map Source selection and Location draft cleanup are
provided through callbacks. The component imports no globe, data, application
or server modules. Package checks and scoped formatting cover this entry.
@@ -356,8 +352,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
@@ -374,6 +370,15 @@ renderer, persistence or services. The facade retains panel visibility, share
restore claims and render scheduling. Both controls are destroyed before the
facade's asynchronous teardown can yield.
## Ordinary action buttons
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
presentation and sharing-toolbar owners. It receives translated text and explicit
presentation/accessibility properties. Feature controllers own action dispatch,
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
surface and preserve IDs and layout hooks. The helper imports no application,
renderer, storage or services; it belongs to the consuming package closures.
## Display controls
`ui/display` owns Display button, selector and slider subscriptions. It receives
@@ -461,8 +466,8 @@ release listeners; pending action feedback is limited to its current owner.
## UI assembly and styles
`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
application's instances; `src/ui.js` remains the compatibility entry. The shell
rendering, HUD and share operations. `src/standalone/controls.js` provides the running
application's instances. The shell
imports no standalone bootstrap or concrete live layer implementation.
Panel layout, position/drag, notices, recording and deferred UI work have separate
owners with synchronous cleanup. Existing scene, share and HUD engines retain
@@ -593,6 +598,10 @@ state subscriptions retain their existing event contract.
tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
status notices and gesture/timer cleanup.
- `share/view-codec` is the share-link codec (`decodeShareView`,
`encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can
read a `/share/<code>` link, for example to describe it in link previews.
`share/link-path` reads and builds the `/share/<code>` address itself.
- `ui/visual-settings` owns style preferences, detection overrides, display inputs
and IR cleanup. Its engine services and panel operations are explicit.
- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
@@ -729,3 +738,32 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
decoded in the source. Source and geometry modules remain independent of Cesium,
DOM and platform middleware.
### Native form fields
`src/ui/formField.js` constructs native numeric inputs without listeners or
application state. Static selects and number inputs use the same `gev-field`
surface, with explicit compact and inline densities. Feature owners retain
option identity/order, bounds, parsing, draft values, input/change timing and
teardown. CCTV effective-pose conversion and tuner/device operations remain
feature responsibilities. A Vue replacement can reuse this contract and CSS;
it replaces DOM construction, rather than adding a second state owner.
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
palettes use `--gev-field-background`, `--gev-field-border-color`,
`--gev-field-color` and `--gev-field-disabled-opacity`. Radio retains its own
compact density and arrow space. CCTV owns calibration editor width in
`cctv.css`; the shared inline density does not impose a feature-specific width.
Cyber supplies a shared-field palette explicitly and keeps its legacy native
field rules separate, so Radio's palette remains an intentional exception.
### Read-only status badges
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
status inference or live-region policy. It consumes translated text and controlled
visibility; appearance variants share CSS rather than mapping machine states to
universal severity. CCTV owns frame/source and calibration rules and their data
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
node identity, header focus, disclosure and teardown. The shared adapter never
creates another state store or announces all card updates. A Vue view can replace
this adapter and retain these presentation inputs and styles.
+658 -139
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -53,9 +53,8 @@ Cesium installation shared with its viewer; it must not bundle separate copies.
`gods-eye-view/infrastructure/geojson` exports the lower-level
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
The standalone app's `src/data/localGeojson.js` keeps its original single-argument
factory and supplies its existing functions. Other consumers should use the package
exports, which do not import standalone application globals.
Consumers use the package exports, which do not import standalone application
globals.
Dataset files and source/license notices remain under `src/data/local_data/`.
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
+2 -3
View File
@@ -57,9 +57,8 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
writes none.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
+365
View File
@@ -0,0 +1,365 @@
# Share-link format
A share link restores a view: where the camera is, what it follows, the visual
style, data layers and panel layout. There are two forms.
- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by
`src/sharelink.js`. The address bar follows the live view in this form, and
links already in the wild keep working; the long form is never removed.
- **Short form** — `<app>/share/<code>`, where `<app>` is wherever the app is
served (an origin's root, or a base path) and `<code>` is encoded and
decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and
needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec`
(a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on
any copy of the app. `src/share/shareLinkPath.js` reads and builds the
address, and `src/share/legacyShareState.js` converts between the long
form's parsed state and the code's state.
The same view in both forms (a real session's link):
| Form | Characters |
| ----- | ----------------- |
| Long | 302 (the hash) |
| Short | 39 (the `<code>`) |
For example, a camera-only place view is a 24-character code (31 with its
created-at time).
Following an aircraft is 42–50: 42 when the link is made from the app's own
follow camera with the fix taken as the link is made, 44 with a fix four
seconds older, and 50 after orbiting to another heading and pitch. A view in
any other style that keeps the style's own look adds only the style (two
bytes): the 42-character followed aircraft in Noir is 44.
## Opening a short link
At start-up the app reads `<code>` from an address whose path ends in
`share/<code>` (a host can pass the code instead, as `initialShareCode` to
`createApplicationControls`). A code that decodes runs the same restore as a
long link; once the app next writes the address, `/share/<code>` is replaced
by the app's own address and the long form, without a new history entry, so
a reload follows the live view. A code that is rejected restores nothing: the
app starts as usual and says the link can't be opened.
A thing view turns on its entity's layer and follows the entity by id, using
the same restore as a long link's followed flight or satellite (and its
expiry windows); a vessel is selected once the vessel feed has it. Opening is
best effort: until the entity is found, and if it is not found in time, the
camera is where the link was made, looking at the entity's last-known fix,
and the app says that the entity isn't reporting and when the fix was taken.
The share button makes a thing view while the camera follows an aircraft,
military aircraft or satellite. A selected vessel is not followed (the camera
stays where the user puts it), so a link made then is a place view; vessel
thing views open, but the app does not make them yet.
## Payload
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
1. one **version byte**, currently `1`;
2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type`
followed by its value;
3. one **check byte**: the CRC-8 of everything before it.
| Wire type | Value |
| --------- | ----------------------------------------------------------- |
| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 |
| 1 fixed64 | 8 bytes (not used by v1; skipped) |
| 2 LEN | varint length, then that many bytes (text or a sub-message) |
| 5 fixed32 | 4 bytes (not used by v1; skipped) |
Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable
ASCII. Fields are written in ascending order; the decoder accepts any order.
A field whose value equals its default is **omitted**. The decoder fills every
omitted field from the same defaults table the encoder reads (for a
style-dependent field, the decoded style's defaults), so an empty
payload (code `AQc`: the version byte and its check byte) is the all-default
view.
### Check byte
The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial
value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the
ASCII digits `123456789` is `0xF4`). A decoder that computes a different value
rejects the code (`checksum`) before reading any field, so a mistyped code
never opens a different view. A degree-8 CRC catches every error burst of up
to 8 bits; one base64url character is 6 bits, so **every single-character
typo is rejected** (a test tries every substitution at every position). Two
swapped neighbours, a dropped or an extra character are caught with a
probability of about 255 in 256 (many of them already fail as base64url).
## Field registry (top level)
| # | State key | Type | Default | Encoding |
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
| 4 | `alt` | sint | 800 | whole metres |
| 5 | `heading` | angle | 0 | whole degrees, see angles |
| 6 | `pitch` | sint | −35 | whole degrees |
| 7 | `roll` | angle | 0 | whole degrees |
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
| 9 | booleans | flags | 0 | bit set when the value differs from default |
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
| 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | |
| 19 | `sharpenIntensity` | uint | style | |
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
| 23 | `detectionFadePct` | uint | style | 0–40 |
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
Enum codes are the list position (0, 1, 2…). A name not in the list is written
as LEN text in the same field, so the app can add a style or map before this
table learns its code. A new style's settings are encoded against its own
defaults, while a decoder that does not know the style uses Normal's; adding a
style also extends the pinned style-defaults table.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
### Style defaults
A default marked **style** is what the view's style applies when it is
selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's
first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`),
with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's
shader parameters at the preset's values, else the shader's own. The encoder
compares against the defaults of the view's style, and the decoder fills
omitted settings from the defaults of the style it decoded (an unknown style
uses Normal's). So a view that keeps its style's look costs its camera and
style alone. (A recipient-side preference the app remembers, such as the
detection allocation, is not a style default: a sender who changed it pays
for it.) In v1:
| Setting | Every style |
| ------------------------- | ------------- |
| bloom, intensity | off, 0 |
| sharpen, intensity | on, 49 |
| HUD visible, variant | on, tactical |
| detection mode, density | DENSE, 75 |
| allocation, fade, outside | ELASTIC, 7, 1 |
| celestial ring | off |
| Style | Shader parameters (field 27 defaults) |
| ------------ | --------------------------------------------------------------- |
| normal | none |
| retro | pixelation 1, distortion 0, instability 0.42 |
| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 |
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
| anime | saturation 1, edgeThick 0.5 |
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
| snow | density 0.6, wind 0.5 |
These are frozen like every other default: the codec reads them from the
app's tables today, and a test pins them, so if a table moves the old values
are frozen in the codec rather than changing what shared links mean.
### Precision
| Value | Stored as | Matches the long form |
| --------------------------- | ------------------------- | --------------------- |
| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` |
| Altitude, range | whole metres | `Math.round` |
| Heading, pitch, roll | whole degrees | `Math.round` |
| Intensities and percentages | integers | `Math.round` |
| Style parameters | hundredths after clamping | `sp` (× 100) |
| Created-at and observed-at | whole seconds | `at` |
Angles: heading and roll are normalized into [0, 360) — the long form can
write `roll=360` for a camera at 359.6°, which the compact form stores as 0 —
and written as the nearer signed turn, so 350° costs one byte (−10).
When a state is encoded, out-of-range input is clamped (latitude, pitch) or
wrapped (longitude). A payload that stores a latitude or longitude beyond
±90/±180 cannot come from the encoder and is rejected.
### Layers (fields 10 and 11)
A layer's number is derived from its permanent layer-state token
(`src/data/layerStateTokenReservations.json`): a one-character token is its
index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The
token ledger never reassigns a token, so layer numbers never change and new
layers need no change here.
Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set
when layer _n_ is on. Present-but-empty means "every layer off"; an absent
field leaves the recipient's layers alone (the long form's `l=` versus no `l`).
A thing view's entity layer is always on, so its bit is not written: in a
thing view, present-but-empty means "only the entity's layer", and an absent
field opens with only the entity's layer on (the app writes the field
whenever it has a layer set).
Field 11 repeats `(layer number, option key, value)` for each option that
differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The
option key is `option token index × 2 + kind`: kind 0 means the value is a
zigzag varint (an option whose long-form spelling is a canonical integer),
kind 1 means a varint length and ASCII text (the long-form spelling). Values
are decoded by the layer-state codec's own option decoders.
A followed aircraft, military aircraft or satellite is a thing view in the
compact form, not a tracking option: the encoder moves it out of the layer
options, and the converter moves it back for the long form.
### Panels (field 12)
Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where
collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that
differ from the frozen default layout are written, plus a collapsed-0 entry
for each panel the state has no opinion about (an older long-form link that
predates the panel); a panel with no entry takes its default. Values of 3 are
reserved: a decoder ignores that entry, as for collapsed 0.
| # | Panel | Long-form token | Default |
| --- | ---------------------- | --------------- | ------------------- |
| 0 | `control-panel` | `c` | collapsed, unpinned |
| 1 | `location-bar` | `l` | collapsed, unpinned |
| 2 | `data-panel` | `d` | collapsed |
| 3 | `cctv-panel` | `v` | collapsed |
| 4 | `recent-imagery-panel` | `i` | expanded |
| 5 | `street-level-panel` | `t` | collapsed |
| 6 | `radio-panel` | `r` | collapsed |
| 7 | `scene-panel` | `s` | collapsed |
| 8 | `global-context-panel` | `g` | collapsed |
| 9 | `pp-toggles` | `p` | collapsed |
| 10 | `param-slider-panel` | `m` | expanded |
### Style parameters (field 27)
Pairs of varints `(token index, value × 100)` for the parameters of the
view's style that differ from its defaults, using the long form's `sp` tokens
(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter
of the style: the stored ones over the style's defaults. A long link without
`sp` converts to the style's defaults.
## View kinds
A **place** view (field 13 absent) opens at its camera, fields 2–7.
A **thing** view (field 13 present) follows an entity. Field 13 is a
sub-message:
| # | Field | Encoding | Default |
| --- | ----------- | -------------------------------------------------------- | ------- |
| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | |
| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | |
| 3 | vessel | entity id: varint (MMSI) or LEN text | |
| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | |
| 5 | range | uint metres from the entity to the camera | 2000 |
| 6 | heading | angle, the camera's heading while following | 0 |
| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 |
| 8 | fix lat | coord, the entity's last-known latitude | none |
| 9 | fix lon | coord, its last-known longitude | none |
| 10 | fix alt | sint metres | 0 |
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
The entity kind is the field that carries the id (its code in
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
present (two is malformed). Entity ids are the ones the layers look up: an
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
the entity from `range` metres away, heading clockwise from north, in the
entity's local east-north-up frame. The defaults match the app's own follow
camera, which looks north at about −35°, so only its range is written.
Fields 8–12 are the last-known fix, the entity's position when the link was
made. Fields 8 and 9 are both written whenever there is a fix; their presence
marks it. Its time is the created-at time (field 8 at the top level) minus
field 11, or field 12 when the view has no created-at time.
**The camera of a thing view with a fix is derived, not stored**: fields 2–7
are not written, and the camera is where following the fix from the offset
puts it (`followCameraFor` in `src/share/follow.js`): the camera sits
`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up
of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m,
and looks back at the fix (its heading and pitch are those of that direction
at the camera, roll 0; at follow distances they equal h and p). A
decoder ignores fields 2–7 when the fix is present. A thing view without a
fix (one converted from a long link, which records neither offset nor fix)
writes its camera in fields 2–7 like a place view.
A decoder that meets a view kind or an entity kind it does not know (an id in
a thing field it skips) opens the view as a place at its camera — the derived
one when there is a fix — and reports `fallback`.
## Decoding rules
`decodeShareView(code)` takes the bare code and never throws. It returns
`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`.
Rejected (the whole link): input over 2048 characters, bad base64url
(padding, foreign characters, impossible length, stray bits), fewer than two
bytes, a check byte that does not match (`checksum`), an unknown version byte,
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
overlong varint, a LEN longer than what remains (at any level, including
inside the thing sub-message), a reserved wire type, field number 0, a known
field with the wrong wire type or repeated, two entity ids in one thing, a
duplicate panel/option/style entry, non-ASCII text, and a latitude or
longitude (camera or fix) beyond ±90/±180.
Tolerated and reported in `unknownFields`: unknown fields at any level
(skipped by wire type), unknown enum codes and names (the default is used),
unknown flag bits, layers, panel values and style parameters, and layer
options the layer-state codec rejects or that are filed under a layer other
than their owner. Detection mode and allocation names outside their lists
fall back to the default rather than reaching the long form's lenient
migration. The map stack and HUD variant are open: a name outside their lists
that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what
it means) and is not reported; only a name outside that grammar is. A
created-at time in the future is dropped, as the long form's `at` is.
## Changing the format
These rules keep every link that was ever shared working, in every version of
the app that can open it.
1. **Field numbers are permanent.** Never renumber, never reuse. A field that
is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`.
2. **A field's type and wire type are permanent.** To change one, add a new
field.
3. **Defaults are frozen meanings.** A default is what an omitted field meant
when links were written. If the app's default moves, leave the table alone:
the encoder will start writing the new value explicitly. Style defaults
are read from the app's style tables; when those move, freeze the old
values in the codec (see Style defaults). The same applies
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
numbers and style parameter tokens only grow.
5. **Add, don't change.** A new setting takes the next free field number
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new
boolean takes the next flag bit instead, which costs nothing when it is at
its default.
6. **New view kinds and entity kinds** get the next code. A new entity kind's
id takes the next free thing field (13 onwards), so older apps skip it and
fall back to the camera. A thing that needs more than an id (for example a
CCTV camera, a radio station or a transit vehicle) adds further thing
fields the same way.
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
(a test fails until it has one).
8. **Bump the version byte only** for a change older decoders must refuse.
Decoders reject any version they do not know. The trailing CRC-8 check
byte belongs to every version.
9. **Derivations are frozen too.** `followCameraFor` and the follow-offset
defaults are part of the format: changing them moves the camera of every
thing link already shared.
Add a test for every change: a round trip, and a payload written with the new
field that the previous decoder still reads.
+2 -2
View File
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`).
## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one.
+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
+77
View File
@@ -0,0 +1,77 @@
# Simplified Chinese (`zh-CN`)
Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
## Style
- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」.
- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」.
- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis.
- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation.
- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」).
- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS.
## Plurals
`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」.
## Glossary
| English | 中文 |
| ------------------------------------- | --------------------------- |
| layer / data layer | 图层 / 数据图层 |
| feed | 数据源 |
| source / data source | 来源 / 数据源 |
| LIVE (status) / live data | 实时 / 实时数据 |
| SIMULATED | 模拟 |
| stale / degraded / unavailable | 过期 / 降级 / 不可用 |
| retry | 重试 |
| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 |
| Provider Settings | 服务商设置 |
| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 |
| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 |
| vessel, ship | 船舶 |
| satellite | 卫星 |
| launch (rocket) / space mission | 发射 / 太空任务 |
| contact (tracked object) | 目标 |
| installation, site | 设施 |
| camera / CCTV camera | 摄像头 / CCTV 摄像头 |
| earthquake / active fire | 地震 / 活跃火点 |
| scene / shot | 场景 / 镜头 |
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
| preset / visual preset | 预设 / 视觉预设 |
| Display / HUD layout | 显示 / HUD 布局 |
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
| Scope (circular viewport mask) / keyhole | 视域 |
| Clean UI | 纯净界面 |
| cockpit | 座舱 |
| heading / altitude / ground speed | 航向 / 高度 / 地速 |
| map heading / North up | 方位 / 正北朝上 |
| Street Level / Street View (Google) | 街景 / 街景(Google) |
| Recent Imagery | 近期影像 |
| Global Context / Context (panel) | 全局态势 / 态势 |
| radio (station) | 电台 |
| transit / bikeshare | 公共交通 / 共享单车 |
| Previous / Next | 上一个 / 下一个 |
| compass points (N, NNE, …) | 北, 北东北, … |
| weather field / FIELD (selector) | 气象场 / 要素 |
| weather model / forecast lead hours | 模式 / 预报时效 |
| valid (forecast time) | 有效时间 |
| scene cloud / mosaic | 整景云量 / 镶嵌图 |
| pin (Recent Imagery) | 固定 |
| THIS (voice pointer) | 所指 |
| outline / trace an outline | 轮廓 / 绘制轮廓 |
| mark (map annotation) | 标注 |
| camera badge | 摄像头标记 |
| provider / embedded provider controls | 服务商 / 平台控件 |
| share (a view or link) | 分享 |
| AI agent | AI 智能体 |
| stage (rocket) / downrange | 子级 / 航程 |
| capture shot / camera move | 保存镜头 / 运镜 |
| story beat / evidence beat | 事件节点 / 证据节点 |
| camera pose | 位姿 |
| forecast uncertainty cone | 预报不确定范围 |
| seed (catalog data) | 预置 |
| frame (bring into view) | 将…纳入视野 |
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
-50
View File
@@ -1,50 +0,0 @@
# Running the mobile demo
## Start
```sh
npm install
npm run dev
```
Open `http://localhost:4173/mobile/` in Chrome. Open DevTools, turn on the device toolbar (Cmd+Shift+M), and pick a phone (iPhone 14 Pro Max is close to the 17 Pro Max at 430×932) or an iPad Pro (1024×1366). The two-pane tablet layout starts at 600 px wide.
No keys are needed. Load the page once before presenting: the first load compiles and caches, and later loads paint Today in under a second from the last good snapshot.
## Presenter keys
Press `.` to open the director. Press a key to jump to a beat. Each beat sets up its own state, so they can be run in any order.
| Key | Beat | What to say |
| --- | --- | --- |
| 1 | Today | The daily brief: since you last looked, today's Moment, your sky, your watch. |
| 2 | Your sky now | Everything above you this minute, from live ADS-B and orbital elements. |
| 3 | The Empire State Moment | A 4D reconstruction: helicopter tracks, sealed captures with view cones, chat and the record on one clock. Illustrative. |
| 4 | The Live lane, with hearts | Twitch-style chat over the city; reactions rise from where people are. Simulated chat. |
| 5 | Need eyes | A room asks for coverage; the first sealed answer earns the byline and Signal. |
| 6 | Capture | Look, Witness, Live and Upload, signed on the device. |
| 7 | Dossier of a live aircraft | Gotham-style: identity, live state, track, links, receipts. Real data. |
| 8 | Link graph | Everything that thing touches, synchronized with the globe. |
| 9 | Time machine at 60× | Replays a real bundled recording of London, NYC and the Gulf. |
| 0 | Clip this Moment | Select a range and clip it into a shareable Moment. |
| - | An alert, calmly | Severity-gated alert rules on live data. |
| = | Search to dossier | Search "7700" (or "ISS" if nothing is squawking) and open the result. |
`Esc` closes the top sheet. The director has a **Reset demo** action.
## What's real
| Real | Illustrative or simulated |
| --- | --- |
| Live aircraft, military, satellites, quakes, cyclones, fires, radar, lightning, wind, air quality, GNSS interference, transit, public cameras | The Empire State Moment (synthetic tracks over real NYC footprints) |
| Detections (circling, squawks, quake clusters) on live data | Chat in the Live lane, example posts, crews, and other contributors |
| The time machine's recording (this session's polling plus a bundled real recording from 23 Oct 2026, 08:25–11:06Z) | Need eyes requests from other people |
| Capture signing, hash chains and verification (WebCrypto on this device) | The room clerk (rules, not a model) |
Every illustrative or simulated surface carries a label in the app.
## Rehearse
Run the beats once on the presenting Mac before the meeting. On first load the keyless terrain host can be slow; if a close-up stays black for more than a few seconds, the engine falls back to flat terrain on its own.
Refresh the bundled data while `npm run dev` is running: `node mobile/demo/capture-snapshot.mjs --base http://localhost:4173` for the snapshot, and `node mobile/demo/capture-history.mjs --base http://localhost:4173 --minutes 150` for the recording (it writes every 5 minutes; behind a proxy, prefix it with `NODE_USE_ENV_PROXY=1`).
-346
View File
@@ -1,346 +0,0 @@
# God's Eye — Mobile
_The window through which you see the world, and the eye through which the world sees itself._
This folder holds two things:
| Path | What it is | Tethered to production? |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`mobile/index.html`](index.html) → `/mobile/` | **Prototype.** A working phone/foldable app that sets the design language on top of the real engine: CesiumJS, Esri cloudless imagery, NASA Black Marble night lights, Google Photorealistic 3D Tiles (when keyed), and the app's own live `/api` feeds (ADS-B, military, AIS, TLEs, quakes, FIRMS fires, cyclones, launches, CCTV) plus the bundled Bhote Koshi event pack. Capture is real: camera, IMU, GPS and a WebCrypto signature. | Yes. Runs on the existing dev server (`npm run dev`, open `http://localhost:4173/mobile/`). |
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file straight from disk in any browser (the dev server's CSP blocks its inline script). Rebuild it with `node mobile/vision/build.mjs`. |
---
## 1. The goal, rewritten
> Build the app people open **the moment something happens in the real world**, and the app they open **when nothing is happening** just to look.
>
> It must make 40+ live layers feel like one calm surface on a 6.9" phone, unfold into a two-pane instrument on a foldable, scrub the planet through time like a video, and let anyone with a phone add **signed, sensor-rich observations** that make reconstructions of real events harder to fake.
Five commitments fall out of that sentence:
1. **Situations, not layers.** The public does not think in feeds. They think in _things worth watching_: a cyclone, a launch, a flood, a chopper school, a stadium, an airport at rush hour. The home screen is a feed of situations. Layers are the ingredients a situation turns on for you.
2. **Click _into_ the world, not to the next card.** Opening a card is a spatial transition: the card's thumbnail is the globe, and it expands into the live globe at that place, at that time, with that situation's lenses on. Closing it folds the world back into the card. No page loads, no white flashes.
3. **Time is a first-class axis.** Every view has a scrubbable timeline (past ← now → forecast). Situations carry their own time window. Reconstructions are 4D: space × time × evidence.
4. **Truth needs walls.** Captures from this app are cryptographically signed at the moment of capture and carry the full session: IMU trace, GPS, heading, timestamps, device and sensor metadata, a hash chain. Foundation **observations** are kept separate from the **hypotheses** people (and AI) layer on top, and every change is in an inspectable audit trail.
5. **Cinematic by default, performant always.** Day and night blend across the terminator. High orbit gets the planetary treatment (cloudless day mosaic + night lights + atmosphere + bloom); close in, it becomes photorealistic 3D. The post chain is budgeted for a phone GPU, adapts to thermal state and battery, and drops to a still frame when nothing moves.
## 2. Information architecture
Five tabs. Capture is a tab, not a floating action, because it is a _mode_ with its own full-screen surface (HIG: tab bars navigate between peer areas; capture is a peer area here, as it is in Camera-first apps).
| Tab | SF-style glyph | Job |
| ----------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Now** | `dot.radiowaves` | A two-column, RedNote-style masonry feed of live and recent situations. Each card is a living thumbnail: real imagery of the place, live counters, a pulse when it changes. Sections: _Happening now_, _Near you_, _Reconstructions_, _Always on_ (chopper schools, busy airports, ISS, port of Rotterdam…). |
| **Globe** | `globe.americas` | The window. Full-bleed globe, the lens dock, the time ribbon, search. This is the expert surface. |
| **Capture** | `viewfinder` | Two modes on one dial: **Look** (AR — the God's-eye view projected around you: aircraft, satellites, ships, quakes as overlays on the camera, aligned by compass + gyro) and **Witness** (your phone becomes an eye for the God's eye: signed photo/video with IMU, GPS, heading, depth when available). |
| **Threads** | `point.3.connected.trianglepath.dotted` | Reconstructions people are building together: observations, hypotheses, AI refinements, and the ledger. |
| **You** | `person.crop.circle` | Your captures and their seals, holograms you left in places, situations you follow, alert radius. |
The iOS 26-era shell: a floating glass tab bar that shrinks to a pill while you scroll the feed and expands when you stop; a **tab-bar accessory** shows the one situation you are following live ("Typhoon · 185 km/h · 2 min ago"), like a now-playing bar for the planet.
## 3. Interaction model
- **Card → world (hero transition).** The tapped card's frame animates to full-screen while its static thumbnail cross-fades into the live globe already flying to the situation camera. The sheet rises to the medium detent with the situation brief. Swipe the sheet down or tap the back chevron to fold back to the card. Reduced Motion: cross-fade only.
- **Sheets with detents** (peek ≈ 96 pt / medium ≈ 45% / large ≈ 92%). Peek shows the headline numbers; medium shows the brief and the evidence strip; large shows the full thread. Dragging the globe while the sheet is at medium collapses it to peek, so the world always wins.
- **Lens dock.** 40+ layers live in eight families. The dock shows _family_ chips (Sky, Sea, Orbit, Hazards, Eyes, Ground, Weather, Infra); tapping a family opens a Control-Center-style grid of its layers with live counts. Long-press a family to solo it. The globe also curates by **semantic zoom**: at planetary altitude only aggregates render (density glows, cyclone cones, fire clusters); individual glyphs appear as you descend.
- **Time ribbon.** A thin, haptic-detented scrubber above the tab bar: −72 h ← NOW → +120 h (forecast). Event pips sit on the ribbon (the quake, the launch, the flood crest). Dragging fast moves in hours; slow, in minutes. Tapping NOW snaps back to live with a single tick haptic. In Live the ribbon breathes.
- **Pick anything.** Tap a contact on the globe: it's highlighted, a trail draws, and a compact card offers **Follow**, **Ride** (cockpit/bridge camera), **Nearest eye** (closest live camera), **Thread** (start or join a reconstruction).
- **Foldables & large screens.** Width ≥ 600 pt switches to a two-pane layout: feed/thread list on the leading pane, globe on the trailing pane; selection in one drives the other. With a vertical hinge (`horizontal-viewport-segments: 2`) the panes split exactly at `env(viewport-segment-right 0 0)` so nothing renders under the fold. Half-folded (tabletop, `vertical-viewport-segments: 2`): globe on top, controls and timeline on the bottom half, like a laptop.
- **One-handed reach.** All primary controls sit in the bottom 40% of the screen. Search lives in the tab bar row (iOS 26 pattern), not at the top.
- **Haptics & sound.** `navigator.vibrate` where available for ribbon detents and capture seal; never decorative.
## 4. Visual language
- **Ground:** near-black with a blue bias (`#05070B`), never pure grey. The globe is the only light source; UI is glass over it.
- **Glass:** `backdrop-filter: blur(24px) saturate(1.6)` panels with a 0.5 px inner highlight, 22–28 pt continuous corners, no drop shadows on glass.
- **Signal colors** carry meaning and are reserved: Sky `#7CC4FF`, Sea `#38E1C6`, Orbit `#C9A7FF`, Hazards `#FF6A3D`, Eyes `#FFD166`, Ground `#9BE15D`, Weather `#8AB4F8`, Infra `#B8C0CC`. Live = a soft pulse; Stale = desaturated with a dotted outline; Signed = a seal glyph in warm gold.
- **Type:** the system UI face (SF Pro / Roboto Flex via `system-ui`) for UI; tabular numerals for every live readout; a monospace for coordinates and hashes only. Large titles (34 pt) on Now and Threads, collapsing on scroll.
- **Tufte rule for reconstructions:** maximize data-ink. Small multiples for before/after, sparklines in-line with text, direct labels instead of legends, uncertainty drawn (cones, bands, dotted where inferred), and every number carries its source and age.
## 5. Rendering budget (phone)
| Item | Target |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frame | 60 fps while interacting, 0 fps (request-render mode) at rest |
| Resolution scale | `min(devicePixelRatio, 2)`, dropping to 1.5 / 1.25 when frame time > 20 ms for 2 s |
| Tiles | `maximumScreenSpaceError` 2 → 4 on compact; tile cache 256 MB → 128 MB |
| Post chain | atmosphere + night-lights blend (built into the globe shader) → FXAA → single-pass bloom (half res) → vignette + grade. Bloom and grain switch off under Low Power / thermal pressure |
| Labels | no Cesium labels; HTML/canvas overlay in screen space, capped at 40 visible |
| Data | each layer has an LOD rule (aggregate above 2,000 km altitude, glyphs below) and a visible-count cap |
## 6. Capture and provenance
A Witness capture produces a **sealed bundle**:
```
bundle = {
media: JPEG/WebM bytes → sha256
session: { start, end, imu: [{t, α, β, γ, ax, ay, az}], gps: [{t, lat, lon, acc, alt}], heading }
context: { device, ua, screen, timezone, network-time-offset }
prev: sha256 of this device's previous bundle (hash chain)
hash: sha256(canonical(media hash + session + context + prev))
signature: ECDSA P-256 over hash, key generated on device, non-extractable, stored in IndexedDB
}
```
In a native build the key lives in the Secure Enclave / StrongBox and is bound to App Attest / Play Integrity, and the bundle is written as a C2PA manifest. The prototype does the browser-equivalent with WebCrypto so the flow, the data and the verification UI are real. Verification recomputes the hash, checks the signature against the device public key, and checks IMU/GPS plausibility (the motion trace must agree with the image's horizon and the compass heading; the GPS track must agree with the time). None of this proves truth. It raises the cost of lying.
## 7. Reconstructions (Threads)
A reconstruction is a document with three strata, always drawn in this order:
1. **Observations** — machine-sourced or signed: ADS-B/AIS tracks, satellite imagery, seismograph readings, signed captures. Immutable; each has source, time, hash.
2. **Hypotheses** — claims that connect observations ("this clip was filmed from the north bank at 14:02", "the aircraft entered a hold at 2,100 ft"). Each has an author (person or AI), confidence, the observations it cites, and agreement/disagreement counts.
3. **Narrative** — the story view: a camera path through space and time that cites hypotheses.
Collaborative AI prompting happens on stratum 2: a participant asks the model to refine ("align the witness clip to the corridor using the bridge as a landmark"). The model's output is a _proposed hypothesis_ with its prompt, model, inputs and diff recorded in the ledger. Nobody edits an observation; you can only cite, dispute, or supersede a hypothesis. The ledger is a hash chain anyone can export and verify.
## 8. Layer catalog (44)
| Family | Layers |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Sky | Commercial flights · Military aircraft · Helicopters · Private/GA · Emergency squawks · Drones (Remote ID) · Airport boards · Holding patterns |
| Sea | AIS vessels · Ports · Tankers/cargo · Fishing effort · Dark vessels (AIS gaps) · Submarine cables |
| Orbit | Satellites (TLE) · ISS & crewed · Starlink trains · Launches & trajectories · Debris · Ground tracks & passes |
| Hazards | Earthquakes · Active fires (FIRMS) · Fire perimeters · Floods · Tropical cyclones · Volcanoes · Air quality |
| Eyes | Public CCTV · Signed captures · Street-level imagery · Holograms · Live streams |
| Ground | Transit vehicles · Traffic flow · Bike share · Rail · Events & venues · ALPR cameras |
| Weather | Radar · Clouds · Lightning · Wind · Forecast models |
| Infra | Datacenters · Dams · Power plants · Military installations · GNSS interference · Recent satellite imagery |
## 9. Situations worth monitoring (the feed's genres)
Live today with the repo's feeds: tropical cyclone tracks · largest quake in 24 h · fire clusters in Europe/California · military air activity over a region · ISS pass over you · busiest airport now · helicopter school circuits (Fort Novosel) · launch countdowns with trajectory · Bhote Koshi flood reconstruction · ships queuing at Suez/Panama/Singapore · GNSS jamming hot spots · transit at rush hour.
Ten more to build: stadium/concert 4D reconstructions from crowd captures · storm chasers with signed dashcams · emergency squawk 7700 replays · air-show displays · marathon/route races with runner trackers · eclipse path watch parties · aurora nowcasts with sky captures · ferry crossings and harbour pilots · wildfire smoke vs. air quality vs. hospital load · building-climb or rooftop incidents reconstructed from ADS-B news helicopters + witness video · protest/march route documentation with signed captures · volcano webcams vs. ash advisories · delivery-drone corridors (Prime Air style corridor replays).
## 10. Prototype architecture
```
mobile/
index.html entry served at /mobile/ (CSP: no inline script)
main.js boots the shell, lazy-loads the globe
styles/ tokens.css, shell.css, feed.css, globe-ui.css, capture.css, threads.css
globe/ Cesium engine for phones (owned by the globe agent)
index.js createGlobe(container, options) → Globe API (below)
imagery.js Esri day + Black Marble night blend, Google 3D tiles toggle
post.js mobile post chain + adaptive quality governor
layers/*.js live layers on top of the repo's /api routes
situations.js builds situation cards from live feeds (+ bundled Nepal)
ui/ shell, feed, sheet, lens dock, time ribbon, capture, threads, you
capture/ camera, IMU, GPS, provenance (WebCrypto signing + verify)
```
### Globe API contract
```js
const globe = await createGlobe(containerEl, { quality: 'auto' });
globe.flyTo({ lon, lat, height, heading?, pitch?, duration? }) → Promise
globe.home() // planetary view
globe.layers() → [{ id, family, name, on, count, status: 'live'|'stale'|'off'|'error' }]
globe.setLayer(id, on)
globe.setFamily(family, on)
globe.setTime(date | null) // null = live
globe.timeWindow() → { start, end, now }
globe.setLens(id) // 'cinematic' | 'clear' | 'night' | 'thermal' | 'nvg'
globe.focus(situation) // flies + applies situation.layers + time
globe.select(id | null)
globe.on(event, fn) / off // 'pick' {kind,id,title,subtitle,lon,lat,meta}
// 'layers' (counts changed), 'camera' {lon,lat,height}
// 'quality' {scale,fps}
globe.pause() / resume() // tab hidden / capture open
globe.contactsNear(lon, lat, km) → [{ kind, id, title, lon, lat, altM, headingDeg }] // for AR
globe.situations() → Promise<Situation[]> // live feed cards
```
`Situation = { id, kind, family, title, subtitle, place, lon, lat, camera:{lon,lat,height,heading,pitch}, layers:[ids], time?:{start,end}, metrics:[{label,value,unit}], thumbnail: url, live:boolean, updatedAt, source }`.
Thumbnails use the keyless Esri World Imagery `export` endpoint for the situation's bounding box, so every card shows the real place.
---
## 11. Round two: from weather app to God's Eye
Review notes on the first build: the feed showed one kind of picture (open ocean under a cyclone), the app read as a weather app, there was nowhere for people to talk, and capture stopped at a sealed photo. This section is the response. Where it disagrees with §1–§10, it wins.
### 11.1 Identity: space → sky → ground → inside
The app's subject is altitude. Every surface tells you **where the sensor is** and **how high it is**, from orbit down to street level and finally inside: a room, a cockpit, a person's own capture. That is the brand, and it replaces the weather-app palette of blue gradients and big temperature-style numbers.
| Altitude band | Sensor | Telemetry burned into the frame |
| ------------------------- | --------------------------------------------------------------- | --------------------------------------------- |
| **Orbit** · 400–36,000 km | satellites, ISS, launches, weather satellite IR | `ORBIT · 412 KM · 7.66 KM/S · PASS 21:04` |
| **Sky** · 0–15 km | ADS-B aircraft, helicopters, military, radar returns, lightning | `SKY · FL350 · 487 KT · SQ 7700` |
| **Ground** · 0 m | CCTV, traffic, transit, fires, quakes, floods | `GROUND · CAM 0412 · I-5 S · 14:02:11Z` |
| **Inside** · you | signed captures, live broadcasts, holograms, conversations | `WITNESS · SEALED · ±4 M · 1.2 KM FROM EVENT` |
Visual rules:
- **Sensor framing.** Feed cards and the globe chrome use corner brackets, a hairline reticle, and a monospace telemetry strip burned into the image (coordinates, MGRS, UTC, sensor id, altitude). It is restrained: one strip per card, never a full military HUD.
- **Type.** Display: the system face, tight and heavy. Telemetry: a monospace face (JetBrains Mono, which the desktop app already loads) in small caps with tabular numerals. The monospace face is the voice of the instruments, not of the UI.
- **Color.** Ground stays near-black. Each altitude band has one signal hue: Orbit violet, Sky ice-blue, Ground amber, Inside warm gold for sealed. Hazard red is reserved for things that hurt people. No blue-gradient cards.
- **Imagery is evidence.** Thumbnails are composited from the layers that actually describe the situation (§11.2). A picture of empty ocean is never acceptable.
### 11.2 The feed: compelling, diverse, real
The Now feed is the global equivalent of a neighborhood safety feed: it tells you what is happening, where, how close, and who saw it. Diversity is a requirement. The feed mixes **card archetypes** and **altitude bands**, and never puts two cards of the same archetype or band side by side when it can avoid it.
Card archetypes:
1. **Sensor card**: a composited frame. The base is the Esri image of the place or GIBS satellite IR. On top go the real data layers (radar, lightning strikes, cyclone cone and track, flight trails, quake rings, fire hotspots and smoke direction from wind), with sensor framing and telemetry burned in.
2. **Eye card**: a real frame from the nearest public camera (`/api/cctv`), with a LIVE timestamp, camera id, and direction of view. This is the most Citizen-like card: you see the street.
3. **Track card**: one moving thing, such as an aircraft squawking 7700, a helicopter on its 14th circuit at Fort Novosel, or the ISS crossing your night sky. It shows a trail, a big readout, and a "ride along" call to action.
4. **Number card**: one striking fact with a sparkline. Examples: "4,812 lightning strikes in the last hour over the Gulf", "GNSS jamming over the Baltic, 340 aircraft affected".
5. **Witness card**: a signed capture or a live broadcast from a person. It shows the seal, the distance from the event, and the reply count.
6. **Room card**: a conversation that is heating up. It shows the top claim, its status (_Confirmed by 3 signed captures_ / _Disputed_), and the participants.
Situation genres to source from keyless layers. Each card must say its source:
- Cyclones (NHC cone, track and wind radii, GIBS IR)
- Severe-storm cells (radar plus lightning density)
- Heavy rain over a major city (radar)
- Jet-stream wind anomalies (GFS wind)
- Quakes (USGS, with intensity rings)
- Wildfires (FIRMS or EONET, with smoke direction from wind)
- Emergency squawks (7500, 7600, 7700)
- Military activity
- Helicopter schools
- Launches (pad and trajectory)
- ISS and Starlink passes over the viewer
- GNSS interference (`/api/gnss-integrity`)
- Transit at rush hour (`/api/transit`)
- Busiest airport arrivals
- Live public cameras at famous places (Times Square, Shibuya, Tower Bridge where available, otherwise any busy feed)
- Ship queues at chokepoints
- The Bhote Koshi reconstruction
### 11.3 Discourse: every situation has a room
People will talk about events anyway, on other platforms and without the evidence. The room puts the conversation **on the evidence**: anchored in space and time, and graded by how it was observed.
- **Structured posts.** Each post is one of: **Witness** (I saw or recorded this), **Context** (background, a link, a map), **Question**, **Correction**, or **Prediction**. The kind is chosen in the composer and drawn as an icon. Questions float to the top until answered.
- **Anchored.** A post can pin a place (a dot on the globe) and a time (a pip on the time ribbon). Tapping a post flies the globe there and scrubs the ribbon to that moment. Pinned posts appear in 3D as small speech glyphs at their location.
- **Earned badges, not follower counts.** Badges come from evidence:
- _Was there_: a signed capture within N km and M minutes of the event.
- _Sealed_: the post has a sealed attachment.
- _Local_: the author's home area is set.
- _Domain_: pilot, mariner, met, first responder, held as a verified credential.
Posts are ordered by evidence first, then recency, never by follower count.
- **Community notes with bridging.** Any claim can get a note. A note becomes visible only when raters who usually disagree both rate it helpful (bridging-based ranking, as Community Notes does), which resists brigading.
- **AI synthesis, kept honest.** A pinned card at the top of every room is labelled **Known / Disputed / Unknown**. AI writes it from the observations and the posts, and every line cites its sources. It is regenerated as the room changes, with its prompt and inputs recorded in the ledger. It never states anything that has no citation.
- **Live layer.** While you are in a room, presence shows as small avatars, reactions float up the side, and a "N people watching" count appears. Rooms for live situations sit in the feed as Room cards.
- **Reply with evidence.** "Reply" offers **Capture** first and text second. The best answer to "is the bridge still standing?" is a sealed photo from 200 m away.
### 11.4 Capture: contribute a place, an event, or a live stream
Capture is how a person becomes a sensor. Four modes share one dial:
| Mode | What it makes | Trust tier |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Look** | AR overlay of contacts around you. Nothing is recorded unless you tap. | none |
| **Witness** | A sealed photo or clip with the IMU, GPS and heading trace (§6). | **Sealed** |
| **Live** | A broadcast from your camera. It appears on the globe as an _eye_: a frustum cone at your position and heading, with live telemetry, watchable by the room. Every 10 s the stream is sealed as hash-chained segments. | **Sealed live** |
| **Upload** | A photo or video from your library. EXIF is read if present, then you place it on the map and set the time. | **Unverified**: labelled as such everywhere, and it can never become an observation, only cited by a hypothesis. |
After the shutter comes the **attach sheet**. It suggests the situation you are probably at, from distance and time ("Typhoon Champi · 4.2 km from track", "Times Square camera · 180 m"). It offers "new place" if none fits, a post kind (Witness by default), an optional note, and whether to drop it as a **hologram** that others can find at this spot later. Posting puts the capture in that situation's room, and a Witness card in the feed when it is notable.
Privacy: faces and plates are blurred before upload by on-device detection (a native-build commitment, and a disclosed limitation in the web prototype). Your exact location is shared at the precision you choose (exact, 100 m, 1 km), and the seal still proves the exact value to verifiers without publishing it.
---
## 12. Round three: Gotham for everyone
Founder's brief for this round: take what made Palantir Gotham work, fix what Citizen got wrong, and build the window people open every day and the first app they open when something happens. The time slider does far more. Communication feels like Twitch chat meets an X community meets Reddit. Real data wherever possible, more realistic everywhere else. It is demoed on a Mac in Chrome's device mode, as a phone and as a tablet.
> No longer should the audit trail for physical reality be the dominion of nation states. We have the right to know, with the receipts attached.
### 12.1 What we take from Gotham
Gotham's power is not the map. It is that **everything is an object, every object has a history and links, and every view (map, timeline, graph, dossier) shows the same objects**. Select something anywhere and every view follows. Analysts build an investigation, share it, and every fact keeps its source.
| Gotham idea | God's Eye version |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ontology of objects and links | **Entities**: aircraft, vessels, satellites, cameras, places, events, captures and contributors (pseudonymous). Each has a **dossier**: identity, live state, track history, events, links and receipts. |
| Map + timeline + graph, synchronized | Globe, **time machine** and **link graph** share one selection and one clock. |
| Search to dossier | Universal search ("N46567", "7700", "Piccadilly", "M5.2", "ISS") opens a dossier, not a list of pins. |
| Watchlists and alerts | **Watch**: follow an entity or a place, and write alert rules in plain chips ("any squawk 7700 within 200 km of Home"). |
| Investigations with provenance | **Moments**: shareable 4D reconstructions whose every track, capture and claim carries its source and hash. |
| Audit log | The room ledger: every post, note, AI synthesis and vote is hash-chained and exportable. |
**The line we don't cross.** Gotham tracks people; we don't. Trackable entities are things that broadcast publicly (transponders, AIS, orbital elements), public infrastructure, and events. Contributors are pseudonymous. There is no face recognition and no person search, and aircraft on owner-privacy lists (LADD/PIA) are never named. This is the product's moral spine and a talking point, not a footnote.
### 12.2 What Citizen got wrong, and our incentives
Citizen grew on fear: an alert stream about crime near you, ranked by alarm, with little verification and a paid "Protect" upsell. Anxiety is the retention loop, rumor travels as fast as truth, and nobody is rewarded for being right.
Ours rewards **being right, early, with receipts**:
- **Signal, not followers.** A contributor's reputation (Signal) rises when their sealed captures are corroborated (cited by others, matched by sensor data such as ADS-B, radar or quakes) and falls when their claims are corrected. Signal weighs their votes and notes. Follower counts are not shown.
- **First eyes.** The first sealed capture of an event gets a permanent byline on the record ("First eyes: @harbor_cam_kid, 14:02:11Z, 180 m"). This is the reason to open the app and capture when something happens.
- **Need eyes.** Any room can request coverage at a place ("Is the 7th St bridge underwater?"). People nearby get one quiet ask; fulfilling it with a sealed capture earns Signal and the byline. This turns curiosity into coverage, which is the incentive structure Citizen never had.
- **Calm by default.** Notifications are severity-gated and local, "Nothing unusual near you" is a valid state, and half the feed is wonder (the ISS overhead, a launch, aurora, a helicopter school) so the daily habit is curiosity, not dread.
- **Ranking by evidence.** Sealed, corroborated, and close beats loud.
### 12.3 The daily loop
- **Today** (first tab) opens with a brief: "Your world · 7:42 AM": since you last looked (the three biggest changes), what's overhead tonight (ISS pass, launch window), your watched places and things, and **Today's Moment**, one 20-second 4D replay a day worth watching (a ritual, like a daily puzzle). Below the brief comes the situation feed (§11.2).
- **Your sky now.** One tap shows what is above you this minute: aircraft, satellites, the ISS. This is the most delightful hook in the app.
- **Watch.** Follow places (home, commute, a family member's city) and things (a tail number, a ship, the ISS). A followed thing's status rides in the tab-bar accessory.
### 12.4 When something happens: Moments
A **Moment** is a 4D reconstruction assembled around an event: tracks, sensor frames, sealed captures with their view cones, the chat, and the record, all on one clock.
- **Detected from real data.** When two or more aircraft circle the same point (news and police helicopters orbiting a scene), the engine raises a situation before any headline: "3 helicopters circling Midtown Manhattan". It also flags an emergency squawk, a cluster of quakes or a sudden lightning cell. These are real detections on live ADS-B and sensor feeds.
- **Replayable.** Open a Moment and the time machine replays it: helicopter tracks with trails, captures popping in at their timestamps with view cones pointing at the subject, chat replaying at its pace, and posts appearing as they were made.
- **Hero demo Moment: "Climbers on the Empire State Building".** An illustrative reconstruction with realistic synthetic tracks: two news helicopters and one police helicopter orbiting at 1,000–1,500 ft and radii of 400–700 m, with realistic speeds and turn rates. Witness captures from 34th St, 5th Ave and an office at 1 Penn are shown with their view cones, along with the chat and the record. It is clearly labelled illustrative. It shows exactly what the product does when this happens for real.
### 12.5 The time machine
The time ribbon becomes a time machine:
- **It remembers.** The app records every live contact's position each poll (a ring buffer persisted in IndexedDB, about 6 h). Scrubbing back shows where everything actually was, with ghost trails. A selected aircraft's full track comes from the server's track routes. The app also ships a **bundled real recording** (`mobile/demo/history/`, a few hours over New York, London and the Gulf of Mexico, captured with `capture-history.mjs`) so a first run already has ghosts to replay; it keeps its true timestamps and the time machine shows it where it happened (`RECORDING · <date>`).
- **Everything moves together.** Contacts, radar and lightning frames, quakes and fires appearing, cyclone positions, the sun, room posts, and captures all follow the clock.
- **Play.** Play/pause and 1×, 10×, 60× and 600× speeds; momentum scrubbing; detents at events; a **NOW** snap.
- **Event pips.** Pips from every layer are colour-coded by altitude band. Tapping one jumps to it.
- **Clip.** Select a range, then "Clip this Moment", which creates a shareable Moment with its receipts.
### 12.6 Talking: Live and Record
Two lanes in every room. This is the delineation that keeps the conversation both fun and true.
| | **Live** (Twitch) | **Record** (Reddit and X) |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Pace | Fast, ephemeral, scrolls away | Considered, persistent, threaded |
| Shape | One-line chat, emotes, @mentions, slow mode, viewer count | Posts with kinds (§11.3), replies, quote-posts, attachments |
| Reactions | Hearts, 🔥, 👀 and 😮 fly up the screen, **and rise from the reactor's location on the globe**: you see the world watching | Corroborate / Dispute votes weighted by Signal |
| Badges | Witness glow for people with a sealed capture nearby, plus First eyes | Was there, Sealed, Local, Domain |
| Becomes the record? | Only if it carries evidence or is promoted by corroboration | Yes, hash-chained in the ledger |
- **The clerk.** Every few minutes, AI summarizes the Live lane into "What chat is saying", with citations to messages, and posts it into the Record as a clearly labelled synthesis.
- **Crews.** Crews are persistent groups (X communities): "NYC Scanner Nerds", "Fort Novosel Alumni", "Gulf Storm Chasers". A crew has its own feed, members and rooms, and can co-watch a Moment together.
- **Watch parties.** Opening a live situation drops you into its Live lane, with presence and the heart stream. That is the "hearts flying" feeling, everywhere.
### 12.7 Tabs
**Today · Live · Capture · Rooms · You**
- **Today**: the brief plus the feed.
- **Live**: the globe, time machine and lenses.
- **Capture**: Look · Witness · Live · Upload, plus Need eyes requests near you.
- **Rooms**: live rooms, crews and Moments.
- **You**: Signal, receipts, captures, watchlist and alert rules.
Search (the tab-bar search button) leads to dossiers.
### 12.8 Demo mode
The demo is presented in Chrome's device toolbar on a Mac:
- **Instant first frame.** The last good situations and thumbnails are cached (IndexedDB) and a bundled snapshot of real data ships with the app (`mobile/demo/`, with its capture time shown). The app paints in under a second, then hydrates live.
- **Mouse and touch** work everywhere (drag sheets, scrub, pinch with trackpad).
- **Director.** Press `.` (or triple-tap the logo) to open a presenter menu that jumps to the beats of the story: Today → Your sky now → the Empire State Moment → the Live lane with hearts → Need eyes → Capture → Dossier and link graph → the time machine → tablet layout. Each beat sets up its state so the demo never depends on luck.
- **Honest labels.** Real data shows its source and age. Illustrative content (the hero Moment, simulated chat, example posts and crews) carries a small "Illustrative" or "Simulated" tag. A VC should see that the real parts are real.
-109
View File
@@ -1,109 +0,0 @@
/** Rear camera: preview, still photo (JPEG) and short video (MediaRecorder). */
export function createCamera(video) {
let stream = null;
let recorder = null;
let chunks = [];
async function start() {
if (stream) return stream;
if (!navigator.mediaDevices?.getUserMedia)
throw new Error('Camera API unavailable (needs https)');
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1440 },
},
});
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await video.play().catch(() => {});
return stream;
}
function stop() {
if (recorder && recorder.state !== 'inactive') recorder.stop();
stream?.getTracks().forEach((t) => t.stop());
stream = null;
video.srcObject = null;
}
function info() {
const t = stream?.getVideoTracks()[0];
if (!t) return null;
const s = t.getSettings?.() || {};
return {
label: t.label || 'camera',
width: s.width,
height: s.height,
facingMode: s.facingMode || null,
frameRate: s.frameRate ? Math.round(s.frameRate) : null,
};
}
/** Grab the current frame as a JPEG blob at the sensor's resolution. */
async function photo() {
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h) throw new Error('Camera not ready');
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(video, 0, 0, w, h);
return new Promise((resolve, reject) =>
c.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Encode failed'))),
'image/jpeg',
0.92,
),
);
}
function canRecord() {
return !!stream && typeof MediaRecorder !== 'undefined';
}
function startVideo() {
if (!canRecord()) throw new Error('Video recording unavailable');
const types = [
'video/mp4;codecs=avc1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
];
const mimeType = types.find((t) => MediaRecorder.isTypeSupported?.(t));
chunks = [];
recorder = new MediaRecorder(
stream,
mimeType ? { mimeType, videoBitsPerSecond: 4e6 } : undefined,
);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.start(250);
}
function stopVideo() {
return new Promise((resolve) => {
if (!recorder || recorder.state === 'inactive') return resolve(null);
recorder.onstop = () =>
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
recorder.stop();
});
}
return {
start,
stop,
info,
photo,
startVideo,
stopVideo,
canRecord,
get active() {
return !!stream;
},
};
}
-121
View File
@@ -1,121 +0,0 @@
/**
* Minimal promise wrapper over the device's IndexedDB: signing keys, sealed
* captures (+ media), and round two's room posts, uploads and small state.
*/
const DB_NAME = 'gev-mobile';
const DB_VERSION = 2;
let dbp = null;
export function openDb() {
if (dbp) return dbp;
dbp = new Promise((resolve, reject) => {
if (!('indexedDB' in globalThis)) {
reject(new Error('IndexedDB is unavailable'));
return;
}
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('keys'))
db.createObjectStore('keys', { keyPath: 'id' });
if (!db.objectStoreNames.contains('captures')) {
const s = db.createObjectStore('captures', { keyPath: 'id' });
s.createIndex('seq', 'seq');
}
if (!db.objectStoreNames.contains('media'))
db.createObjectStore('media', { keyPath: 'id' });
// v2: rooms and uploads.
if (!db.objectStoreNames.contains('posts')) {
const p = db.createObjectStore('posts', { keyPath: 'id' });
p.createIndex('room', 'room');
}
if (!db.objectStoreNames.contains('uploads'))
db.createObjectStore('uploads', { keyPath: 'id' });
if (!db.objectStoreNames.contains('kv'))
db.createObjectStore('kv', { keyPath: 'id' });
};
req.onsuccess = () => {
const db = req.result;
// Another tab upgraded the schema: let it, and reopen next time.
db.onversionchange = () => {
db.close();
dbp = null;
};
resolve(db);
};
req.onerror = () => reject(req.error);
req.onblocked = () => reject(new Error('IndexedDB upgrade blocked'));
});
dbp.catch(() => (dbp = null));
return dbp;
}
function wrap(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function get(store, key) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).get(key));
}
export async function put(store, value) {
const db = await openDb();
return wrap(db.transaction(store, 'readwrite').objectStore(store).put(value));
}
export async function del(store, key) {
const db = await openDb();
return wrap(
db.transaction(store, 'readwrite').objectStore(store).delete(key),
);
}
export async function all(store) {
const db = await openDb();
return wrap(db.transaction(store).objectStore(store).getAll());
}
/** Write a capture bundle and its media atomically. */
export async function putCapture(bundle, mediaBlob) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(['captures', 'media'], 'readwrite');
tx.objectStore('captures').put(bundle);
tx.objectStore('media').put({ id: bundle.id, blob: mediaBlob });
tx.oncomplete = () => resolve(bundle);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function lastCapture() {
const db = await openDb();
return new Promise((resolve, reject) => {
const req = db
.transaction('captures')
.objectStore('captures')
.index('seq')
.openCursor(null, 'prev');
req.onsuccess = () => resolve(req.result?.value || null);
req.onerror = () => reject(req.error);
});
}
export async function captureBySeq(seq) {
const db = await openDb();
return wrap(
db.transaction('captures').objectStore('captures').index('seq').get(seq),
);
}
export async function byIndex(store, index, key) {
const db = await openDb();
return wrap(
db.transaction(store).objectStore(store).index(index).getAll(key),
);
}
-145
View File
@@ -1,145 +0,0 @@
/**
* Tiny, dependency-free EXIF reader for JPEG: GPS position, altitude and
* image direction, DateTimeOriginal (+ offset), orientation, make/model.
* Returns null for anything that is not a JPEG with an Exif APP1 segment.
*/
const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
function readIfd(view, tiff, offset, le) {
const out = {};
if (offset + 2 > view.byteLength) return out;
const n = view.getUint16(offset, le);
for (let i = 0; i < n; i += 1) {
const e = offset + 2 + i * 12;
if (e + 12 > view.byteLength) break;
const tag = view.getUint16(e, le);
const type = view.getUint16(e + 2, le);
const count = view.getUint32(e + 4, le);
const size = (TYPE_SIZE[type] || 1) * count;
const at = size > 4 ? tiff + view.getUint32(e + 8, le) : e + 8;
if (at + size > view.byteLength) continue;
out[tag] = readValue(view, at, type, count, le);
}
return out;
}
function readValue(view, at, type, count, le) {
const one = (k) => {
const p = at + k * (TYPE_SIZE[type] || 1);
switch (type) {
case 1:
case 7:
return view.getUint8(p);
case 3:
return view.getUint16(p, le);
case 4:
return view.getUint32(p, le);
case 9:
return view.getInt32(p, le);
case 5: {
const d = view.getUint32(p + 4, le);
return d ? view.getUint32(p, le) / d : 0;
}
case 10: {
const d = view.getInt32(p + 4, le);
return d ? view.getInt32(p, le) / d : 0;
}
default:
return null;
}
};
if (type === 2) {
let s = '';
for (let k = 0; k < count; k += 1) {
const c = view.getUint8(at + k);
if (!c) break;
s += String.fromCharCode(c);
}
return s.trim();
}
if (count === 1) return one(0);
return Array.from({ length: count }, (_, k) => one(k));
}
const dms = (v) =>
Array.isArray(v) ? v[0] + (v[1] || 0) / 60 + (v[2] || 0) / 3600 : Number(v);
/** "2026:10:18 20:53:37" (+ "-05:00") → ms epoch; local time if no offset. */
function exifDate(s, offset) {
const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s || '');
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : ''}`;
const t = Date.parse(iso);
return Number.isFinite(t) ? t : null;
}
/**
* @param {Blob|ArrayBuffer} input
* @returns {Promise<null | { lat?, lon?, altM?, headingDeg?, takenAt?, takenAtHasZone?, orientation?, make?, model?, raw }>}
*/
export async function readExif(input) {
const buf =
input instanceof Blob
? await input.slice(0, 256 * 1024).arrayBuffer()
: input;
const view = new DataView(buf);
if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8) return null;
let p = 2;
while (p + 4 <= view.byteLength) {
if (view.getUint8(p) !== 0xff) return null;
const marker = view.getUint8(p + 1);
const len = view.getUint16(p + 2);
if (marker === 0xda || marker === 0xd9) return null; // start of scan: no EXIF before it
if (
marker === 0xe1 &&
p + 10 <= view.byteLength &&
view.getUint32(p + 4) === 0x45786966
) {
const tiff = p + 10;
const le = view.getUint16(tiff) === 0x4949;
if (view.getUint16(tiff + 2, le) !== 42) return null;
const ifd0 = readIfd(view, tiff, tiff + view.getUint32(tiff + 4, le), le);
const exif = ifd0[0x8769]
? readIfd(view, tiff, tiff + ifd0[0x8769], le)
: {};
const gps = ifd0[0x8825]
? readIfd(view, tiff, tiff + ifd0[0x8825], le)
: {};
const out = { raw: { ifd0, exif, gps } };
if (gps[2] != null && gps[4] != null) {
let lat = dms(gps[2]);
let lon = dms(gps[4]);
if (String(gps[1]).toUpperCase() === 'S') lat = -lat;
if (String(gps[3]).toUpperCase() === 'W') lon = -lon;
if (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180 &&
(lat || lon)
) {
out.lat = lat;
out.lon = lon;
}
}
if (gps[6] != null) out.altM = (gps[5] === 1 ? -1 : 1) * Number(gps[6]);
if (gps[0x11] != null) out.headingDeg = Number(gps[0x11]);
const offset = exif[0x9011] || exif[0x9010];
const taken = exifDate(
exif[0x9003] || exif[0x9004] || ifd0[0x0132],
offset,
);
if (taken) {
out.takenAt = taken;
out.takenAtHasZone = !!offset;
}
if (ifd0[0x0112]) out.orientation = ifd0[0x0112];
if (ifd0[0x010f]) out.make = ifd0[0x010f];
if (ifd0[0x0110]) out.model = ifd0[0x0110];
return out;
}
p += 2 + len;
}
return null;
}
-155
View File
@@ -1,155 +0,0 @@
/**
* Live broadcast session (DESIGN.md §11.4 "Live"): MediaRecorder timeslice
* chunks are hashed and sealed every 10 s into a hash chain of segments,
* each carrying the pose and position at that moment. Stopping returns the
* whole recording, the chain and the sensor session, ready for `seal()`.
*
* Segment i covers bytes [offset, offset + bytes) of the final file, so a
* verifier can re-split the file and recompute every link.
*/
import { sha256Hex, canonical } from './provenance.js';
const TYPES = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4;codecs=avc1',
'video/mp4',
];
export function canBroadcast(stream) {
return !!stream && typeof MediaRecorder !== 'undefined';
}
/**
* @param {{ stream: MediaStream, sensors: object, pose: () => object,
* fix: () => object|null, segmentMs?: number, onSegment?: (seg) => void }} o
*/
export function createLiveSession(o) {
const segmentMs = o.segmentMs || 10000;
const mimeType = TYPES.find((t) => MediaRecorder.isTypeSupported?.(t));
const chunks = [];
let pending = [];
const segments = [];
let recorder = null;
let timer = 0;
let prev = null;
let offset = 0;
let t0 = 0;
let segT0 = 0;
let sealing = Promise.resolve();
async function sealSegment() {
const parts = pending;
pending = [];
if (!parts.length) return null;
const blob = new Blob(parts, { type: mimeType || 'video/webm' });
const t1 = Date.now();
const p = o.pose?.() || {};
const f = o.fix?.() || null;
const seg = {
i: segments.length,
t0: segT0,
t1,
offset,
bytes: blob.size,
media: await sha256Hex(blob),
pose: {
headingDeg: Number.isFinite(p.az) ? Math.round(p.az * 10) / 10 : null,
pitchDeg: Number.isFinite(p.el) ? Math.round(p.el * 10) / 10 : null,
},
gps: f
? {
lat: +f.lat.toFixed(6),
lon: +f.lon.toFixed(6),
acc: Math.round(f.acc || 0),
}
: null,
prev,
};
seg.hash = await sha256Hex(canonical(seg));
prev = seg.hash;
offset += blob.size;
segT0 = t1;
segments.push(seg);
o.onSegment?.(seg);
return seg;
}
function start() {
recorder = new MediaRecorder(
o.stream,
mimeType ? { mimeType, videoBitsPerSecond: 2.5e6 } : undefined,
);
recorder.ondataavailable = (e) => {
if (!e.data?.size) return;
chunks.push(e.data);
pending.push(e.data);
};
recorder.start(1000);
t0 = Date.now();
segT0 = t0;
o.sensors?.beginSession?.();
timer = setInterval(() => {
sealing = sealing
.then(sealSegment)
.catch((err) => console.warn('segment', err));
}, segmentMs);
}
async function stop() {
clearInterval(timer);
if (recorder && recorder.state !== 'inactive')
await new Promise((resolve) => {
recorder.onstop = resolve;
recorder.stop();
});
await sealing;
await sealSegment();
const session = o.sensors?.endSession?.() || {
start: new Date(t0).toISOString(),
end: new Date().toISOString(),
imu: [],
gps: [],
heading: null,
};
return {
blob: new Blob(chunks, { type: mimeType || 'video/webm' }),
segments,
session,
startedAt: t0,
endedAt: Date.now(),
mimeType: mimeType || 'video/webm',
};
}
return {
start,
stop,
get segments() {
return segments;
},
get startedAt() {
return t0;
},
get active() {
return !!recorder && recorder.state === 'recording';
},
};
}
/** Re-split a live file at the sealed offsets and recompute the chain. */
export async function verifySegments(blob, segments) {
let prev = null;
for (const s of segments) {
const part = blob.slice(s.offset, s.offset + s.bytes);
const media = await sha256Hex(part);
const { hash, ...core } = s;
const h = await sha256Hex(canonical(core));
if (media !== s.media || h !== hash || s.prev !== prev)
return { ok: false, at: s.i };
prev = hash;
}
return { ok: true, n: segments.length, head: prev };
}
-384
View File
@@ -1,384 +0,0 @@
/**
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
* Enclave + C2PA flow:
*
* media → sha256
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
* context { device, ua, screen, timezone, network-time-offset, … }
* prev hash of this device's previous bundle (hash chain)
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
* is generated non-extractable and kept in IndexedDB.
*
* None of this proves truth. It raises the cost of lying.
*/
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
const subtle = globalThis.crypto?.subtle;
const enc = new TextEncoder();
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
export function canonical(value) {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
return JSON.stringify(value ?? null);
}
if (Array.isArray(value))
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
const keys = Object.keys(value)
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
const normalize = (v) => JSON.parse(canonical(v));
export const toHex = (buf) =>
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
const fromHex = (hex) =>
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const fromB64url = (s) => {
const b = atob(
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
);
return Uint8Array.from(b, (c) => c.charCodeAt(0));
};
export async function sha256Hex(data) {
const bytes =
typeof data === 'string'
? enc.encode(data)
: data instanceof Blob
? await data.arrayBuffer()
: data;
return toHex(await subtle.digest('SHA-256', bytes));
}
function assertCrypto() {
if (!subtle)
throw new Error('WebCrypto needs a secure context (https or localhost)');
}
/** The device signing key: generated once, private half non-extractable. */
export async function deviceKey() {
assertCrypto();
const stored = await get('keys', 'device').catch(() => null);
if (stored?.privateKey && stored?.publicJwk) return stored;
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', pair.publicKey);
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
const rec = {
id: 'device',
privateKey: pair.privateKey,
publicKey: pair.publicKey,
publicJwk,
keyId,
createdAt: new Date().toISOString(),
extractable: pair.privateKey.extractable,
};
await put('keys', rec);
return rec;
}
/** Estimate the device clock's offset from the server's Date header (±1 s). */
export async function networkTimeOffset() {
try {
const t0 = Date.now();
const r = await fetch(location.pathname, {
method: 'HEAD',
cache: 'no-store',
});
const t1 = Date.now();
const d = Date.parse(r.headers.get('date') || '');
if (!Number.isFinite(d)) return null;
return Math.round(d + 500 - (t0 + t1) / 2);
} catch {
return null;
}
}
/**
* Seal a capture.
* @param {{ media: Blob, session: object, context: object }} input
* @returns {Promise<object>} the stored bundle
*/
export async function seal({ media, session, context }) {
assertCrypto();
const key = await deviceKey();
const mediaHash = await sha256Hex(media);
const last = await lastCapture().catch(() => null);
const seq = (last?.seq || 0) + 1;
const core = normalize({
v: 1,
seq,
media: {
sha256: mediaHash,
type: media.type || 'application/octet-stream',
bytes: media.size,
},
session,
context,
prev: last?.hash || null,
keyId: key.keyId,
publicKey: key.publicJwk,
sealedAt: new Date().toISOString(),
});
const hash = await sha256Hex(canonical(core));
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
const bundle = {
...core,
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
hash,
alg: 'ES256',
signature: b64url(sig),
};
await putCapture(bundle, media);
return bundle;
}
const median = (a) => {
if (!a.length) return NaN;
const s = [...a].sort((x, y) => x - y);
return s[Math.floor(s.length / 2)];
};
/** Core fields covered by the hash (everything but the outputs). */
function coreOf(bundle) {
const { id, hash, alg, signature, ...core } = bundle;
void id;
void hash;
void alg;
void signature;
return core;
}
/**
* Verify a bundle: hash, signature, chain, sensor plausibility.
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
*/
export async function verify(bundle, mediaBlob) {
assertCrypto();
const checks = [];
const add = (id, label, status, detail) =>
checks.push({ id, label, status, detail });
// 1. Media hash
if (mediaBlob) {
const h = await sha256Hex(mediaBlob);
add(
'media',
'Media hash',
h === bundle.media?.sha256 ? 'pass' : 'fail',
h === bundle.media?.sha256
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
);
} else
add('media', 'Media hash', 'warn', 'Media not available on this device');
// 2. Bundle hash
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
const hashOk = recomputed === bundle.hash;
add(
'hash',
'Bundle hash',
hashOk ? 'pass' : 'fail',
hashOk
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
: 'Bundle was modified after sealing',
);
// 3. Signature
try {
const pub = await subtle.importKey(
'jwk',
{ ...bundle.publicKey, ext: true },
ALG,
true,
['verify'],
);
const ok = await subtle.verify(
SIGN,
pub,
fromB64url(bundle.signature),
fromHex(bundle.hash),
);
const mine = await deviceKey().catch(() => null);
const same = mine?.keyId === bundle.keyId;
add(
'signature',
'Signature',
ok ? (same ? 'pass' : 'warn') : 'fail',
ok
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
: 'Signature does not verify against the embedded public key',
);
} catch (err) {
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
}
// 4. Chain
if (bundle.seq === 1)
add(
'chain',
'Hash chain',
bundle.prev === null ? 'pass' : 'fail',
bundle.prev === null
? 'Genesis capture for this device key'
: 'First capture claims a predecessor',
);
else {
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
if (!prev)
add(
'chain',
'Hash chain',
'warn',
`Capture #${bundle.seq - 1} is not on this device`,
);
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
add(
'chain',
'Hash chain',
'pass',
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
);
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
}
// 5. IMU plausibility
const imu = bundle.session?.imu || [];
if (imu.length < 5)
add(
'imu',
'Motion trace',
'warn',
imu.length
? `Only ${imu.length} samples`
: 'No motion sensors were available',
);
else {
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
const rate = imu.length / Math.max(0.001, dur);
let mono = true;
for (let i = 1; i < imu.length; i += 1)
if (imu[i].t < imu[i - 1].t) mono = false;
const mags = imu
.filter((s) => Number.isFinite(s.ax))
.map((s) => Math.hypot(s.ax, s.ay, s.az));
const g = median(mags);
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
const sd = Math.sqrt(
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
);
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
const alive = sd > 0.004;
// Pitch from gravity should agree with the orientation β.
let tiltAgree = null;
const withBoth = imu.filter(
(s) =>
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
);
if (withBoth.length > 4) {
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const diffs = withBoth.map((s) => {
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
return Math.min(d(pitchG, s.β), d(flipped, s.β));
});
tiltAgree = median(diffs) < 20;
}
const status =
!mono || gravityOk === false
? 'fail'
: alive && tiltAgree !== false
? 'pass'
: 'warn';
add(
'imu',
'Motion trace',
status,
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
);
}
// 6. GPS plausibility
const gps = bundle.session?.gps || [];
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
else {
const s0 = Date.parse(bundle.session.start);
const s1 = Date.parse(bundle.session.end);
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
let maxSpeed = 0;
for (let i = 1; i < gps.length; i += 1) {
const a = gps[i - 1];
const b = gps[i];
const dt = (b.t - a.t) / 1000;
if (dt > 0.5) {
const R = 6371e3;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
}
}
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
const last = gps[gps.length - 1];
// The clock's timezone should be roughly where the fix says we are.
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
const solarH = last.lon / 15;
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
const speedOk = maxSpeed < 300;
const status =
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
add(
'gps',
'Location',
status,
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
);
}
// 7. Heading
const hd = bundle.session?.heading;
if (hd && Number.isFinite(hd.deg))
add(
'heading',
'Heading',
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
);
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
// 8. Clock
const off = bundle.context?.networkTimeOffsetMs;
const sealedVsEnd =
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
add(
'clock',
'Clock',
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
? 'warn'
: clockOk
? 'pass'
: 'warn',
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
);
return { ok: !checks.some((c) => c.status === 'fail'), checks };
}
-371
View File
@@ -1,371 +0,0 @@
/**
* Device sensors for Look and Witness: motion/orientation (with iOS
* permission on a user gesture), compass heading, camera pose, GPS watch,
* and a session recorder for the sealed IMU trace.
*/
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
/** iOS 13+: must be called from a user gesture. */
export async function requestMotionPermission() {
const asks = [];
if (
typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function'
)
asks.push(DeviceOrientationEvent.requestPermission());
if (
typeof DeviceMotionEvent !== 'undefined' &&
typeof DeviceMotionEvent.requestPermission === 'function'
)
asks.push(DeviceMotionEvent.requestPermission());
if (!asks.length)
return typeof DeviceOrientationEvent === 'undefined'
? 'unsupported'
: 'granted';
try {
const res = await Promise.all(asks);
return res.every((r) => r === 'granted') ? 'granted' : 'denied';
} catch {
return 'denied';
}
}
/** W3C Z-X'-Y'' rotation matrix (device → East-North-Up). */
export function rotation(alpha, beta, gamma) {
const a = alpha * D2R;
const b = beta * D2R;
const g = gamma * D2R;
const cA = Math.cos(a);
const sA = Math.sin(a);
const cB = Math.cos(b);
const sB = Math.sin(b);
const cG = Math.cos(g);
const sG = Math.sin(g);
return [
[cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],
[cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],
[-cB * sG, sB, cB * cG],
];
}
/** Back-camera pose in ENU: forward / right / up unit vectors + azimuth/elevation. */
export function cameraPose(alpha, beta, gamma, screenAngle = 0) {
const R = rotation(alpha, beta, gamma);
const col = (i) => [R[0][i], R[1][i], R[2][i]];
const fwd = col(2).map((v) => -v);
let right = col(0);
let up = col(1);
if (screenAngle) {
// Landscape: rotate the screen basis about the forward axis.
const s = screenAngle * D2R;
const r2 = right.map((v, i) => v * Math.cos(s) + up[i] * Math.sin(s));
const u2 = up.map((v, i) => v * Math.cos(s) - right[i] * Math.sin(s));
right = r2;
up = u2;
}
const az = (Math.atan2(fwd[0], fwd[1]) * R2D + 360) % 360;
const el = Math.asin(Math.max(-1, Math.min(1, fwd[2]))) * R2D;
const roll = Math.atan2(right[2], up[2]) * R2D;
return { fwd, right, up, az, el, roll };
}
/** Pose from a simulated yaw/pitch (desktop drag-to-look). */
export function poseFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
const fwd = [
Math.cos(e) * Math.sin(a),
Math.cos(e) * Math.cos(a),
Math.sin(e),
];
const right = [Math.cos(a), -Math.sin(a), 0];
const up = [
right[1] * fwd[2] - right[2] * fwd[1],
right[2] * fwd[0] - right[0] * fwd[2],
right[0] * fwd[1] - right[1] * fwd[0],
];
return { fwd, right, up, az: ((az % 360) + 360) % 360, el, roll: 0 };
}
export function createSensors() {
const state = {
orientation: null, // {alpha,beta,gamma,absolute}
motion: null, // {ax,ay,az,lx,ly,lz,rr}
compass: null, // degrees, iOS webkitCompassHeading
compassAccuracy: null,
fix: null, // {t,lat,lon,acc,alt,heading,speed}
geoError: null,
hasMotion: false,
hasOrientation: false,
absolute: false,
};
const trace = []; // live |a| for the HUD
let rec = null;
let geoWatch = null;
let alphaOffset = 0;
let listeners = [];
function onOrient(e) {
if (e.alpha == null && e.beta == null) return;
state.hasOrientation = true;
const absolute =
e.absolute === true || e.type === 'deviceorientationabsolute';
if (absolute) state.absolute = true;
if (
!absolute &&
state.absolute &&
e.type === 'deviceorientation' &&
e.webkitCompassHeading == null
)
return;
state.orientation = {
alpha: e.alpha ?? 0,
beta: e.beta ?? 0,
gamma: e.gamma ?? 0,
absolute,
};
if (e.webkitCompassHeading != null) {
state.compass = e.webkitCompassHeading;
state.compassAccuracy = e.webkitCompassAccuracy;
// Align the arbitrary iOS alpha with magnetic north, smoothly.
// az = −α for an upright camera, so α + (raw − compass) points at north.
const raw = cameraPose(e.alpha, e.beta, e.gamma).az;
const target = ((raw - e.webkitCompassHeading + 540) % 360) - 180;
alphaOffset += (((target - alphaOffset + 540) % 360) - 180) * 0.15;
}
if (rec) sample();
}
function onMotion(e) {
const g = e.accelerationIncludingGravity;
const l = e.acceleration;
if (!g || g.x == null) return;
state.hasMotion = true;
state.motion = {
ax: g.x,
ay: g.y,
az: g.z,
lx: l?.x,
ly: l?.y,
lz: l?.z,
rr: e.rotationRate
? [e.rotationRate.alpha, e.rotationRate.beta, e.rotationRate.gamma]
: null,
};
const mag = Math.hypot(g.x, g.y, g.z);
trace.push({ t: performance.now(), v: mag });
while (trace.length && performance.now() - trace[0].t > 5000) trace.shift();
if (rec) sample();
}
let lastSampleT = 0;
function sample() {
const now = performance.now();
if (now - lastSampleT < 15) return; // ≤ ~66 Hz
lastSampleT = now;
const o = state.orientation || {};
const m = state.motion || {};
rec.imu.push({
t: Date.now() - rec.t0Wall,
α: round(o.alpha),
β: round(o.beta),
γ: round(o.gamma),
ax: round(m.ax, 3),
ay: round(m.ay, 3),
az: round(m.az, 3),
});
if (rec.imu.length > 4000) rec.imu.shift();
}
function round(v, d = 2) {
return Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null;
}
function on(target, type, fn) {
target.addEventListener(type, fn, { passive: true });
listeners.push(() => target.removeEventListener(type, fn));
}
function start() {
stop();
if ('ondeviceorientationabsolute' in window)
on(window, 'deviceorientationabsolute', onOrient);
on(window, 'deviceorientation', onOrient);
on(window, 'devicemotion', onMotion);
if ('geolocation' in navigator) {
geoWatch = navigator.geolocation.watchPosition(
(p) => {
state.geoError = null;
state.fix = {
t: p.timestamp || Date.now(),
lat: p.coords.latitude,
lon: p.coords.longitude,
acc: p.coords.accuracy,
alt: p.coords.altitude,
heading: p.coords.heading,
speed: p.coords.speed,
};
if (rec)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
},
(err) => (state.geoError = err.message || 'Location unavailable'),
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 },
);
}
}
function stop() {
listeners.forEach((f) => f());
listeners = [];
if (geoWatch != null) navigator.geolocation.clearWatch(geoWatch);
geoWatch = null;
}
/** Current back-camera pose, or null without orientation sensors. */
function pose(screenAngle = screenOrientationAngle()) {
const o = state.orientation;
if (!o) return null;
const alpha = state.compass != null ? o.alpha + alphaOffset : o.alpha;
return cameraPose(alpha, o.beta, o.gamma, screenAngle);
}
function headingInfo() {
const p = pose();
if (!p) return null;
return {
deg: Math.round(p.az * 10) / 10,
pitch: Math.round(p.el * 10) / 10,
roll: Math.round(p.roll * 10) / 10,
source:
state.compass != null
? 'compass'
: state.absolute
? 'absolute'
: 'relative',
};
}
return {
state,
trace,
start,
stop,
pose,
headingInfo,
/** Begin recording a session (IMU + GPS). Keeps a pre-roll of what's already live. */
beginSession() {
rec = {
t0Wall: Date.now(),
start: new Date().toISOString(),
imu: [],
gps: [],
};
if (state.fix)
rec.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return rec;
},
endSession() {
if (!rec) return null;
const r = rec;
rec = null;
// Keep the last 20 s of motion before the shutter.
const cutoff = Date.now() - r.t0Wall - 20000;
const imu = r.imu.filter((s) => s.t >= cutoff);
if (!r.gps.length && state.fix)
r.gps.push({
t: state.fix.t,
lat: round(state.fix.lat, 6),
lon: round(state.fix.lon, 6),
acc: round(state.fix.acc, 1),
alt: round(state.fix.alt, 1),
});
return {
start: r.start,
end: new Date().toISOString(),
imu,
gps: r.gps,
heading: headingInfo(),
};
},
get recording() {
return !!rec;
},
};
}
export function screenOrientationAngle() {
const a = screen.orientation?.angle ?? window.orientation ?? 0;
return Number(a) || 0;
}
/* ---------- AR geometry ---------- */
const A = 6378137;
const E2 = 6.69437999014e-3;
function ecef(lat, lon, h) {
const la = lat * D2R;
const lo = lon * D2R;
const N = A / Math.sqrt(1 - E2 * Math.sin(la) ** 2);
return [
(N + h) * Math.cos(la) * Math.cos(lo),
(N + h) * Math.cos(la) * Math.sin(lo),
(N * (1 - E2) + h) * Math.sin(la),
];
}
/** Azimuth, elevation (deg) and slant range (m) from an observer to a target. */
export function lookAngles(obs, tgt) {
const o = ecef(obs.lat, obs.lon, obs.alt || 0);
const t = ecef(tgt.lat, tgt.lon, tgt.altM || 0);
const d = [t[0] - o[0], t[1] - o[1], t[2] - o[2]];
const la = obs.lat * D2R;
const lo = obs.lon * D2R;
const e = -Math.sin(lo) * d[0] + Math.cos(lo) * d[1];
const n =
-Math.sin(la) * Math.cos(lo) * d[0] -
Math.sin(la) * Math.sin(lo) * d[1] +
Math.cos(la) * d[2];
const u =
Math.cos(la) * Math.cos(lo) * d[0] +
Math.cos(la) * Math.sin(lo) * d[1] +
Math.sin(la) * d[2];
const range = Math.hypot(e, n, u);
return {
az: (Math.atan2(e, n) * R2D + 360) % 360,
el: Math.asin(u / range) * R2D,
range,
enu: [e / range, n / range, u / range],
};
}
/** Project an ENU unit direction into screen pixels for a pinhole camera. */
export function projectDir(dir, pose, W, H, vfovDeg) {
const x =
dir[0] * pose.right[0] + dir[1] * pose.right[1] + dir[2] * pose.right[2];
const y = dir[0] * pose.up[0] + dir[1] * pose.up[1] + dir[2] * pose.up[2];
const z = dir[0] * pose.fwd[0] + dir[1] * pose.fwd[1] + dir[2] * pose.fwd[2];
const fy = H / 2 / Math.tan((vfovDeg / 2) * D2R);
if (z <= 0.01)
return { behind: true, x: W / 2 + x * 1e4, y: H / 2 - y * 1e4 };
return { behind: false, x: W / 2 + (x / z) * fy, y: H / 2 - (y / z) * fy, z };
}
export function dirFromAzEl(az, el) {
const a = az * D2R;
const e = el * D2R;
return [Math.cos(e) * Math.sin(a), Math.cos(e) * Math.cos(a), Math.sin(e)];
}
-935
View File
@@ -1,935 +0,0 @@
#!/usr/bin/env node
/**
* Builds the hero Moment, mobile/demo/moments/empire-state.json:
* "Climbers on the Empire State Building" (DESIGN.md §12.4).
*
* ILLUSTRATIVE. Nothing here happened. The event, the helicopters, their
* callsigns and tail numbers, the witnesses, the chat and the posts are all
* fictional, and the file says so (`illustrative: true`). What is real is the
* place: the building's position and heights, the street corners, the
* heliports, and the neighbouring building massing (midtown-buildings.json).
*
* The helicopter tracks come from a small flight model, not from hand-drawn
* circles: each aircraft flies a constant airspeed in a steady wind, steers
* to hold an orbit radius with a rate-limited heading (about standard rate),
* and drifts in altitude. The ground track that results is what ADS-B shows
* for a real news orbit: stretched downwind, faster on the downwind side,
* with a few metres of position noise. Output is 1 Hz.
*
* node mobile/demo/build-empire-state.mjs
*
* Deterministic: the same seed always writes the same file.
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
async function prettyJson(value) {
const text = JSON.stringify(value);
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return `${text}\n`;
}
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SEED = 0x35_0f1f7a;
/* ── Place (real) ───────────────────────────────────────────────── */
const ESB = { lon: -73.98566, lat: 40.74844 };
const GROUND_MSL = 15; // m, 5th Ave at 34th St
const ROOF = 381; // m above street (102nd floor roof)
const ANTENNA = 443; // m, antenna tip
const DECK_86 = 320; // m, 86th floor open-air deck
const DECK_102 = 373; // m, 102nd floor deck
const DMH = {
lon: -74.00905,
lat: 40.70137,
name: 'Downtown Manhattan Heliport',
};
const E34 = {
lon: -73.97175,
lat: 40.74245,
name: 'East 34th Street Heliport',
};
const NJ = { lon: -74.0742, lat: 40.7727, name: 'over Secaucus, NJ' };
/* ── Clock (fictional date) ─────────────────────────────────────── */
const T0 = Date.UTC(2026, 8, 18, 21, 18, 0); // 5:18 PM EDT
const DURATION_S = 25 * 60;
const at = (mmss) => {
const [m, s] = String(mmss).split(':').map(Number);
return T0 + (m * 60 + (s || 0)) * 1000;
};
/* ── Deterministic randomness ───────────────────────────────────── */
function mulberry32(a) {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(SEED);
const gauss = () => {
let u = 0;
let v = 0;
while (!u) u = rand();
while (!v) v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
/* ── Local metric frame ─────────────────────────────────────────── */
const D2R = Math.PI / 180;
const KX = 111_320 * Math.cos(ESB.lat * D2R);
const KY = 110_574;
const toXY = (p) => [(p.lon - ESB.lon) * KX, (p.lat - ESB.lat) * KY];
const toLL = (x, y) => ({ lon: ESB.lon + x / KX, lat: ESB.lat + y / KY });
const KT = 0.514444;
const FT = 0.3048;
const wrap = (d) => (((d % 360) + 540) % 360) - 180;
const bearingXY = (dx, dy) => (Math.atan2(dx, dy) / D2R + 360) % 360;
/* ── Flight model ───────────────────────────────────────────────── */
// Wind at 1,000–1,500 ft over Midtown: 14 kt from 290° (blowing toward 110°).
const WIND = { fromDeg: 290, kt: 14 };
const windV = () => {
const to = (WIND.fromDeg + 180) * D2R;
return [Math.sin(to) * WIND.kt * KT, Math.cos(to) * WIND.kt * KT];
};
/**
* Fly a helicopter through `plan` (a list of legs) and return 1 Hz points.
* Legs: { until, mode:'ground'|'goto'|'orbit', ... }.
*/
function fly({ start, startAltFt, plan, tStart, tEnd, noiseM = 4 }) {
const dt = 0.2;
let [x, y] = toXY(start);
let alt = startAltFt * FT;
let hdg = plan[0].initialHeading ?? 0;
let air = 0; // airspeed m/s
const [wx, wy] = windV();
const out = [];
let leg = 0;
let nextOut = tStart;
let altPhase = rand() * Math.PI * 2;
for (let t = tStart; t <= tEnd; t += dt * 1000) {
while (leg < plan.length - 1 && t >= plan[leg].until) leg++;
const L = plan[leg];
let targetAir = (L.kt ?? 0) * KT;
let targetAlt = (L.altFt ?? startAltFt) * FT;
let desired = hdg;
if (L.mode === 'ground') {
targetAir = 0;
targetAlt = (L.altFt ?? 0) * FT;
} else if (L.mode === 'goto') {
const [tx, ty] = toXY(L.to);
desired = bearingXY(tx - x, ty - y);
// Arrive gently: slow down inside 600 m of the end of a goto leg.
const d = Math.hypot(tx - x, ty - y);
if (L.slowNear && d < 600)
targetAir = Math.max(L.slowNear * KT, targetAir * (d / 600));
} else if (L.mode === 'orbit') {
const [cx, cy] = toXY(L.center);
const dx = x - cx;
const dy = y - cy;
const d = Math.hypot(dx, dy);
const beta = bearingXY(dx, dy);
// Radius breathes a little: pilots never fly a perfect circle.
const r =
L.radius * (1 + 0.06 * Math.sin((t - tStart) / 1000 / 47 + L.radius));
const err = Math.atan((d - r) / 110) / D2R;
desired = L.dir === 'cw' ? beta + 90 + err : beta - 90 - err;
// The heading that holds that ground track in the wind (crab angle).
const gx = Math.sin(desired * D2R);
const gy = Math.cos(desired * D2R);
const cross = wx * gy - wy * gx; // wind across the track
const crab = Math.asin(
Math.max(-0.9, Math.min(0.9, cross / Math.max(8, targetAir))),
);
desired = desired + crab / D2R;
}
// Heading: rate-limited, about standard rate (3°/s), up to 5.5°/s.
const maxRate = (L.maxRate ?? 5.5) * dt;
const dh = wrap(desired - hdg);
hdg = (hdg + Math.max(-maxRate, Math.min(maxRate, dh)) + 360) % 360;
// Speed and altitude respond with first-order lags.
air += (targetAir - air) * Math.min(1, dt / 6);
altPhase += dt / 37;
const drift =
L.mode === 'orbit' ? (28 * Math.sin(altPhase) + 9 * gauss()) * FT : 0;
alt += (targetAlt + drift - alt) * Math.min(1, dt / 9);
if (L.mode === 'ground') {
air = 0;
alt += (0 - alt) * Math.min(1, dt / 4);
} else {
x += (Math.sin(hdg * D2R) * air + wx * Math.min(1, air / 10)) * dt;
y += (Math.cos(hdg * D2R) * air + wy * Math.min(1, air / 10)) * dt;
}
if (t >= nextOut) {
nextOut += 1000;
if (L.mode === 'hidden') continue;
const p = toLL(x + gauss() * noiseM, y + gauss() * noiseM);
// ADS-B altitude: 25 ft steps, MSL.
const altFt = Math.round((alt / FT + GROUND_MSL / FT) / 25) * 25;
out.push({
t: Math.round(t),
lon: Math.round(p.lon * 1e6) / 1e6,
lat: Math.round(p.lat * 1e6) / 1e6,
altM: Math.round(altFt * FT),
});
}
}
return out;
}
const near = (p, m, deg) => {
const [x, y] = toXY(p);
return toLL(x + Math.sin(deg * D2R) * m, y + Math.cos(deg * D2R) * m);
};
const orbitCenter = (m, deg) => near(ESB, m, deg);
/* ── The three helicopters (fictional) ──────────────────────────── */
const police = fly({
start: DMH,
startAltFt: 0,
tStart: T0,
tEnd: T0 + DURATION_S * 1000,
plan: [
{ mode: 'ground', until: at('0:20'), initialHeading: 20 },
// Up the west side, over the Hudson shoreline, then across to 34th St.
{
mode: 'goto',
to: { lon: -74.0135, lat: 40.7205 },
kt: 95,
altFt: 900,
until: at('0:55'),
},
{
mode: 'goto',
to: { lon: -74.0055, lat: 40.739 },
kt: 110,
altFt: 1100,
until: at('1:40'),
},
{
mode: 'goto',
to: near(ESB, 520, 240),
kt: 90,
altFt: 1100,
slowNear: 70,
until: at('2:10'),
},
{
mode: 'orbit',
center: orbitCenter(60, 200),
radius: 470,
kt: 64,
altFt: 1100,
dir: 'ccw',
until: at('14:00'),
},
// Tightens and steps down once officers reach the 102nd floor deck.
{
mode: 'orbit',
center: orbitCenter(30, 120),
radius: 410,
kt: 58,
altFt: 1000,
dir: 'ccw',
until: at('25:01'),
},
],
});
const news7 = fly({
start: NJ,
startAltFt: 900,
tStart: T0 + 85_000,
tEnd: T0 + DURATION_S * 1000,
plan: [
{
mode: 'goto',
to: { lon: -74.024, lat: 40.7585 },
kt: 120,
altFt: 1400,
until: at('3:20'),
initialHeading: 120,
},
{
mode: 'goto',
to: near(ESB, 700, 300),
kt: 105,
altFt: 1450,
slowNear: 80,
until: at('4:30'),
},
{
mode: 'orbit',
center: orbitCenter(120, 30),
radius: 660,
kt: 76,
altFt: 1450,
dir: 'ccw',
until: at('25:01'),
},
],
});
const sky4 = fly({
start: E34,
startAltFt: 0,
tStart: T0 + 330_000,
tEnd: T0 + DURATION_S * 1000,
plan: [
{ mode: 'ground', until: at('6:00'), initialHeading: 300 },
{
mode: 'goto',
to: near(ESB, 600, 140),
kt: 70,
altFt: 1250,
slowNear: 62,
until: at('7:25'),
},
{
mode: 'orbit',
center: orbitCenter(90, 160),
radius: 560,
kt: 68,
altFt: 1250,
dir: 'ccw',
until: at('21:00'),
},
// Leaves for the next story: back out over the East River, south.
{
mode: 'goto',
to: { lon: -73.962, lat: 40.728 },
kt: 110,
altFt: 1300,
until: at('22:30'),
},
{
mode: 'goto',
to: { lon: -73.99, lat: 40.69 },
kt: 120,
altFt: 1300,
until: at('25:01'),
},
],
});
const tracks = [
{
id: 'pd-14',
kind: 'helicopter',
label: 'PD-14 (illustrative)',
callsign: 'PD14',
registration: 'ILLUS-14',
type: 'Bell 429',
operator: 'City police aviation (fictional)',
role: 'police',
color: '#5EC8FF',
points: police,
},
{
id: 'news-7',
kind: 'helicopter',
label: 'NEWS-7 (illustrative)',
callsign: 'NEWS7',
registration: 'ILLUS-07',
type: 'Airbus H125',
operator: 'Channel 7 News (fictional)',
role: 'news',
color: '#FFD166',
points: news7,
},
{
id: 'sky-4',
kind: 'helicopter',
label: 'SKY-4 (illustrative)',
callsign: 'SKY4',
registration: 'ILLUS-04',
type: 'Airbus H130',
operator: 'Sky 4 News (fictional)',
role: 'news',
color: '#FF9F6B',
points: sky4,
},
];
/* ── Witness captures (fictional people, real corners) ──────────── */
function aim(p, altM, targetAltM) {
const [x, y] = toXY(p);
const d = Math.hypot(x, y);
return {
headingDeg: Math.round(bearingXY(-x, -y) * 10) / 10,
pitchDeg: Math.round((Math.atan2(targetAltM - altM, d) / D2R) * 10) / 10,
rangeM: Math.round(d),
};
}
const capture = (id, mmss, where, altM, targetAltM, fovDeg, rest) => ({
id,
t: at(mmss),
lon: where.lon,
lat: where.lat,
altM,
...aim(where, altM, targetAltM),
fovDeg,
sealed: true,
...rest,
});
const captures = [
capture(
'c1',
'0:52',
{ lon: -73.98795, lat: 40.74968 },
1.5,
DECK_86 + 10,
22,
{
title: 'Two figures outside the 86th floor fence',
handle: 'kvn_on_34th',
place: 'W 34th St & 6th Ave',
firstEyes: true,
kind: 'photo',
},
),
capture(
'c2',
'3:05',
{ lon: -73.98752, lat: 40.74482 },
1.5,
DECK_86 + 30,
30,
{
title: 'North face, police helicopter overhead',
handle: 'jules_flatiron',
place: '5th Ave & 29th St',
kind: 'clip',
},
),
capture('c3', '6:10', { lon: -73.99325, lat: 40.75195 }, 142, ROOF + 20, 18, {
title: 'On the mast, east side, from the 40th floor',
handle: 'penn_plaza_40th',
place: '1 Penn Plaza, 40th floor',
kind: 'clip',
}),
capture('c4', '8:40', { lon: -74.00355, lat: 40.75255 }, 9, ROOF, 14, {
title: 'Three helicopters around the spire',
handle: 'hellsk1tchen',
place: 'The High Line at W 30th St',
kind: 'photo',
}),
capture('c5', '12:20', { lon: -73.98805, lat: 40.74745 }, 55, DECK_102, 40, {
title: 'Officers on the 102nd floor deck',
handle: 'lena.ktown',
place: 'Rooftop, W 32nd St (Koreatown)',
kind: 'clip',
}),
capture('c6', '15:30', { lon: -73.98205, lat: 40.75112 }, 1.5, ROOF, 28, {
title: 'Crowd on 5th Ave looking up',
handle: 'midtown_mara',
place: '5th Ave & 38th St',
kind: 'photo',
}),
capture('c7', '19:45', { lon: -73.97765, lat: 40.74662 }, 48, DECK_102, 20, {
title: 'Both back over the rail at 102',
handle: 'rooftop_rafa',
place: 'Rooftop, Murray Hill',
kind: 'clip',
}),
capture(
'c8',
'21:10',
{ lon: -73.98875, lat: 40.74905 },
1.5,
ROOF - 40,
34,
{
title: 'SKY-4 peeling away east',
handle: 'deli_guy_dan',
place: 'Broadway & 33rd St (Herald Sq)',
kind: 'photo',
},
),
];
/* ── Live lane: chat (fictional handles) ────────────────────────── */
// [m:ss, handle, text, badge?]. Bursts follow the beats: first sighting,
// police arrival, news arrival, the mast climb, the top, officers on deck,
// coming down.
const CHAT = [
['0:12', 'deli_guy_dan', 'why is everyone on 34th looking up'],
[
'0:31',
'kvn_on_34th',
'theres PEOPLE on the empire state. outside the fence on 86',
'local',
],
['0:38', 'midtown_mara', 'wait what'],
['0:44', 'brklyn_bea', 'link??'],
[
'0:52',
'kvn_on_34th',
'sealed photo up. north side, two of them',
'first-eyes',
],
['0:58', 'throwaway_tom', 'thats a window washer rig'],
['1:04', 'kvn_on_34th', 'no rig. no ropes that I can see', 'first-eyes'],
['1:10', 'jules_flatiron', 'omg I see them from 29th'],
['1:19', 'nyc_scanner_nerd', 'scanner: units responding to 350 5th', 'local'],
['1:33', 'ess_ay_dee', '👀👀👀'],
['1:41', 'midtown_mara', 'this is insane'],
[
'1:52',
'avgeek_jfk',
'police helo just lifted out of the downtown heliport',
'pilot',
],
['2:04', 'brklyn_bea', 'I hear it'],
['2:10', 'nyc_scanner_nerd', 'PD-14 overhead now, orbiting', 'local'],
['2:12', 'deli_guy_dan', 'LOUD'],
['2:14', 'ess_ay_dee', '🔥🔥'],
['2:16', 'hellsk1tchen', 'can see the heli circling from the west side'],
['2:21', 'throwaway_tom', 'ok not window washers lol'],
['2:25', 'murrayhill_mo', 'is this a protest?'],
[
'2:31',
'kvn_on_34th',
'no banner, they just look like climbers. helmets',
'first-eyes',
],
['2:44', 'lena.ktown', 'going up to our roof'],
['2:58', 'jules_flatiron', 'clip sealed from 5th & 29th', 'witness'],
['3:12', 'brklyn_bea', 'how did they even get out there'],
[
'3:26',
'avgeek_jfk',
'orbit looks ~1,100 ft, tight, maybe 500 m radius',
'pilot',
],
['3:39', 'midtown_mara', 'they are not moving'],
['3:55', 'ess_ay_dee', 'pray for them fr'],
['4:08', 'nyc_scanner_nerd', 'second helo inbound from jersey', 'local'],
['4:22', 'deli_guy_dan', 'news is here'],
['4:30', 'avgeek_jfk', 'NEWS-7 up high, ~1,450', 'pilot'],
['4:33', 'murrayhill_mo', 'its on tv now'],
['4:35', 'brklyn_bea', 'channel 7 live rn'],
['4:37', 'ess_ay_dee', '😮'],
['4:41', 'throwaway_tom', 'tv is 40s behind this chat lmao'],
['4:52', 'hellsk1tchen', 'two helis circling opposite sides'],
['5:10', 'kvn_on_34th', 'THEY ARE CLIMBING. the mast', 'first-eyes'],
['5:12', 'midtown_mara', 'NO'],
['5:13', 'jules_flatiron', 'oh my god'],
['5:15', 'deli_guy_dan', 'nope nope nope'],
['5:17', 'ess_ay_dee', '😮😮😮'],
['5:20', 'brklyn_bea', 'im shaking'],
[
'5:24',
'lena.ktown',
'on the roof, I can see them on the east side',
'local',
],
['5:31', 'murrayhill_mo', 'how high is that'],
['5:36', 'avgeek_jfk', 'mast starts ~1,250 ft. antenna tip 1,454', 'pilot'],
['5:52', 'throwaway_tom', 'who does this'],
[
'6:02',
'nyc_scanner_nerd',
'third helo lifting from E 34th St heliport',
'local',
],
[
'6:10',
'penn_plaza_40th',
'from 1 Penn 40th fl. sealed. they are halfway up the mast',
'witness',
],
['6:14', 'deli_guy_dan', 'thats so close'],
['6:25', 'midtown_mara', 'is that a drone?'],
['6:40', 'hellsk1tchen', 'drone over by the river too'],
[
'6:58',
'avgeek_jfk',
'not a drone, thats SKY-4 coming off the E 34th pad',
'pilot',
],
['7:20', 'brklyn_bea', 'three helicopters now'],
['7:26', 'ess_ay_dee', '🔥🔥🔥'],
[
'7:41',
'metwx_paul',
'winds ~15 kt from WNW at 1,000+ ft. thats why the orbits look stretched east',
'met',
],
['7:58', 'jules_flatiron', 'the gusts up there must be brutal'],
['8:12', 'murrayhill_mo', 'they stopped'],
['8:30', 'kvn_on_34th', 'one is waving??', 'first-eyes'],
[
'8:40',
'hellsk1tchen',
'sealed from the high line, all three helis in frame',
'witness',
],
['8:51', 'deli_guy_dan', 'crowd on 5th is massive now'],
['9:05', 'brklyn_bea', 'please come down'],
['9:22', 'lena.ktown', 'they are going higher', 'witness'],
['9:34', 'throwaway_tom', 'HOW'],
['9:40', 'kvn_on_34th', 'ONE IS AT THE TOP OF THE MAST', 'first-eyes'],
['9:41', 'midtown_mara', 'NOOOO'],
['9:42', 'ess_ay_dee', '😮😮😮😮'],
['9:43', 'jules_flatiron', 'oh my god oh my god'],
['9:44', 'deli_guy_dan', 'cant watch'],
['9:46', 'murrayhill_mo', 'insane'],
['9:48', 'brklyn_bea', '😭'],
['9:51', 'hellsk1tchen', 'the whole avenue just gasped'],
['9:55', 'penn_plaza_40th', 'whole office is at the window', 'witness'],
[
'10:08',
'heli_dispatch_ret',
'three ships stacked 1,000/1,250/1,450, all left orbits. textbook',
'pilot',
],
['10:22', 'throwaway_tom', 'helmet cam. this is for a video 100%'],
[
'10:40',
'avgeek_jfk',
'NEWS-7 widening out, prob for the wide shot',
'pilot',
],
['11:05', 'midtown_mara', 'is the deck closed?'],
[
'11:20',
'nyc_scanner_nerd',
'scanner says deck cleared, ESU going up',
'local',
],
['11:42', 'brklyn_bea', 'come on come on'],
['12:05', 'murrayhill_mo', 'whats ESU'],
[
'12:11',
'nyc_scanner_nerd',
'emergency service unit. rescue specialists',
'local',
],
[
'12:20',
'lena.ktown',
'sealed: officers on the 102 deck right under them',
'witness',
],
['12:24', 'ess_ay_dee', '🙏'],
['12:35', 'deli_guy_dan', 'they are talking to them i think'],
['13:02', 'jules_flatiron', 'PD-14 dropping lower'],
[
'13:40',
'heli_dispatch_ret',
'police ship tightening the orbit, stepping down to ~1,000',
'pilot',
],
['14:10', 'throwaway_tom', 'third climber on the setback??'],
['14:14', 'midtown_mara', 'wait three??'],
['14:20', 'brklyn_bea', 'I see a third one'],
[
'14:48',
'jules_flatiron',
'thats the mast shadow lol. compare the 5th ave and high line shots',
'witness',
],
['15:02', 'hellsk1tchen', 'yeah shadow, sun is behind us', 'witness'],
[
'15:30',
'midtown_mara',
'sealed from 38th, the crowd is all phones',
'witness',
],
['15:51', 'deli_guy_dan', 'sold out of water lol'],
['16:20', 'murrayhill_mo', 'how long has it been'],
['16:24', 'kvn_on_34th', '16 min since I saw them', 'first-eyes'],
['16:50', 'ess_ay_dee', '❤️❤️'],
['17:15', 'brklyn_bea', 'they are moving'],
['17:30', 'lena.ktown', 'coming DOWN', 'witness'],
['17:32', 'midtown_mara', 'YES'],
['17:34', 'deli_guy_dan', 'thank god'],
['17:36', 'ess_ay_dee', '❤️❤️❤️'],
['17:55', 'throwaway_tom', 'slowly. slowly'],
['18:30', 'jules_flatiron', 'first one at the rail'],
['18:58', 'penn_plaza_40th', 'officers reaching over the rail', 'witness'],
['19:20', 'kvn_on_34th', 'ONE IS IN', 'first-eyes'],
['19:22', 'brklyn_bea', '😭😭'],
['19:24', 'midtown_mara', 'ok im crying'],
['19:40', 'lena.ktown', 'SECOND ONE IN', 'witness'],
['19:42', 'deli_guy_dan', 'LETS GOOO'],
['19:43', 'ess_ay_dee', '❤️🔥❤️🔥'],
['19:44', 'murrayhill_mo', 'the street is clapping'],
[
'19:45',
'rooftop_rafa',
'sealed from murray hill, both over the rail',
'witness',
],
['19:50', 'hellsk1tchen', 'cheering from the high line too'],
['20:10', 'throwaway_tom', 'they are so arrested'],
[
'20:40',
'nyc_scanner_nerd',
'scanner: two in custody, no injuries reported',
'local',
],
['21:00', 'avgeek_jfk', 'SKY-4 departing east, next story', 'pilot'],
['21:10', 'deli_guy_dan', 'sealed one of SKY-4 leaving', 'witness'],
['21:35', 'jules_flatiron', 'what a day for midtown'],
['22:10', 'midtown_mara', 'the replay of this is gonna be wild'],
['22:40', 'brklyn_bea', 'thank you everyone for the updates'],
[
'23:15',
'heli_dispatch_ret',
'NEWS-7 and PD-14 still up. will hold till the street clears',
'pilot',
],
['24:05', 'murrayhill_mo', 'traffic on 5th still stopped'],
[
'24:40',
'kvn_on_34th',
'cant believe I just looked up at the right time',
'first-eyes',
],
];
/* ── Record lane: posts ─────────────────────────────────────────── */
const POSTS = [
[
'0:58',
'kvn_on_34th',
'witness',
'Two people outside the safety fence on the 86th floor setback, north face. Sealed photo from W 34th St & 6th Ave, 22° zoom.',
412,
],
[
'2:40',
'midtown_mara',
'question',
'Is the observation deck being cleared? Is anyone still up there?',
88,
],
[
'3:30',
'nyc_scanner_nerd',
'context',
'Police aviation is on scene, orbiting at about 1,100 ft. News helicopters usually follow within minutes.',
230,
],
[
'6:15',
'penn_plaza_40th',
'witness',
'From 1 Penn Plaza, 40th floor: both are on the mooring mast, east side, about halfway up. Sealed clip.',
377,
],
[
'7:05',
'avgeek_jfk',
'correction',
'The "drone" in clips around 6:30 is SKY-4 lifting out of the East 34th St heliport. Its track matches.',
156,
],
[
'7:50',
'metwx_paul',
'context',
'Wind at 1,000–1,500 ft is about 15 kt from the WNW. That is why every orbit looks stretched toward the East River.',
141,
],
[
'10:15',
'heli_dispatch_ret',
'context',
'Three helicopters holding 1,000, 1,250 and 1,450 ft in left-hand orbits. Staggered altitudes are normal deconfliction.',
264,
],
[
'12:25',
'lena.ktown',
'witness',
'Rooftop in Koreatown: officers visible on the 102nd floor deck directly below the climbers. Sealed.',
198,
],
[
'14:55',
'jules_flatiron',
'correction',
"There is no third climber. The figure on the setback is the mast's shadow: compare the 5th Ave and High Line captures, taken 6 minutes apart.",
301,
],
['16:10', 'brklyn_bea', 'question', 'Has anyone been hurt?', 64],
[
'19:50',
'rooftop_rafa',
'witness',
'Both are back over the rail at the 102nd floor and officers have them. Sealed clip from Murray Hill.',
520,
],
[
'20:45',
'nyc_scanner_nerd',
'context',
'Scanner traffic: two in custody, no injuries reported. Unconfirmed until an official statement.',
187,
],
];
/* ── Beats (for timeline pips) ──────────────────────────────────── */
const BEATS = [
['0:52', 'First sealed capture: two figures outside the 86th floor fence'],
['2:10', 'Police helicopter on scene'],
['4:30', 'NEWS-7 arrives'],
['5:10', 'Climbers start up the mast'],
['7:25', 'SKY-4 joins: three helicopters orbiting'],
['9:40', 'One climber at the top of the mast'],
['12:20', 'Officers on the 102nd floor deck'],
['17:30', 'Climbers start down'],
['19:40', 'Both back over the rail'],
['21:00', 'SKY-4 departs'],
];
/* ── Cinematic camera path, keyed to time ───────────────────────── */
// Each key: bearing from the building, ground range, the camera's pitch
// down to the look point, and the look point's height (default the deck).
function camKey(
mmss,
bearingFromEsb,
rangeM,
pitchDownDeg,
lookAltM = DECK_86,
) {
const heightM = Math.round(lookAltM + Math.tan(pitchDownDeg * D2R) * rangeM);
const p = near(ESB, rangeM, bearingFromEsb);
const [x, y] = toXY(p);
const heading = bearingXY(-x, -y);
const pitch = Math.atan2(lookAltM - heightM, Math.hypot(x, y)) / D2R;
return {
t: at(mmss),
lon: Math.round(p.lon * 1e6) / 1e6,
lat: Math.round(p.lat * 1e6) / 1e6,
height: heightM,
heading: Math.round(heading * 10) / 10,
pitch: Math.round(pitch * 10) / 10,
};
}
const cameraPath = [
camKey('0:00', 212, 4200, 30, 150),
camKey('1:00', 218, 3200, 30, 200),
camKey('2:00', 228, 2300, 31, 260),
camKey('3:30', 248, 1800, 32),
camKey('5:00', 278, 1550, 31, ROOF),
camKey('6:30', 308, 1450, 30, ROOF),
camKey('8:00', 338, 1350, 29, ROOF),
camKey('9:40', 6, 1150, 26, ROOF + 20),
camKey('11:30', 38, 1350, 29, ROOF),
camKey('13:30', 72, 1650, 32),
camKey('15:30', 108, 1900, 34),
camKey('17:30', 142, 1600, 31, ROOF),
camKey('19:40', 172, 1300, 27, DECK_102),
camKey('21:30', 198, 2000, 31),
camKey('23:30', 216, 3000, 31, 220),
camKey('25:00', 224, 4000, 30, 150),
];
/* ── Assemble ───────────────────────────────────────────────────── */
const jitter = (t, s) => t + Math.round(gauss() * s * 1000);
const chat = CHAT.map(([mmss, handle, text, badge]) => ({
t: jitter(at(mmss), 1.2),
handle,
text,
...(badge ? { badge } : {}),
})).sort((a, b) => a.t - b.t);
const posts = POSTS.map(([mmss, handle, kind, text, votes], i) => ({
id: `p${i + 1}`,
t: at(mmss),
handle,
kind,
text,
votes,
...(captures.find(
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
)
? {
captureId: captures.find(
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
).id,
}
: {}),
}));
const moment = {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
lon: ESB.lon,
lat: ESB.lat,
time: { start: T0, end: T0 + DURATION_S * 1000 },
illustrative: true,
illustrativeNote:
'Illustrative reconstruction. The event, aircraft, callsigns, people, chat and posts are fictional; the place, building heights and heliports are real.',
recommendedSpeed: 30,
groundMslM: GROUND_MSL,
subject: {
lon: ESB.lon,
lat: ESB.lat,
altM: DECK_86,
label: 'Empire State Building',
roofM: ROOF,
antennaM: ANTENNA,
decks: [DECK_86, DECK_102],
},
stage: {
buildings: 'midtown-buildings.json',
water: 'midtown-water.json',
highlight: '1015862',
},
wind: WIND,
cameraPath,
beats: BEATS.map(([mmss, title]) => ({ t: at(mmss), title })),
tracks,
captures,
chat,
posts,
sources: [
{
name: 'Illustrative reconstruction',
detail:
'Helicopter tracks are synthetic, from a flight model (constant airspeed, 14 kt wind, rate-limited turns) shaped like real news and police orbits. Callsigns and tail numbers are fictional and are not real registrations.',
},
{
name: 'NYC Open Data · Building Footprints',
detail:
'Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue).',
},
{
name: 'OpenStreetMap water polygons',
detail: 'Hudson and East River surfaces (ODbL, via OpenFreeMap).',
},
{
name: 'Esri World Imagery',
detail: 'Base imagery of the real place.',
},
{
name: 'Simulated Live and Record lanes',
detail:
'Chat, posts, handles and witness captures are simulated to show how a room forms around an event.',
},
],
};
await mkdir(path.join(HERE, 'moments'), { recursive: true });
const file = path.join(HERE, 'moments', 'empire-state.json');
await writeFile(file, await prettyJson(moment));
console.log(
`wrote ${file}: ${tracks.map((t) => `${t.id} ${t.points.length} pts`).join(', ')}, ` +
`${captures.length} captures, ${chat.length} chat, ${posts.length} posts`,
);
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env node
/**
* Builds mobile/demo/midtown-water.json: the Hudson and East River around
* the Empire State Moment, from OpenStreetMap water polygons (OpenFreeMap
* vector tiles, OpenMapTiles schema, ODbL).
*
* Why: Esri World Imagery stitches two different water sources across the
* Hudson, and from a low oblique camera the seam reads as a black-and-olive
* staircase. The stage paints these real water polygons over the imagery so
* the rivers read as one calm dark surface.
*
* node mobile/demo/build-midtown-water.mjs
*
* Output: { origin:[lon,lat], source, fetchedAt, polys:[[[x0,y0,…], hole…]] }
* with integer metres east/north of `origin`.
*/
import { writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
const vt = await import(require.resolve('@mapbox/vector-tile'));
const VectorTile = vt.VectorTile || vt.default?.VectorTile;
const pbfMod = await import(require.resolve('pbf'));
const Pbf = pbfMod.default?.default || pbfMod.default || pbfMod.PbfReader;
const ORIGIN = [-73.9857, 40.7484];
const BBOX = { west: -74.08, east: -73.88, south: 40.68, north: 40.82 };
const Z = 13;
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
const ky = 110_574;
async function prettyJson(value) {
const text = JSON.stringify(value);
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return `${text}\n`;
}
}
const tileX = (lon) => Math.floor(((lon + 180) / 360) * 2 ** Z);
const tileY = (lat) => {
const r = (lat * Math.PI) / 180;
return Math.floor(
((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** Z,
);
};
const meta = await (await fetch('https://tiles.openfreemap.org/planet')).json();
const template = meta.tiles[0];
const polys = [];
for (let x = tileX(BBOX.west); x <= tileX(BBOX.east); x++)
for (let y = tileY(BBOX.north); y <= tileY(BBOX.south); y++) {
const url = template.replace('{z}', Z).replace('{x}', x).replace('{y}', y);
const r = await fetch(url);
if (!r.ok) throw new Error(`tile ${x}/${y} HTTP ${r.status}`);
const tile = new VectorTile(new Pbf(new Uint8Array(await r.arrayBuffer())));
const water = tile.layers.water;
if (!water) continue;
for (let i = 0; i < water.length; i++) {
const gj = water.feature(i).toGeoJSON(x, y, Z);
const list =
gj.geometry.type === 'Polygon'
? [gj.geometry.coordinates]
: gj.geometry.type === 'MultiPolygon'
? gj.geometry.coordinates
: [];
for (const rings of list) {
const out = rings.map((ring) => {
const flat = [];
let px = null;
let py = null;
for (const [lon, lat] of ring) {
const mx = Math.round((lon - ORIGIN[0]) * kx);
const my = Math.round((lat - ORIGIN[1]) * ky);
if (px !== null && Math.abs(mx - px) < 8 && Math.abs(my - py) < 8)
continue;
flat.push(mx, my);
px = mx;
py = my;
}
return flat;
});
// Drop ponds: only rivers, bays and harbour (big outer rings).
const o = out[0];
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (let k = 0; k < o.length; k += 2) {
minX = Math.min(minX, o[k]);
maxX = Math.max(maxX, o[k]);
minY = Math.min(minY, o[k + 1]);
maxY = Math.max(maxY, o[k + 1]);
}
if (o.length >= 6 && maxX - minX + (maxY - minY) > 600)
polys.push(out.filter((ring) => ring.length >= 6));
}
}
}
const doc = {
origin: ORIGIN,
source:
'OpenStreetMap contributors (ODbL) via OpenFreeMap vector tiles, water layer',
fetchedAt: new Date().toISOString(),
count: polys.length,
polys,
};
const file = path.join(HERE, 'midtown-water.json');
await writeFile(file, await prettyJson(doc));
console.log(`wrote ${polys.length} water polygons → ${file}`);
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env node
/**
* Builds mobile/demo/midtown-buildings.json: real building massing for the
* Empire State Moment, from NYC Open Data "Building Footprints" (DoITT,
* dataset 5zhs-2jue, roof heights from photogrammetry). Every building
* within 1.6 km of the Empire State Building whose roof is over 60 ft, and
* towers over 200 ft out to 2.6 km (so the massing thins out instead of
* ending at a hard circle).
*
* node mobile/demo/build-midtown.mjs [path/to/cached.json]
*
* Output: { origin:[lon,lat], source, fetchedAt, b:[[roofM, groundM, x0,y0,x1,y1,…]] }
* where x/y are integer metres east/north of `origin` (outer ring only,
* Douglas-Peucker simplified to 1 m).
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
async function prettyJson(value) {
const text = JSON.stringify(value);
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return `${text}\n`;
}
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ORIGIN = [-73.9857, 40.7484];
const FT = 0.3048;
async function load() {
const cached = process.argv[2];
if (cached) return JSON.parse(await readFile(cached, 'utf8'));
const url = new URL('https://data.cityofnewyork.us/resource/5zhs-2jue.json');
url.searchParams.set(
'$select',
'the_geom,height_roof,ground_elevation,bin,construction_year',
);
url.searchParams.set(
'$where',
`within_circle(the_geom,${ORIGIN[1]},${ORIGIN[0]},2600) AND height_roof > 60`,
);
url.searchParams.set('$limit', '20000');
const r = await fetch(url);
if (!r.ok) throw new Error(`NYC Open Data HTTP ${r.status}`);
return r.json();
}
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
const ky = 110_574;
const toXY = ([lon, lat]) => [(lon - ORIGIN[0]) * kx, (lat - ORIGIN[1]) * ky];
function simplify(pts, tol) {
if (pts.length < 4) return pts;
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [a, b] = stack.pop();
const [ax, ay] = pts[a];
const [bx, by] = pts[b];
const dx = bx - ax;
const dy = by - ay;
const len = Math.hypot(dx, dy) || 1e-9;
let best = -1;
let bestD = tol;
for (let i = a + 1; i < b; i++) {
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
if (d > bestD) {
bestD = d;
best = i;
}
}
if (best > 0) {
keep[best] = 1;
stack.push([a, best], [best, b]);
}
}
return pts.filter((_, i) => keep[i]);
}
const rows = await load();
const out = [];
for (const row of rows) {
const roof = Number(row.height_roof) * FT;
if (!Number.isFinite(roof) || roof < 18) continue;
const ground = Number(row.ground_elevation) * FT;
const polys = row.the_geom?.coordinates || [];
// The largest outer ring stands for the building.
let ring = null;
for (const poly of polys) {
const r = poly[0];
if (r && (!ring || r.length > ring.length)) ring = r;
}
if (!ring || ring.length < 4) continue;
let pts = ring.map(toXY);
const [fx, fy] = pts[0];
const dist = Math.hypot(fx, fy);
if (dist > 2600) continue;
// Beyond 1.6 km only towers, thinning out with distance.
if (dist > 1600 && roof < 61 + ((dist - 1600) / 1000) * 60) continue;
// Open ring: split at the farthest vertex so both halves simplify cleanly.
pts = pts.slice(0, -1);
let far = 1;
for (let i = 1; i < pts.length; i++)
if (
Math.hypot(pts[i][0] - pts[0][0], pts[i][1] - pts[0][1]) >
Math.hypot(pts[far][0] - pts[0][0], pts[far][1] - pts[0][1])
)
far = i;
pts = [
...simplify(pts.slice(0, far + 1), 1.0),
...simplify([...pts.slice(far), pts[0]], 1.0).slice(1, -1),
];
if (pts.length < 3) continue;
const flat = [];
for (const [x, y] of pts) flat.push(Math.round(x), Math.round(y));
out.push([
Math.round(roof * 10) / 10,
Number.isFinite(ground) ? Math.round(ground * 10) / 10 : 15,
...flat,
]);
}
out.sort((a, b) => a[0] - b[0]);
const doc = {
origin: ORIGIN,
source:
'NYC Open Data · Building Footprints (NYC OTI/DoITT, 5zhs-2jue), roof heights from photogrammetry',
fetchedAt: new Date().toISOString(),
count: out.length,
b: out,
};
const file = path.join(HERE, 'midtown-buildings.json');
await writeFile(file, await prettyJson(doc));
console.log(`wrote ${out.length} buildings → ${file}`);
-388
View File
@@ -1,388 +0,0 @@
#!/usr/bin/env node
/**
* Captures a bundled flight-history recording for the time machine
* (DESIGN.md §12.5). For N minutes it polls the real feeds the engine uses
* (adsb.lol point snapshots around each region's anchors, the dev server's
* /api/flights as a fallback and its /api/military), keeps the busiest
* regions, and writes one file per region to mobile/demo/history/<region>.json.
*
* npx vite --port 4191 --strictPort # a dev server, for the fallbacks
* node mobile/demo/capture-history.mjs [--base http://localhost:4191]
* [--minutes 150] [--poll 20] [--budget 4000000] [--out dir]
* (behind an HTTPS proxy: NODE_USE_ENV_PROXY=1 node …)
*
* The engine's normalisation (flightFromState, classifyAircraft) is used, and the
* same sampling as the recorder's persisted history: one fix per 30 s for
* low traffic, one per 120 s for cruise. The file keeps the real capture
* timestamps; the engine never shifts them.
*
* File format (quantised, delta-encoded):
* { format: 'gev-history/1', region, label, bbox, capturedAt: { start, end },
* source, t0 (epoch s), tracks: [{ k, m, p }] }
* k: recorder key (icao24 / hex) m: recorder meta (callsign, type…)
* p: comma-separated ints, 6 per sample, deltas from the previous sample:
* dt (s) · dlon (1e-4°) · dlat (1e-4°) · dalt (10 m) · heading (°, absolute)
* · speed (m/s, absolute)
* Written every 5 minutes, so an interrupted run still leaves a usable file.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { flightFromState } from '../globe/feeds.js';
import { classifyAircraft } from '../../src/data/aircraftClass.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const args = Object.fromEntries(
process.argv
.slice(2)
.join(' ')
.split('--')
.filter(Boolean)
.map((kv) => {
const [k, ...v] = kv.trim().split(/\s+/);
return [k, v.join(' ') || true];
}),
);
const BASE = args.base || 'http://localhost:4191';
const MINUTES = Number(args.minutes || 150);
const POLL_S = Number(args.poll || 20);
const BUDGET = Number(args.budget || 4_000_000);
const OUT = args.out ? path.resolve(args.out) : path.join(HERE, 'history');
const REGIONS = [
{
id: 'nyc',
label: 'New York area',
bbox: { west: -76.5, south: 39.4, east: -71.2, north: 42.2 },
anchors: [{ lat: 40.75, lon: -73.98 }],
},
{
id: 'london',
label: 'London and the North Sea approaches',
bbox: { west: -2.6, south: 50.2, east: 5.6, north: 53.6 },
anchors: [
{ lat: 51.51, lon: -0.12 },
{ lat: 52.2, lon: 4.2 },
],
},
{
id: 'gulf',
label: 'Gulf of Mexico',
bbox: { west: -98.5, south: 23.5, east: -81.5, north: 31.2 },
anchors: [
{ lat: 27.5, lon: -90 },
{ lat: 29.6, lon: -95.2 },
{ lat: 27.9, lon: -83.5 },
],
},
];
const CRUISE_ALT_M = 6000;
const LOW_GAP_S = 30;
const CRUISE_GAP_S = 120;
const inBox = (b, lon, lat) =>
lat >= b.south && lat <= b.north && lon >= b.west && lon <= b.east;
/** region id → Map(key → { meta, pts: [[t, lon, lat, alt, hdg, spd]] }) */
const store = new Map(REGIONS.map((r) => [r.id, new Map()]));
let firstAt = Infinity;
let lastAt = -Infinity;
const sources = new Set();
function add(region, key, meta, s) {
const tracks = store.get(region.id);
let tr = tracks.get(key);
if (!tr) {
tr = { meta, pts: [] };
tracks.set(key, tr);
} else tr.meta = { ...tr.meta, ...meta };
const last = tr.pts[tr.pts.length - 1];
const gap = s[3] > CRUISE_ALT_M ? CRUISE_GAP_S : LOW_GAP_S;
if (last && s[0] - last[0] < gap) return;
tr.pts.push(s);
if (s[0] < firstAt) firstAt = s[0];
if (s[0] > lastAt) lastAt = s[0];
}
function ingest(records) {
const nowS = Date.now() / 1000;
for (const r of records) {
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
if (r.onGround && (r.speed ?? 0) < 8) continue;
const t = Math.floor(
Math.min(r.seenAt ?? Date.now(), Date.now() + 5000) / 1000,
);
if (nowS - t > 15 * 60) continue;
const region = REGIONS.find((g) => inBox(g.bbox, r.lon, r.lat));
if (!region) continue;
const meta = {
kind: r.military ? 'military' : r.helicopter ? 'helicopter' : 'aircraft',
layer: r.military ? 'military' : 'flights',
callsign: r.callsign || undefined,
...(r.registration ? { registration: r.registration } : {}),
...(r.type ? { type: r.type } : {}),
...(r.country ? { country: r.country } : {}),
...(r.squawk ? { squawk: r.squawk } : {}),
...(r.helicopter ? { helicopter: true } : {}),
...(r.military ? { military: true } : {}),
...(r.ga ? { ga: true } : {}),
...(r.pia ? { pia: true } : {}),
...(r.ladd ? { ladd: true } : {}),
};
add(region, String(r.id), meta, [
t,
r.lon,
r.lat,
Math.round(r.altM ?? 0),
Math.round(r.heading ?? 0),
Math.round(r.speed ?? 0),
]);
}
}
async function getJson(url, headers = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 30_000);
try {
const res = await fetch(url, { signal: ctl.signal, headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { json: await res.json(), headers: res.headers };
} finally {
clearTimeout(timer);
}
}
const ADSBLOL = 'https://api.adsb.lol/v2';
const UA = { 'User-Agent': 'gods-eye-view-history-capture/1.0' };
/** adsb.lol v2 aircraft → the engine's flight record shape. */
function fromAdsbLol(a, nowMs) {
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) return null;
const onGround = a.alt_baro === 'ground';
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
const cls = classifyAircraft({ typeCode: a.t, category: a.category });
const military = Boolean(a.dbFlags & 1);
return {
id: String(a.hex).replace(/^~/, ''),
callsign: String(a.flight || a.r || a.hex).trim(),
registration: a.r || '',
type: a.t || '',
lon: a.lon,
lat: a.lat,
altM: onGround
? 0
: Math.max(0, (Number.isFinite(altFt) ? altFt : 15000) * 0.3048),
onGround,
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
heading: a.track ?? a.true_heading ?? 0,
squawk: a.squawk || null,
helicopter: cls === 'helicopter',
military,
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt: nowMs - (a.seen_pos ?? a.seen ?? 0) * 1000,
};
}
/**
* Each region's anchors straight from adsb.lol (250 nm point queries, the
* same source the dev server falls back to); an anchor that fails is asked
* through the dev server instead.
*/
async function pollFlights() {
let n = 0;
for (const g of REGIONS) {
for (const a of g.anchors) {
// Polite spacing: adsb.lol answers 429 to bursts.
await new Promise((r) => setTimeout(r, 1500));
try {
const { json } = await getJson(
`${ADSBLOL}/lat/${a.lat}/lon/${a.lon}/dist/250`,
UA,
);
const now = Number.isFinite(json.now) ? json.now : Date.now();
const recs = (json.ac || [])
.map((x) => fromAdsbLol(x, now))
.filter(Boolean);
ingest(recs);
n += recs.length;
sources.add('adsb.lol (ODbL)');
continue;
} catch (error) {
console.warn(`[adsb.lol ${g.id}]`, error.message);
}
try {
const { json } = await getJson(
`${BASE}/api/flights?lat=${a.lat}&lon=${a.lon}`,
);
const recs = (json.states || []).map(flightFromState).filter(Boolean);
ingest(recs);
n += recs.length;
sources.add('God’s Eye dev server (OpenSky / adsb.lol)');
} catch (error) {
console.warn(`[flights ${g.id}]`, error.message);
}
}
}
return n;
}
async function pollMilitary() {
try {
const { json } = await getJson(`${BASE}/api/military`);
const recs = [];
for (const a of json.ac || []) {
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) continue;
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
recs.push({
id: String(a.hex),
callsign: String(a.flight || a.r || a.hex).trim(),
registration: a.r || '',
type: a.t || '',
lon: a.lon,
lat: a.lat,
altM:
a.alt_baro === 'ground' ? 0 : Math.max(0, (altFt ?? 15000) * 0.3048),
onGround: a.alt_baro === 'ground',
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
heading: a.track ?? a.true_heading ?? 0,
squawk: a.squawk || null,
military: true,
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt: Date.now() - (a.seen_pos ?? a.seen ?? 0) * 1000,
});
}
ingest(recs);
sources.add('adsb.lol military (ODbL)');
return recs.length;
} catch (error) {
console.warn('[military]', error.message);
return 0;
}
}
function encode(region, cruiseGap) {
const tracks = [];
const t0 = Number.isFinite(firstAt) ? firstAt : Math.floor(Date.now() / 1000);
for (const [k, tr] of store.get(region.id)) {
if (tr.pts.length < 2) continue;
const p = [];
let prev = [t0, 0, 0, 0];
let keptT = -Infinity;
tr.pts.forEach((s, i) => {
const isLast = i === tr.pts.length - 1;
if (
cruiseGap > CRUISE_GAP_S &&
s[3] > CRUISE_ALT_M &&
!isLast &&
s[0] - keptT < cruiseGap
)
return;
keptT = s[0];
const lon = Math.round(s[1] * 1e4);
const lat = Math.round(s[2] * 1e4);
const alt = Math.round(s[3] / 10);
p.push(
s[0] - prev[0],
lon - prev[1],
lat - prev[2],
alt - prev[3],
s[4],
s[5],
);
prev = [s[0], lon, lat, alt];
});
// A string, so the file stays compact under Prettier.
tracks.push({ k, m: tr.meta, p: p.join(',') });
}
return {
format: 'gev-history/1',
region: region.id,
label: region.label,
bbox: region.bbox,
capturedAt: {
start: new Date(firstAt * 1000).toISOString(),
end: new Date(lastAt * 1000).toISOString(),
},
source: [...sources].join(' · '),
note: 'Real recorded positions, captured through the God’s Eye dev server. Timestamps are the true capture times and are never shifted.',
t0,
tracks,
};
}
/** Prettier-clean JSON (the repo checks mobile/ with Prettier). */
async function pretty(text) {
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return text + '\n';
}
}
async function save() {
await mkdir(OUT, { recursive: true });
let gap = CRUISE_GAP_S;
for (;;) {
const files = REGIONS.map((g) => [g, JSON.stringify(encode(g, gap))]);
const bytes = files.reduce((n, [, s]) => n + s.length, 0);
if (bytes > BUDGET && gap < 300) {
gap += 60;
continue;
}
for (const [g, text] of files)
await writeFile(path.join(OUT, `${g.id}.json`), await pretty(text));
const index = {
format: 'gev-history-index/1',
capturedAt: {
start: new Date(firstAt * 1000).toISOString(),
end: new Date(lastAt * 1000).toISOString(),
},
regions: REGIONS.map((g, i) => ({
id: g.id,
label: g.label,
bbox: g.bbox,
file: `${g.id}.json`,
tracks: JSON.parse(files[i][1]).tracks.length,
})),
};
await writeFile(
path.join(OUT, 'index.json'),
await pretty(JSON.stringify(index)),
);
console.log(
`[save] ${new Date().toISOString()} span ${Math.round((lastAt - firstAt) / 60)} min, ${Math.round(bytes / 1024)} KB (cruise gap ${gap}s)`,
);
return;
}
}
const until = Date.now() + MINUTES * 60_000;
let polls = 0;
let lastSave = Date.now();
let lastMil = 0;
console.log(`capturing ${MINUTES} min from ${BASE} → ${OUT}`);
while (Date.now() < until) {
const started = Date.now();
const n = await pollFlights();
if (Date.now() - lastMil > 40_000) {
lastMil = Date.now();
await pollMilitary();
}
polls++;
if (polls % 15 === 1)
console.log(
`[poll ${polls}] ${n} aircraft; tracks ${REGIONS.map((g) => `${g.id}:${store.get(g.id).size}`).join(' ')}`,
);
if (Date.now() - lastSave > 5 * 60_000) {
lastSave = Date.now();
await save().catch((e) => console.warn('[save]', e.message));
}
const wait = POLL_S * 1000 - (Date.now() - started);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
}
await save();
console.log('done');
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env node
/**
* Regenerates the bundled first-frame snapshot (DESIGN.md §12.8):
*
* mobile/demo/snapshot.json the live situations() result, with capturedAt
* mobile/demo/thumbs/<id>.jpg each card's composited thumbnail
*
* It drives the real engine in headless Chromium against a running dev
* server, so every card is real data at capture time.
*
* npm run dev # or: npx vite --port 4173
* node mobile/demo/capture-snapshot.mjs [--base http://localhost:4173]
* [--chrome /path/to/chrome] [--max 28] [--quality 0.8]
* [--lat 40.75 --lon -73.98] # a viewer position for distances
*
* Budget: thumbnails are 360×480 CSS px at 1.25× and JPEG-recompressed so
* the whole folder stays under ~3 MB.
*/
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
const args = Object.fromEntries(
process.argv
.slice(2)
.join(' ')
.split('--')
.filter(Boolean)
.map((kv) => {
const [k, ...v] = kv.trim().split(/\s+/);
return [k, v.join(' ') || true];
}),
);
const BASE = args.base || 'http://localhost:4173';
const MAX = Number(args.max || 28);
const QUALITY = Number(args.quality || 0.8);
const BUDGET = 3 * 1024 * 1024;
const puppeteer = (await import(require.resolve('puppeteer'))).default;
const browser = await puppeteer.launch({
executablePath: args.chrome || process.env.CHROME_PATH || undefined,
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--no-sandbox',
],
protocolTimeout: 600_000,
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 430, height: 932, deviceScaleFactor: 1 });
page.on('pageerror', (e) => console.warn('[page]', e.message));
await page.goto(`${BASE}/mobile/globe/dev.html?hud=0&labels=0`, {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.waitForFunction('window.globe', { timeout: 120_000 });
console.log('engine up; building live situations (this takes ~30–60 s)…');
const viewer =
args.lat && args.lon
? { lat: Number(args.lat), lon: Number(args.lon) }
: null;
const result = await page.evaluate(
async ({ max, quality, viewer }) => {
if (viewer) globe.setViewer(viewer);
// Warm the recorder a little so detections have a chance to show.
const list = (await globe.situations()).slice(0, max);
const out = [];
for (const s of list) {
let thumb = null;
try {
const url = await globe.thumbnail(s, {
width: 360,
height: 480,
dpr: 1.25,
});
const img = await createImageBitmap(await (await fetch(url)).blob());
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
const blob = await new Promise((r) =>
c.toBlob(r, 'image/jpeg', quality),
);
const buf = new Uint8Array(await blob.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 0x8000)
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
thumb = btoa(bin);
} catch (error) {
console.warn('thumbnail failed', s.id, error?.message);
}
out.push({ situation: JSON.parse(JSON.stringify(s)), thumb });
}
return out;
},
{ max: MAX, quality: QUALITY, viewer },
);
const thumbsDir = path.join(HERE, 'thumbs');
await mkdir(thumbsDir, { recursive: true });
for (const f of await readdir(thumbsDir))
if (f.endsWith('.jpg')) await rm(path.join(thumbsDir, f));
let bytes = 0;
const situations = [];
for (const { situation, thumb } of result) {
const file = `${String(situation.id).replace(/[^\w.-]+/g, '_')}.jpg`;
if (thumb) {
const buf = Buffer.from(thumb, 'base64');
if (bytes + buf.length > BUDGET) {
console.warn(`budget reached; ${situation.id} ships without a thumb`);
} else {
bytes += buf.length;
await writeFile(path.join(thumbsDir, file), buf);
situation.thumbFile = `thumbs/${file}`;
}
}
situations.push(situation);
}
const snapshot = {
capturedAt: new Date().toISOString(),
source:
'Live God’s Eye feeds through the dev server (see each card’s source)',
note: 'Bundled first frame for demos. The shell shows capturedAt and replaces these with live cards as soon as situations() resolves.',
situations,
};
let text = JSON.stringify(snapshot);
try {
const prettier = await import('prettier');
text = await prettier.format(text, { parser: 'json' });
} catch {
text += '\n';
}
await writeFile(path.join(HERE, 'snapshot.json'), text);
console.log(
`wrote ${situations.length} situations, ${Math.round(bytes / 1024)} KB of thumbnails`,
);
} finally {
await browser.close();
}
File diff suppressed because it is too large. Load diff
-30
View File
@@ -1,30 +0,0 @@
{
"format": "gev-history-index/1",
"capturedAt": {
"start": "2026-10-23T08:25:37.000Z",
"end": "2026-10-23T11:06:30.000Z"
},
"regions": [
{
"id": "nyc",
"label": "New York area",
"bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 },
"file": "nyc.json",
"tracks": 525
},
{
"id": "london",
"label": "London and the North Sea approaches",
"bbox": { "west": -2.6, "south": 50.2, "east": 5.6, "north": 53.6 },
"file": "london.json",
"tracks": 1559
},
{
"id": "gulf",
"label": "Gulf of Mexico",
"bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 },
"file": "gulf.json",
"tracks": 493
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

-319
View File
@@ -1,319 +0,0 @@
# Globe API: round-two additions
These additions extend DESIGN.md §10 and change no existing signature. Import them from `mobile/globe/index.js`. The doc comment at the top of that file repeats this contract.
## Viewer position
```js
globe.setViewer({ lon, lat } | null) // where the person is: sets distanceKm and the ISS/Starlink "over you" cards
globe.viewer() → { lon, lat } | null
```
## Situations (extended)
```js
globe.situations({ observer? }) → Promise<Situation[]>
globe.omitted() → [{ genre, reason }] // genres the last call left out, and why
globe.timings() → { [genre]: ms } // per-genre build time of the last call
```
`Situation` keeps every field from round one (`id, kind, family, section, title, subtitle, place, lon, lat, camera, layers, time?, metrics, thumbnail, live, updatedAt, source, severity`) and adds these:
| Field | Type | Notes |
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `band` | `'orbit' \| 'sky' \| 'ground' \| 'inside'` | The altitude band of the sensor. The engine never emits `inside`, because only the shell's own witness and room cards are inside. |
| `archetype` | `'sensor' \| 'eye' \| 'track' \| 'number'` | The card layout. The shell adds `witness` and `room` cards itself. |
| `telemetry` | `string` | A short uppercase strip, for example `SKY · FL206 · 332 KT · SQ 7700 · EZY58XM`, `GROUND · CAM 07450 · PICCADILLY CIRCUS · 02:16:14Z` or `ORBIT · ISS · 433 KM · 7.65 KM/S`. Draw it in HTML in a monospace face. |
| `media?` | `{ kind:'image', url, refreshMs, cameraId, headingDeg?, provider?, city? }` | Eye cards only. `url` is a same-origin frame from `/api/cctv/frame/…` that was checked to be a real upstream image, not the proxy's synthetic fallback. For a fresh frame, call `globe.eyeFrameUrl(cameraId)` every `refreshMs`. |
| `series?` | `[{ t, v }]` | Real history for number cards, and for some sensor cards. `t` is epoch ms. |
| `seriesLabel?` | `string` | The quantity `v` measures, for example `'lightning-active area (km²) per 15 min'`. |
| `headline?` | `{ value, unit, label }` | The one big number on a track or number card. |
| `distanceKm?` | `number` | The great-circle distance from the viewer. It is present only once `setViewer` has been called, or when `observer` is passed. |
The `thumbnail` field is still a plain Esri still, so older code keeps working. New code calls `globe.thumbnail(situation)` instead.
The engine orders the cards by severity, then interleaves them so neighbouring cards differ in band and archetype where possible.
## Card compositor
```js
globe.thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) → Promise<string /* blob: object URL */>
```
The compositor builds each thumbnail as a JPEG in an offscreen 2D canvas, never in the Cesium viewer. It works in three layers:
1. **Base imagery.** Esri World Imagery tiles, which are CORS-clean, or NASA GIBS Black Marble for orbit cards.
2. **Real overlays.** The data that describes the situation. Examples include NOAA IR with a cold-top enhancement, MRMS radar, lightning density dots by age, the NHC cone with the forecast track and 34/50/64-kt wind radii, aircraft with 2-minute velocity leaders, quake rings, fires with a smoke plume along the observed wind, and SGP4 ground tracks.
3. **Grade.** A tint for the altitude band (orbit violet night, sky cool, ground amber) and a vignette.
The compositor draws no text and no brackets.
Eye cards return the camera frame itself, lightly graded. Results are cached in memory and, when the page is a secure context, in the Cache API, keyed by id, size and an update bucket. Eye cards refresh every 30 s; other cards follow `updatedAt` in 5-minute buckets. Tiles are shared across cards, and at most 3 cards render at once.
If an overlay fails, the card still gets its base picture. The promise rejects only when the base imagery or the camera frame fails. In that case, fall back to `situation.thumbnail` or to the shell's own placeholder.
## Pins (discourse and capture)
```js
globe.addPin({ id, lon, lat, kind, headingDeg?, fovDeg = 60, rangeM = 350, color?, label?, altM? }) → id
// kind: 'post' | 'witness' | 'eye' | 'live' | 'hologram'
// 'live' and 'eye' pins draw a view-frustum wedge on the ground along headingDeg
globe.removePin(id) → boolean
globe.pins() → [{ id, lon, lat, kind, headingDeg, fovDeg, rangeM, color, label, altM }]
```
- Picking a pin emits `'pick'` with `{ kind: 'pin', pinKind, id, title, subtitle, lon, lat, meta: { layer: 'pins', headingDeg, fovDeg, label } }`.
- Pins appear in `globe.labels()` with `layer: 'pins'` and `pinKind`.
- Live pins breathe at 1 Hz.
## Labels
Each entry from `globe.labels(max)` can now carry an optional `detail`, a short intensity tag:
- Cyclones: `CAT 2 · 85 KT · 974 HPA`
- Emergency squawks: `SQ 7700`
- Air quality: `AQI 152`
## Nearest public camera
```js
globe.nearestEye(lon, lat, km = 50)
→ Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg, imageUrl, city, provider } | null>
globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
```
`nearestEye` reads the CCTV catalog at `/api/cctv/sources`, which serves about 4,000 cameras across London, Toronto, Sydney, Seattle, the Bay Area, Los Angeles, Helsinki/Finland, Tallinn, Barcelona, Calgary, Norway and other places. It does not check the frame. A camera can still answer with the proxy's synthetic image when its upstream is down.
## Weather and storm layers (now available in `globe.layers()`)
| id | Family | Source | Time |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| `radar` | Weather | NOAA MRMS base reflectivity through nowCOAST (`/api/weather`), CONUS | Scrubbing replays about 50 min of 4-minute frames |
| `clouds` | Weather | NOAA global longwave IR mosaic through nowCOAST, drawn as an unlit cloud layer (the alpha-keyed IR) | 6 h of hourly frames |
| `lightning` | Weather | NOAA nowCOAST lightning density, 15 min (Americas and Pacific), with a flickering glow per active 0.5° cell | 3 h of 15-minute frames |
| `wind` | Weather | 10 m wind, drawn with the desktop app's GPU streamline renderer. Uses `/api/wind` GFS when the server can decode GRIB, otherwise a 12° Open-Meteo grid | Live |
| `jet` | Weather | Jet stream at 250 hPa from Open-Meteo, same renderer | Live |
| `cyclones` | Hazards | Now also draws the filled NHC cone and 34/50/64-kt wind radii, parsed from the NHC TCM text at api.weather.gov | Forecast scrubbing, as before |
| `air-quality` | Hazards | Open-Meteo Air Quality (CAMS model), PM2.5 AQI for 40 major cities | Live |
| `gnss` | Infra | `/api/gnss-integrity` (adsb.lol NIC/NACp), shown as 0.5° cells in gpsjam.org bands. From orbit it samples the Baltic, Black Sea, Levant, Gulf and Gulf of Finland; close in, it samples the view | Live |
| `transit` | Ground | `/api/transit` GTFS-realtime vehicles from the open feed nearest the view | Live, 20 s |
Rasters are drawn a few kilometres above the ground and unlit, so storms stay visible on the night side. When an upstream is down, the layer reports `status: 'error'` with a `message`.
When a weather layer is on, `layers()` entries can also carry `frames` (the number of frames available) and a `message` that gives the product title and the frame age.
When NHC has published an advisory but its cone geometry is still pending, the cyclone layer and cards take the forecast positions from the advisory text, so a track is still drawn but there is no cone.
## Dev harnesses
- `mobile/globe/dev.html` shows the globe full-screen and exposes `window.globe`. Its labels show `detail`.
- `mobile/globe/contact.html` is the contact sheet: it calls `situations()`, then `thumbnail()` for every card.
- `?lat=&lon=` sets the viewer.
- `?only=cyclone,eye` filters cards by id prefix.
- `?w=330` sets the card width.
---
# Round three: Gotham for everyone (DESIGN.md §12)
Additive. Nothing above changes. Every call below exists on the object `createGlobe()` resolves to. Times are epoch ms unless a `Date` is shown.
## Instant first frame
```js
globe.situations({ cached: true }) → Promise<Situation[]>
// resolves at once from the last good IndexedDB snapshot (written by every live
// situations() call), else the bundled mobile/demo/snapshot.json (real data).
// Each cached Situation adds: cached: true, cacheOrigin: 'device' | 'bundled',
// capturedAt (epoch ms), live: false, thumbUrl (bundled /mobile/demo/thumbs/<id>.jpg
// or a Cache API blob URL) or null.
globe.situations() → Promise<Situation[]> // live, as before (also saves the cache)
globe.cachedThumbnail(situation) → string | null // sync; thumbUrl for a cached card
globe.thumbnail(cachedSituation) → resolves to the cached JPEG when the card has no live scene
```
The globe paints its first frame with imagery only. Live layers start right after it, and weather, transit, GNSS, Starlink and satellites start about a second later. Background warm-ups (jet, TCM, AQ, CCTV, rasters) start at 4 s.
## Recorder and time machine
```js
globe.history() → { start, end, entities, points, bytes } // recorded range; start/end null when empty
globe.setTime(t) // unchanged. Inside the recorded range, contacts are drawn at their interpolated
// recorded positions with 4-minute ghost trails (pickable: 'pick' kind is the
// contact's kind, meta.layer 'history', entityId). Beyond the record, dimmed as before.
globe.trackOf(entityId) → Promise<[{ t, lon, lat, altM, headingDeg, speed }]>
// recorder samples merged with /api/flights/track (OpenSky) or /api/military/track
// (adsb.lol traces); the array has .source
globe.playback.play({ speed = 60, from?, until? } = {}) → state
// speeds: 1, 10, 30, 60, 600. From live it replays the last 15 min. Reaching "now" goes live;
// inside a Moment it stops at the Moment's end.
globe.playback.stop() → state
globe.playback.state() → { playing, speed, t, live }
globe.playback.speeds // [1, 10, 30, 60, 600]
// event 'playback' → state, on start/stop and every 250 ms while playing.
// 'time' is also emitted every 250 ms while playing ({ live:false, time, playing:true }).
// Playing runs the render loop; at rest the globe is request-render again.
globe.timeline({ from, to } = {}) → [{ t, kind, band, title, severity, situationId?, entityId?, momentId?, lon?, lat? }]
// kind: 'quake' | 'quake-cluster' | 'launch' | 'squawk' | 'cyclone' | 'fire' | 'circling'
// | 'lightning' | 'moment' (a Moment beat) | 'capture' | 'post' | 'chat-burst'
// band: 'orbit' | 'sky' | 'ground' | 'inside'; severity 0..3; sorted by t.
// Defaults to timeWindow(). Moment items appear for Moments that were loaded and overlap the range.
```
The recorder keeps every poll for low traffic (helicopters, approaches, anything below about 6 km) and one fix every 50 s for cruise traffic. Samples older than 30 min are thinned. The buffer is capped at 1.2 M samples (about 24 MB) and persisted to IndexedDB about once a minute, so a reload keeps up to 6 h. When OpenSky is down, the recorder also polls adsb.lol snapshots around New York, London, Los Angeles and the viewer every 15 s, so the detector has real data.
Entity ids are layer-qualified: `flights:<icao24>`, `military:<hex>`, `satellites:<norad>`, `earthquakes:<id>`, `cctv:<id>`, `vessels:<mmsi>`, `situation:<id>`, `place:<slug>`, `moment:<momentId>`, `moment:<momentId>:<trackId>`, `capture:<momentId>:<captureId>`. Every `'pick'` payload can be passed to `entity()` as is.
## Detection
Detections run on recorder data every poll. They appear only when real data shows them.
- `circling`: two or more aircraft (rotorcraft and low-and-slow preferred, below about 8,000 ft and 155 kt), each turning through at least 360° within about 1.5 km of a common centre in the last 20 min. Card: `{ kind:'circling', band:'sky', archetype:'track', title:'3 helicopters circling Midtown Manhattan', momentId:'live-circling-…', members:[{ key, layer, callsign, helicopter }], entityIds, detected:true }`. Its thumbnail draws the recorded orbit tracks. A single aircraft on its second full orbit is a lesser detection (severity 4.5, `single: true`, for example "Helicopter circling Las Vegas Strip"). `momentOfTheDay()` ignores single orbits.
- `quake-cluster`: three or more M2.5+ earthquakes within 50 km in 24 h. Archetype `number`, with a magnitude series.
- `squawk` cards now carry `momentId: 'live-squawk-<icao24>'`.
```js
globe.detections() → Situation[] // current detections, sync
// event 'detections' → detector output, when the set of circling detections changes
```
## Moments
```js
globe.moments() → Promise<Moment[]> // live (circling, squawks) first, then the bundled hero
globe.moment(id) → Promise<Moment|null> // 'empire-state' | 'live-circling-…' | 'live-squawk-<hex>'
globe.momentOfTheDay() → Promise<Moment> // best live Moment if any, else the hero
globe.playMoment(idOrMoment, { speed, camera = true, autoplay = true, framing = 6 } = {}) → Promise<Moment>
// speed defaults to moment.recommendedSpeed (hero: 30 → 50 s). camera:false leaves the camera alone.
// framing: degrees the cinematic camera looks below the subject (puts it in the upper half).
// Hides every live layer, draws tracks (glyph, 90 s bright trail, faint flown path, drop line),
// capture view frustums that pop in at their t, the subject building and its beacon, and flies
// the camera path keyed to the clock. Touching the globe releases the camera.
globe.exitMoment({ restoreCamera = true } = {}) // back to live (or the time before) and the old camera
globe.activeMoment() → { id, title, illustrative, t, start, end, playing, speed, cinematic, moment } | null
globe.seekMoment(t) // epoch ms, or a 0..1 fraction of the Moment
globe.momentCamera(on = true) // re-attach (or release) the cinematic camera → boolean
// event 'moment' → { type, item, t, momentId }
// type: 'start' (item = Moment) | 'capture' | 'chat' | 'post' | 'beat' | 'end' | 'exit'
// | 'camera' (item = { cinematic:false } when the person takes the camera)
// Items fire as the clock passes them while playing, and on small seeks. A seek back, or a jump of
// more than 90 s while paused, repositions silently: render the Live/Record lanes by filtering
// activeMoment().moment.chat/posts/captures by t <= activeMoment().t.
```
```ts
Moment = {
id, title, place, lon, lat,
time: { start, end }, // epoch ms
illustrative: boolean, // true for the bundled hero; false for live detections
illustrativeNote?: string,
recommendedSpeed?: number,
subject?: { lon, lat, altM, label, roofM?, antennaM?, decks? },
cameraPath: [{ t, lon, lat, height /* m above ground */, heading, pitch }],
beats?: [{ t, title }],
tracks: [{ id, kind /* 'helicopter'|'aircraft' */, label, operator, callsign?, registration?, type?,
color?, entityId? /* live: 'flights:<hex>' */, points: [{ t, lon, lat, altM /* MSL */ }] }],
captures: [{ id, t, lon, lat, altM, headingDeg, pitchDeg, fovDeg, rangeM, title, handle, place,
sealed, firstEyes?, kind /* 'photo'|'clip' */, image? }],
chat: [{ t, handle, text, badge? /* 'first-eyes'|'witness'|'local'|'pilot'|'met' */ }],
posts: [{ id, t, handle, kind /* 'witness'|'context'|'question'|'correction' */, text, votes, captureId? }],
sources: [{ name, detail }],
}
```
`playMoment`, `exitMoment` and `setTime(null)` are safe to call at any time, including mid-playback or while a load is still pending: a later call supersedes an earlier one, and the stage frees its primitives only after the frame in progress.
`labels()` collision-avoids each label as it will be drawn (`title · detail`). When a Moment label collides, it falls back to callsign plus altitude, then to the callsign alone. Such entries carry `abbreviated: true` and the shortened `title` and `detail`.
The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`). Its river surfaces are OpenStreetMap water polygons in `mobile/demo/midtown-water.json` (`node mobile/demo/build-midtown-water.mjs`). They are drawn over Esri's mismatched water mosaic across the Hudson.
## Entities and dossiers
```js
globe.entity(ref) → Promise<Dossier> // ref: entity id string, a 'pick' payload, or { kind, id }
Dossier = {
id, kind, title, subtitle,
identity: { icao24?, callsign?, registration?, type?, typeCode?, operator?, route?, country?, squawk?,
norad?, intlDesignator?, tle?, mmsi?, provider?, magnitude?, illustrative?, ... },
live: { lon, lat, altM, speed /* m/s */, heading, squawk?, updatedAt, speedKt?, elevationDeg? } | null,
track: [{ t, lon, lat, altM }],
events: [{ t, title }],
links: [{ id, kind, title, relation, distanceKm? }],
sources: [{ name, fetchedAt, url?, hash /* 'sha256:…' (or 'fnv1a:…' outside secure contexts) */, detail? }],
privacy?: 'pia' | 'ladd' | 'no-public-identity', // identity then holds only what the transponder broadcasts
trackPending?: true, // the server track was slow; call trackOf(id) later for the full one
nextPass?: { start, max, end, maxElevationDeg, visible }, // satellites, for the viewer
illustrative?: true, // hero Moment tracks and captures
}
globe.search(query) → Promise<[{ id, kind, title, subtitle }]> // ≤ 30; "N46567", "UAL", "7700",
// "M5.2", "ISS", "Midtown", "empire"… across live aircraft, the record, satellites, quakes,
// cameras, situations, places (neighbourhoods, airports, cities) and Moments
globe.graph(ref) → { center, nodes: [{ id, kind, title }], edges: [{ from, to, relation }] } // ≤ 40 nodes, sync
```
Kinds: `aircraft`, `helicopter`, `military`, `satellite`, `vessel`, `quake`, `camera`, `situation`, `place`, `moment`, `track`, `capture`. There are never person entities. Aircraft dossiers wait at most 2.5 s for the server track (aircraft identity comes from adsbdb, 3.5 s budget).
## Reactions and attention
```js
globe.react({ lon, lat, kind: 'heart'|'fire'|'eyes'|'wow', count = 1, altM = 0 }) → number
globe.attentionHeat(points | null) // points: [{ lon, lat, weight? }]; soft glow where viewers are
```
Reactions draw on a screen-space canvas that the globe owns (`.gev-reactions`, pointer-events none, above the globe and below your HTML). Each glyph stays anchored to its point as the globe moves. At most 300 are alive at once, and the canvas animates only while some are alive.
## Your sky now
```js
globe.skyNow({ lon, lat } = viewer) → Promise<{
at, observer, sunElevationDeg,
aircraft: [{ id, title, kind, lon, lat, altM, distanceKm, elevationDeg, azimuthDeg, headingDeg, speedMps, squawk }],
satellites: [{ id, title, norad, altKm, elevationDeg, azimuthDeg }],
iss: { visibleNow, aboveHorizon, elevationDeg, azimuthDeg,
nextPass: { start, max, end, maxElevationDeg, riseAzimuthDeg, visible } | null },
sources: [string],
}> // aircraft within 50 km, satellites above the horizon; each list sorted by elevation, highest first
```
## Dev harness
`mobile/globe/dev.html?moment=empire-state&speed=30` plays a Moment on load and prints its events in the HUD. `globe.debug` exposes `{ recorder, detector, stage, ghosts }` for tests. It is not part of the contract.
---
# Polish round
Additive. Nothing above changes.
## Bundled recording (the time machine always has ghosts)
`mobile/demo/history/{nyc,london,gulf}.json` hold a real recording of every aircraft over the New York area, London and the North Sea approaches, and the Gulf of Mexico, captured by `node mobile/demo/capture-history.mjs` (adsb.lol point snapshots around each region's anchors, the dev server's `/api/flights` and `/api/military` as fallbacks; one fix per 30 s below 6 km, per 120 s in cruise). Positions are quantised to 1e-4° and 10 m and delta-encoded (`format: 'gev-history/1'`). Timestamps are the true capture times and are **never shifted**: the time machine shows the recording where it happened.
The engine loads it about 5 s after the first frame into a second recorder (no expiry) and reads both through one view: ghosts, `setTime` history mode, `entity()`, `trackOf()` and `search()` see the recording; on a key present in both, the device's own recorder wins.
```js
globe.history().archive → { start, end, regions: [{ id, label, bbox, source, start, end, tracks,
busiest: { t, aircraft } }] } | null
globe.loadHistory() → Promise<history().archive> // resolves once it is loaded
globe.historyNear(t, lon, lat, km = 100)
→ [{ id /* entity id */, key, kind, title, lon, lat, altM }] // recorded contacts at t, nearest first
// event 'history' → history(), once the recording is loaded
```
The time ribbon shades the recording in teal (`RECORDING · 23 OCT`) next to the device's own `RECORDED` range, and its readout names the day when the playhead is on another UTC day.
## Camera
```js
globe.flyTo({ lon, lat, height, heading, pitch, eye: true }); // eye: lon/lat/height place the camera itself
```
## Night close-ups
Below ~400 km, where Cesium's lighting is faded out, a night-side view grades the Esri mosaic toward night (darker, desaturated, slightly cooler) with a soft glow of the real Black Marble lights above ~40 km; the strength follows the sun's elevation under the camera at the **scene clock**, so a Moment keeps the lighting of its own time (the hero is daylight). A terrain watchdog falls back to the flat ellipsoid when the keyless terrain host stalls (the globe would otherwise show only the star field).
## Dossiers
`privacy` is set only for real privacy programmes (`'pia'`, `'ladd'` flags). An airframe with no public registry record gets `registryMissing: true` instead, and `identity.operator` falls back to the callsign's ICAO airline prefix (`mobile/globe/airlines.js`, about 150 operators).
-169
View File
@@ -1,169 +0,0 @@
/**
* ICAO airline designators (the three letters that start an airline
* callsign, e.g. AMX021 → Aeroméxico) for about 150 of the world's busiest
* operators. Used to name the operator of a flight when the community
* registry has no record for its airframe. Source: ICAO Doc 8585 designators
* as published by the airlines; names are the common trading names.
*/
const AIRLINES = {
AAL: 'American Airlines',
AAR: 'Asiana Airlines',
ABX: 'ABX Air',
ACA: 'Air Canada',
ADR: 'Air Serbia',
AEA: 'Air Europa',
AEE: 'Aegean Airlines',
AFL: 'Aeroflot',
AFR: 'Air France',
AIC: 'Air India',
AJX: 'Air Japan',
ALK: 'SriLankan Airlines',
AMX: 'Aeroméxico',
ANA: 'All Nippon Airways',
ANZ: 'Air New Zealand',
ASA: 'Alaska Airlines',
ASH: 'Mesa Airlines',
AUA: 'Austrian Airlines',
AVA: 'Avianca',
AWE: 'American Airlines (US Airways)',
AXM: 'AirAsia',
AZA: 'ITA Airways',
AZU: 'Azul',
BAW: 'British Airways',
BCS: 'European Air Transport (DHL)',
BEL: 'Brussels Airlines',
BTI: 'airBaltic',
BOX: 'AeroLogic',
CAL: 'China Airlines',
CCA: 'Air China',
CES: 'China Eastern Airlines',
CFG: 'Condor',
CHH: 'Hainan Airlines',
CKS: 'Kalitta Air',
CLX: 'Cargolux',
CMP: 'Copa Airlines',
CPA: 'Cathay Pacific',
CPZ: 'Compass Airlines',
CSN: 'China Southern Airlines',
CSZ: 'Shenzhen Airlines',
CTN: 'Croatia Airlines',
CXA: 'Xiamen Airlines',
DAL: 'Delta Air Lines',
DHK: 'DHL Air',
DLH: 'Lufthansa',
EDV: 'Endeavor Air',
EIN: 'Aer Lingus',
EJA: 'NetJets',
EJU: 'easyJet Europe',
ELY: 'El Al',
ENY: 'Envoy Air',
ETD: 'Etihad Airways',
ETH: 'Ethiopian Airlines',
EVA: 'EVA Air',
EWG: 'Eurowings',
EXS: 'Jet2',
EZS: 'easyJet Switzerland',
EZY: 'easyJet',
FDX: 'FedEx Express',
FFT: 'Frontier Airlines',
FIN: 'Finnair',
FJI: 'Fiji Airways',
FLE: 'Flair Airlines',
GEC: 'Lufthansa Cargo',
GFA: 'Gulf Air',
GJS: 'GoJet Airlines',
GLO: 'Gol',
GTI: 'Atlas Air',
HAL: 'Hawaiian Airlines',
HVN: 'Vietnam Airlines',
IBE: 'Iberia',
IBS: 'Iberia Express',
ICE: 'Icelandair',
IGO: 'IndiGo',
JAL: 'Japan Airlines',
JBU: 'JetBlue',
JIA: 'PSA Airlines',
JJP: 'Jetstar Japan',
JST: 'Jetstar',
JZA: 'Jazz Aviation (Air Canada Express)',
KAL: 'Korean Air',
KLM: 'KLM Royal Dutch Airlines',
KQA: 'Kenya Airways',
LAN: 'LATAM Airlines',
LDM: 'Lauda Europe',
LOG: 'Loganair',
LOT: 'LOT Polish Airlines',
LXJ: 'Flexjet',
MAS: 'Malaysia Airlines',
MEA: 'Middle East Airlines',
MSR: 'EgyptAir',
NAX: 'Norwegian',
NJE: 'NetJets Europe',
NKS: 'Spirit Airlines',
NOZ: 'Norwegian',
NWG: 'Airwork',
OAW: 'Helvetic Airways',
OMA: 'Oman Air',
PAL: 'Philippine Airlines',
PAC: 'Polar Air Cargo',
PDT: 'Piedmont Airlines',
PGT: 'Pegasus Airlines',
POE: 'Porter Airlines',
QFA: 'Qantas',
QTR: 'Qatar Airways',
QXE: 'Horizon Air',
RAM: 'Royal Air Maroc',
RJA: 'Royal Jordanian',
ROU: 'Air Canada Rouge',
RPA: 'Republic Airways',
RYR: 'Ryanair',
RZO: 'Azores Airlines',
SAS: 'Scandinavian Airlines',
SAA: 'South African Airways',
SBI: 'S7 Airlines',
SCX: 'Sun Country Airlines',
SIA: 'Singapore Airlines',
SKW: 'SkyWest Airlines',
SLK: 'SilkAir',
SVA: 'Saudia',
SWA: 'Southwest Airlines',
SWR: 'Swiss',
SXS: 'SunExpress',
TAM: 'LATAM Brasil',
TAP: 'TAP Air Portugal',
TAR: 'Tunisair',
TCX: 'Thomas Cook Airlines',
THA: 'Thai Airways',
THY: 'Turkish Airlines',
TOM: 'TUI Airways',
TRA: 'Transavia',
TSC: 'Air Transat',
TVF: 'Transavia France',
TVS: 'Smartwings',
UAE: 'Emirates',
UAL: 'United Airlines',
UPS: 'UPS Airlines',
UZB: 'Uzbekistan Airways',
VIR: 'Virgin Atlantic',
VJT: 'VistaJet',
VLG: 'Vueling',
VOI: 'Volaris',
VOZ: 'Virgin Australia',
VTI: 'Vistara',
WJA: 'WestJet',
WIF: 'Widerøe',
WMT: 'Wizz Air Malta',
WUK: 'Wizz Air UK',
WZZ: 'Wizz Air',
XLF: 'XL Airways',
};
/** Operator name from a callsign's ICAO airline prefix, or null. */
export function airlineFromCallsign(callsign) {
const m = /^([A-Z]{3})\d/.exec(
String(callsign || '')
.trim()
.toUpperCase(),
);
return m ? AIRLINES[m[1]] || null : null;
}
-171
View File
@@ -1,171 +0,0 @@
/**
* The bundled recording (DESIGN.md §12.5): a few hours of real recorded
* positions over the busiest regions, captured with
* `node mobile/demo/capture-history.mjs` into mobile/demo/history/*.json.
*
* It ships so the time machine always has real ghosts to replay, even on a
* first run when the device's own recorder holds only minutes. Timestamps
* are the true capture times and are never shifted: the time machine shows
* the recording where it really happened (the director navigates there).
*
* The archive is a second recorder (no expiry, no persistence). `history`
* is a read-only view over both that ghosts, the dossier and search use; on
* a key present in both, the device's own recorder wins.
*/
import { createRecorder } from './recorder.js';
// Literal URLs so Vite copies the files into the production build.
const FILES = [
new URL('../demo/history/nyc.json', import.meta.url).href,
new URL('../demo/history/london.json', import.meta.url).href,
new URL('../demo/history/gulf.json', import.meta.url).href,
];
/** Decode one `gev-history/1` file into [{ key, meta, points }]. */
export function decodeHistory(j) {
if (j?.format !== 'gev-history/1' || !Array.isArray(j.tracks)) return [];
const out = [];
for (const tr of j.tracks) {
const p = typeof tr.p === 'string' ? tr.p.split(',').map(Number) : tr.p;
if (!Array.isArray(p) || p.length < 12) continue;
let t = j.t0;
let lon = 0;
let lat = 0;
let alt = 0;
const points = [];
for (let i = 0; i + 5 < p.length; i += 6) {
t += p[i];
lon += p[i + 1];
lat += p[i + 2];
alt += p[i + 3];
points.push({
t: t * 1000,
lon: lon / 1e4,
lat: lat / 1e4,
altM: alt * 10,
headingDeg: p[i + 4],
speed: p[i + 5],
});
}
out.push({ key: String(tr.k), meta: tr.m || { kind: 'aircraft' }, points });
}
return out;
}
export function createArchive() {
const rec = createRecorder({
persist: false,
archive: true,
windowMs: Infinity,
maxInterpGapS: 11 * 60,
});
const regions = [];
let loading = null;
function load() {
if (loading) return loading;
loading = Promise.all(
FILES.map((url) =>
fetch(url)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
).then((files) => {
for (const j of files) {
if (!j) continue;
const tracks = decodeHistory(j);
let start = Infinity;
let end = -Infinity;
for (const tr of tracks) {
rec.merge(tr.key, tr.points, tr.meta);
start = Math.min(start, tr.points[0].t);
end = Math.max(end, tr.points[tr.points.length - 1].t);
}
if (!tracks.length) continue;
regions.push({
id: j.region,
label: j.label,
bbox: j.bbox,
source: j.source,
start,
end,
tracks: tracks.length,
busiest: busiestInstant(tracks, start, end),
});
}
return regions;
});
return loading;
}
return {
recorder: rec,
load,
get loaded() {
return regions.length > 0;
},
regions: () => regions.slice(),
range() {
if (!regions.length) return null;
return {
start: Math.min(...regions.map((r) => r.start)),
end: Math.max(...regions.map((r) => r.end)),
regions: regions.map((r) => ({ ...r })),
};
},
};
}
/** The 5-minute bucket with the most aircraft in the air. */
function busiestInstant(tracks, start, end) {
const B = 5 * 60_000;
const n = Math.max(1, Math.ceil((end - start) / B));
const counts = new Array(n).fill(0);
for (const tr of tracks) {
const a = Math.floor((tr.points[0].t - start) / B);
const b = Math.floor((tr.points[tr.points.length - 1].t - start) / B);
for (let i = Math.max(0, a); i <= Math.min(n - 1, b); i++) counts[i]++;
}
let best = 0;
for (let i = 1; i < n; i++) if (counts[i] > counts[best]) best = i;
return { t: start + best * B + B / 2, aircraft: counts[best] };
}
/**
* A read-only view over the device's recorder and the archive, with the
* recorder's query surface (covers, snapshotAt, trail, samples, stateAt…).
*/
export function historyView(live, archive) {
const a = archive.recorder;
const pick = (key) => (live.has(key) ? live : a.has(key) ? a : null);
return {
covers: (ms, slack) => live.covers(ms, slack) || a.covers(ms, slack),
has: (key) => live.has(key) || a.has(key),
meta: (key) => live.meta(key) || a.meta(key),
*keys() {
yield* live.keys();
for (const k of a.keys()) if (!live.has(k)) yield k;
},
samples(key, from, to) {
const own = live.samples(key, from, to);
return own.length ? own : a.samples(key, from, to);
},
stateAt(key, ms, opts) {
return live.stateAt(key, ms, opts) || a.stateAt(key, ms, opts);
},
snapshotAt(ms, opts) {
const own = live.snapshotAt(ms, opts);
if (!a.covers(ms, 60_000)) return own;
const seen = new Set(own.map((s) => s.key));
for (const s of a.snapshotAt(ms, opts)) if (!seen.has(s.key)) own.push(s);
return own;
},
trail(key, from, to, max) {
const r = live.trail(key, from, to, max);
return r.length >= 2 ? r : a.trail(key, from, to, max);
},
events: () => live.events(),
range: () => live.range(),
source: (key) => (pick(key) === a ? 'archive' : 'device'),
};
}
-147
View File
@@ -1,147 +0,0 @@
/**
* Instant first frame (DESIGN.md §12.8): the last good situations and their
* thumbnails, so the feed paints before any live feed answers.
*
* 1. IndexedDB: the last live situations() result in this browser
* 2. else the bundled snapshot, mobile/demo/snapshot.json (real data,
* captured by mobile/demo/capture-snapshot.mjs; see `capturedAt`)
*
* Thumbnails: the last rendered JPEG per situation id from the Cache API
* (secure contexts), else the bundled mobile/demo/thumbs/<id>.jpg.
*/
import { idbGet, idbPut } from './idb.js';
const SNAPSHOT_URL = new URL('../demo/snapshot.json', import.meta.url).href;
const BUNDLED_THUMBS = import.meta.glob('../demo/thumbs/*.jpg', {
eager: true,
query: '?url',
import: 'default',
});
const LAST_CACHE = 'gev-thumbs-last-v1';
const cacheApi = () =>
typeof caches !== 'undefined' && globalThis.isSecureContext;
const bundledThumb = new Map();
for (const [path, url] of Object.entries(BUNDLED_THUMBS)) {
const id = decodeURIComponent(
path
.split('/')
.pop()
.replace(/\.jpg$/, ''),
);
bundledThumb.set(id, url);
}
const cachedThumb = new Map(); // id → blob: URL from the Cache API
export function thumbFileName(id) {
return `${String(id).replace(/[^\w.-]+/g, '_')}.jpg`;
}
function bundledFor(id) {
return (
bundledThumb.get(thumbFileName(id).replace(/\.jpg$/, '')) ||
bundledThumb.get(id) ||
null
);
}
/** Sync: the best cached thumbnail URL for a situation, or null. */
export function cachedThumbnail(situation) {
const id = situation?.id;
if (!id) return null;
return cachedThumb.get(id) || bundledFor(id) || situation.thumbUrl || null;
}
/** Remember the last rendered thumbnail for an id (Cache API). */
export async function rememberThumbnail(id, blob) {
if (!cacheApi() || !blob) return;
try {
const c = await caches.open(LAST_CACHE);
await c.put(
`${location.origin}/__gev-last/${encodeURIComponent(id)}`,
new Response(blob, { headers: { 'Content-Type': 'image/jpeg' } }),
);
} catch {
/* quota or private mode */
}
}
async function hydrateThumbs(list) {
if (!cacheApi()) return;
try {
const c = await caches.open(LAST_CACHE);
await Promise.all(
list.map(async (s) => {
if (cachedThumb.has(s.id)) return;
const hit = await c.match(
`${location.origin}/__gev-last/${encodeURIComponent(s.id)}`,
);
if (hit) cachedThumb.set(s.id, URL.createObjectURL(await hit.blob()));
}),
);
} catch {
/* ignore */
}
}
let bundledPromise = null;
function loadBundled() {
if (!bundledPromise)
bundledPromise = fetch(SNAPSHOT_URL)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
return bundledPromise;
}
// Start the bundled fetch at import so a cached call never waits on it.
loadBundled();
function decorate(list, capturedAt, origin) {
return list.map((s) => ({
...s,
cached: true,
cacheOrigin: origin,
capturedAt,
live: false,
thumbUrl: cachedThumb.get(s.id) || bundledFor(s.id) || s.thumbUrl || null,
}));
}
/**
* The last good situations, fast. Resolves [] only when there is nothing at
* all (no IndexedDB snapshot and no bundled file).
*/
export async function loadCachedSituations() {
const stored = await Promise.race([
idbGet('kv', 'situations'),
new Promise((r) => setTimeout(() => r(null), 60)),
]);
if (stored?.list?.length) {
hydrateThumbs(stored.list);
return decorate(stored.list, stored.capturedAt, 'device');
}
const bundled = await loadBundled();
if (bundled?.situations?.length)
return decorate(
bundled.situations,
Date.parse(bundled.capturedAt) || bundled.capturedAt,
'bundled',
);
return [];
}
/** Store a live situations() result as the next first frame. */
export function saveSituations(list) {
if (!list?.length) return;
const clean = list.map((s) => {
const { thumbUrl, cached, cacheOrigin, ...rest } = s;
void thumbUrl;
void cached;
void cacheOrigin;
return rest;
});
idbPut('kv', 'situations', { capturedAt: Date.now(), list: clean }).catch(
() => {},
);
}
export const hasBundledThumb = (id) => Boolean(bundledFor(id));
Loaded 100 of 995 files, more files were not shown because too many files have changed in this diff. Show more