Compare commits

..
Author SHA1 Message Date
Sameh Khamis a904562f0f Changelog fragment for #1647 2026-11-09 20:36:34 -08:00
Sameh Khamis 1a8defbfc8 feat(flights): show the tracked aircraft's Planespotters photo in Contacts
Tracking a civil flight asks Planespotters for that aircraft's photo, once
per aircraft, straight from the browser. The CONTEXT panel's CONTACTS view
shows it under the flight's name with the photographer's credit, as a plain
link to the photo's page. Only images on plnspttrs.net and pages on
www.planespotters.net are ever shown or linked. Military aircraft and a
converted TR-3B are never looked up.

Flight lookups (type, route, photo) whose source failed temporarily are
asked again after a 60-second cool-down; an answer, found or not, and a
permanent refusal stay final for the session. A route belongs to the
callsign it was looked up for, so a callsign change drops the old route.

Browser QA scripts that track a flight answer the photo lookup themselves,
so they never call the third party.
2026-11-09 20:36:03 -08:00
Sameh Khamis 78c5b6c245 Merge pull request #1641 from bilawalsidhu/sameh/draw-shape
Applications can take the pointer for their own tool
2026-11-09 17:38:26 -08:00
Sameh Khamis 28907caf7b Merge branch 'main' into sameh/draw-shape 2026-11-09 17:30:15 -08:00
Sameh Khamis a7f96313e9 feat(pointer): an application's own tool can take the pointer
createApplicationTools returns pointer: claim(owner) takes the pointer as
Draw does and returns its lease, or null while another tool holds it;
release(lease) gives it back; isFree() lets the application's own click
handlers yield, as the layers' do. This replaces drawShape, which this
branch added: Draw is as it was.
2026-11-09 16:56:59 -08:00
Sameh Khamis da134328ef Merge pull request #1642 from bilawalsidhu/sameh/transit-floor-fix
Keep vehicles off rooftops and bridge decks by reading the 3D-tile surface where each vehicle is
2026-11-09 15:12:25 -08:00
Sameh Khamis 09520bc34c Changelog fragment for #1642 2026-11-09 14:54:51 -08:00
Sameh Khamis 9bce41d3c6 Read the mesh floor where transit vehicles and grounded flights actually are
With readings kept per point, the floor a vehicle gets depends on which point
is probed and where it is read. Transit offered the sampler the first point of
each vehicle's path in a cell, usually its oldest fix there, so a vehicle that
stayed in one cell never had its current spot probed. It now offers the latest
point of the path in each cell, and the newest fix in the vehicle's own cell
(the selected vehicle's history is walked newest first).

The grounded-flight display clamp read the floor at its sticky cell's centre,
which now answers from the reading nearest the centre, not the contact. It
reads at the contact's own position, held inside the sticky cell so boundary
hysteresis still applies.
2026-11-09 14:51:35 -08:00
Sameh Khamis 04b8c856ad Keep vehicles off rooftops and bridge decks: probe the mesh where the vehicle is
On the photoreal stack the rendered-mesh floor took one probe at each coarse
cell's centre (0.001 degrees) and kept it for the session. A centre on a roof
passed the +80 m DEM window and lifted every vehicle in the cell to roof height
(a Haymarket bus drew 23.5 m above the street, on the garage roof); a centre in
the river put Red Line trains on the Longfellow Bridge 6-9 m under the deck.

The sampler now probes at the caller's point. A cell with a reading is probed
again only at spots about 17 m apart from earlier ones, eight at most, and a
spot refused only because the cell's DEM prior was cold is tried again later.
The floor keeps each reading that passes the gate at the point it was taken
(two within about 5 m keep the lower; sixteen at most per cell) and answers a
read from the reading nearest the asked point, so a viaduct and the street
beside it in one cell keep their own heights.
2026-11-09 14:51:35 -08:00
Mazeyar Moeini Feizabadi e5da073d33 Merge pull request #1638 from avaldini/fix/realtime-voice-unicode-patterns
fix(voice): send OpenAI Realtime tool schemas the API accepts
2026-11-09 20:32:38 +01:00
Mazeyar Moeini Feizabadi ddd07f13f0 Merge branch 'main' into fix/realtime-voice-unicode-patterns 2026-11-09 19:44:29 +01:00
German Khokhlov 10784fa9c9 Merge pull request #1552 from IMBrandao/fix/firms-drop-snpp
fix(firms): stop requesting the retired Suomi NPP source
2026-11-09 21:52:09 +04:00
German Khokhlov fce5ee6381 docs(firms): tighten the Suomi NPP notes 2026-11-09 21:44:09 +04:00
German Khokhlov d90da158ea Merge branch 'main' into fix/firms-drop-snpp
# Conflicts:
#	docs/CURRENT-STATE.md
2026-11-09 21:41:01 +04:00
German Khokhlov b04fcf191c docs(firms): count two VIIRS sources in the layer table and test note 2026-11-09 21:38:07 +04:00
German Khokhlov 581ef7eef4 Merge pull request #766 from BreezeDelegate/fix/node26-allocation-gates
fix(ci): run allocation gates on Node 26
2026-11-09 20:35:28 +04:00
German Khokhlov 015e3ff7fc Merge branch 'main' into fix/node26-allocation-gates 2026-11-09 20:19:29 +04:00
German Khokhlov 72a240ba6f Merge pull request #762 from monwiss/refactor/tile-budget
refactor(providers): move the tile budget helpers out of the TomTom module
2026-11-09 20:00:55 +04:00
German Khokhlov 77df600329 Merge branch 'main' into refactor/tile-budget 2026-11-09 19:52:40 +04:00
Said Turksever 0dbc657bf9 Merge pull request #1576 from bilawalsidhu/german/transit-bikeshare-empty-state
Say why Transit and Bike Share rows are empty outside coverage
2026-11-09 15:48:34 +00:00
German Khokhlov e22348a8e1 Merge branch 'main' into refactor/tile-budget 2026-11-09 19:43:58 +04:00
German Khokhlov 75e8e7ddbd refactor(data): describe tile budgets per period, drop a duplicate test, add the changelog entry 2026-11-09 19:41:51 +04:00
Said Turksever 9787d9c4cc Merge branch 'main' into german/transit-bikeshare-empty-state 2026-11-09 15:32:48 +00:00
Said Turksever 2725f307a6 Merge pull request #1628 from IMBrandao/feat/transit-more-cities
feat(transit): add 34 keyless cities across Europe, Asia, Oceania and North America
2026-11-09 15:30:57 +00:00
Said Turksever cf05a29b7f Merge branch 'main' into feat/transit-more-cities 2026-11-09 15:20:12 +00:00
German Khokhlov 45108a0b0b Merge pull request #794 from daikaginza/fix/api-method-guard
fix(server): declare the method contract on the three read-only query proxies
2026-11-09 19:17:49 +04:00
Sameh Khamis 0600d86584 docs(changelog): Draw hands one shape back (#1641) 2026-11-09 07:16:52 -08:00
Sameh Khamis 4da477a654 feat(draw): hand one drawn area or line back to the application
createApplicationTools returns drawShape({ shape, signal }): it opens Draw
for one area or line and resolves with its Polygon or LineString without
placing a mark, or null when draw mode is left, the signal aborts, a newer
request replaces it, the pointer is held elsewhere or the tool goes. The
shape controls cannot change the asked-for shape, and a shape across the
antimeridian is refused as a row's actions refuse it and drawn again. It
also returns pointerFree, so an application's own click handlers yield to a
tool holding the pointer, as the layers' do.
2026-11-09 07:16:14 -08:00
Said Turksever c1ca0f489c Merge branch 'main' into feat/transit-more-cities 2026-11-09 15:13:18 +00:00
German Khokhlov cdf52ab290 test(server): drop unused fields from the /track hand-off stub 2026-11-09 19:07:14 +04:00
German Khokhlov c3738c0fdb docs(server): tighten the method-guard comments and changelog 2026-11-09 19:07:14 +04:00
German Khokhlov ace88172d3 Merge branch 'main' into fix/api-method-guard 2026-11-09 18:51:02 +04:00
German Khokhlov 6a7a09372a Merge pull request #795 from daikaginza/feat/opensky-compress-cached-body
feat(server): compress the cached OpenSky snapshot for clients that accept gzip
2026-11-09 18:40:19 +04:00
Said Turksever 3e630cf34f fix(transit): mark route line names as kept for i18n
Métro, Calypso, LD and the metro letters are line names as signed, not UI
copy; the i18n catalog check flagged them.
2026-11-09 14:34:33 +00:00
German Khokhlov 9d7fe8fcaa Merge branch 'main' into feat/opensky-compress-cached-body 2026-11-09 18:30:32 +04:00
German Khokhlov a45d6af5f7 Merge pull request #1611 from abiyeenzo/fr-fixes
i18n(fr): typography, terminology and wording fixes
2026-11-09 18:22:18 +04:00
German Khokhlov caad795fa2 refactor(server): drop the null and String paths from gzipBody 2026-11-09 18:15:13 +04:00
German Khokhlov 09bf01630b docs(server): tighten compression comments and docs 2026-11-09 18:14:30 +04:00
German Khokhlov 2ea4a78e75 Fix the fr review follow-ups
- "destination refresh" is the refresh at the place the camera flew to,
  so restore "son actualisation à destination".
- Radio note: "que vous la demandez par la voix", "Votre adresse IP".
- "{count} / 30D": "30 J", like "{days} j".
- Starlink: "la couche complète", as in the active-state tooltip.
- "Show {name} sighting": colon form, so names starting with a vowel
  do not break the elision after "de".
- Wildlife note: Acartia is the source, not a kind of marine mammal.
- Straight apostrophes in the five strings added on main since.
2026-11-09 18:12:48 +04:00
German Khokhlov 61e9cf87f9 Merge branch 'main' into fr-fixes
# Conflicts:
#	src/i18n/locales/fr.json
2026-11-09 18:12:09 +04:00
Said Turksever 2c1b2c278d fix(transit): read Rouen and Le Havre from the operators' own feeds
Rouen now reads Métropole Rouen Normandie's TCAR vehicle feed
(api.mrn.cityway.fr) and Le Havre LiA's own GTFS-RT
(opendata.transports-lia.fr), both listed in their transport.data.gouv.fr
records, instead of the unofficial bus-tracker.fr relay. LiA ids carry an
8- prefix, so a new liaRouteLabel strips it and liaRouteMode reads the line.
2026-11-09 13:58:20 +00:00
Said Turksever 7a053c640d fix(transit): real route-id modes for Prague, Bordeaux and Rouen; dated Warsaw/Poznań credits; data.gov.my refresh
- Prague PID: map route-id ranges from PID's GTFS (metro L991-993, S/R trains
  L1000s/L1100s, P ferries L1800s, trolleybuses 50-59, X lines, funicular LD)
  instead of 'below 100 is a tram, rest bus'.
- Bordeaux TBM: trams ride numbered ids 59-62/163/164 (A-F), boats 951-953;
  the old letter match never fired.
- Rouen Astuce: TCAR:90 Metro as tram, TCAR:99 Calypso as ferry; Le Havre FUNI.
- Warsaw and Poznan: credit carries the data date (and Warsaw links its API),
  as their terms require.
- data.gov.my feeds: CC BY 4.0, refreshMs 30 s to stay under 4 requests/min;
  proxy honours per-feed refreshMs.
- Denver: quote the RTD agreement; tests use the feeds' real route ids.
2026-11-09 13:50:14 +00:00
German Khokhlov a5b40dfc3d Merge pull request #1569 from rudycelekli/fix/perimeters-sub-acre-size
fix(perimeters): show a 0.25-acre fire as 0.25 ac, not 0 ac
2026-11-09 17:39:29 +04:00
German Khokhlov be9c37bee2 Merge remote-tracking branch 'origin/main' into fix/perimeters-sub-acre-size 2026-11-09 17:07:05 +04:00
German Khokhlov 394f3216f8 Drop the sub-acre rounding comment 2026-11-09 17:06:56 +04:00
German Khokhlov c62f7ef00e Merge pull request #1524 from quanticsoul4772/upstream/tools-heading-wrap
fix(tools): wrap whole-degree headings at 360 to 0
2026-11-09 17:00:58 +04:00
quanticsoul4772 dbb082355a Merge branch 'main' into upstream/tools-heading-wrap 2026-11-09 04:47:58 -08:00
German Khokhlov da6b63b6fd docs(tools): tighten the heading-wrap changelog, comments and overview 2026-11-09 16:45:11 +04:00
German Khokhlov 3150a460aa Merge pull request #1547 from quanticsoul4772/upstream/nominatim-cache-mode-windows
test(nominatim): check cache file modes only where POSIX modes exist
2026-11-09 16:44:00 +04:00
German Khokhlov c2bff536a5 Merge branch 'main' into upstream/nominatim-cache-mode-windows 2026-11-09 16:07:00 +04:00
Andrés Valdez d52c719ef6 docs(changelog): OpenAI Realtime voice tool patterns (#1638) 2026-11-09 07:52:24 +00:00
Andrés Valdez 9f2dfdf820 fix(voice): send OpenAI Realtime tool schemas the API accepts
OpenAI Realtime now answers 400 invalid_function_parameters for a whole
session whose tool schema uses Unicode property classes, as resolve_area's
area-id pattern does, so voice never connects ("Failed to create Realtime
token"). The session sends its tools through chatSchema, the filter typed
chat adopted in #1616, which leaves out only \p{…} patterns; area ids are
still checked against the action's own contract before the tool runs.

Fixes #1618
2026-11-09 07:34:44 +00:00
Sameh Khamis 027c9616a5 Merge pull request #1637 from bilawalsidhu/sameh/orbital-followups
Orbital cockpit follow-ups: sensor strip through zoom, one owner-flag sheet, place names in the loading status
2026-11-08 20:21:29 -08:00
Sameh Khamis c1c71d1987 docs(changelog): orbital cockpit follow-ups (#1637) 2026-11-08 20:12:00 -08:00
Sameh Khamis 26718edc0b test(place-names): stand-in packs, the whole status run, and a failure within a load that stays failed 2026-11-08 20:07:04 -08:00
Sameh Khamis 2f31828d28 fix(place-names): a load starts with no failures, so packs that failed earlier do not mark a retry that lands as failed 2026-11-08 20:04:40 -08:00
Sameh Khamis c5e1951f51 fix(cockpit): one place lookup that loads several packs reads as one load, not a LOAD COMPLETE between packs 2026-11-08 19:57:28 -08:00
Sameh Khamis f6dc89b4fa fix(cockpit): a place pack retried after a failure counts as failed only if the retry fails 2026-11-08 19:49:05 -08:00
Sameh Khamis e954734379 fix(cockpit): a place pack that will not load ends the loading batch failed; flags keep their neighbours out from under the border and out of a scaled flag's edge; a new ride asks again for a sheet that failed 2026-11-08 19:45:01 -08:00
Sameh Khamis 5a6cf22be1 feat(cockpit): the owner flags load at startup from one generated 15 KB sheet; the loading status names the place packs while they load on first lookup 2026-11-08 19:35:30 -08:00
Sameh Khamis 63aa45c520 fix(cockpit): the sensor strip follows the view through a zoom step; the pre-load level test checks a worked number; the boundary gate's dependency-asset exemption is tested 2026-11-08 19:08:51 -08:00
Sameh Khamis 1453794573 Merge pull request #1636 from bilawalsidhu/feat/draw-mark-actions
Draw rows take an application's own actions
2026-11-08 18:27:04 -08:00
Sameh Khamis eb1969e874 Merge branch 'main' into feat/draw-mark-actions 2026-11-08 18:20:47 -08:00
Sameh Khamis 034acb06a8 Changelog: Draw rows take an application's own actions 2026-11-08 18:12:48 -08:00
Sameh Khamis 27847bd063 Draw rows take an application's own actions
createApplicationTools({ drawMarkActions }) adds an application's buttons to
the rows of drawn areas and lines. An action gets the shape's id, kind, name,
row title and [lon, lat] geometry, and what it returns or resolves to is a
note under the row. The standalone shell passes none, so the open-source
build is unchanged.
2026-11-08 18:12:32 -08:00
Sameh Khamis 4ee617cf42 Merge pull request #1580 from ZloyS/denis/gev-orbital-focus
feat(cockpit): keep the orbital camera on a ground target
2026-11-08 17:21:27 -08:00
Sameh Khamis 577170ee8e fix(cockpit): the hop's circle runs through both ends, so it moves smoothly as a nearly opposite destination does and lands on it 2026-11-08 17:12:04 -08:00
Sameh Khamis 6be8af17f1 fix(cockpit): a new ride's TRACK ACQUIRED keeps its own time; an arrow during the ease back tilts on from the view; a site beyond the reach ends a focus; the horizon uses the WGS84 surface normal; a hop to the opposite side goes round the Earth; drop the unused in-frame listing 2026-11-08 17:12:04 -08:00
Denis Groshev 7d6314b981 docs(changelog): add the #1580 fragment 2026-11-09 01:56:30 +01:00
Denis Groshev 7b844792be docs(cockpit): describe orbit's Live Signals rows and site focus 2026-11-09 01:56:30 +01:00
Denis Groshev dd02068ce9 feat(cockpit): list orbit's sites and satellites in Live Signals
The orbital Context card is gone and the Contact card stays hidden in orbit
again. Live Signals page 1 had nothing in orbit but TRACK ACQUIRED, because
orbit feeds no Contacts snapshot; it now lists what is around the
satellite, built the way the aircraft rows are, five rows with stable keys
rebuilt once a second. Rows already shown keep their places while they
still qualify, so a site passing under a low orbit is not pulled from under
the pointer.

Site rows come from the Mapped Installations layer (getNearby, asked only
as far as the reach extends) and focus their site, or release it on the
focused site's row, which reads as the subject does. A site the layer drops
during its frequent reloads stays listed for 10 s, since sites do not move.
With the layer off nothing is listed and a SITES STANDBY row says why.
Satellite rows come from the satellites layer's bounded nearest query,
measured from the satellite itself, and ride the satellite; RETURN goes
back to the one before, skipping one that has left the catalogue. While
the camera hops to a new satellite only TRACK ACQUIRED and RETURN show, so
nothing is measured from the old position. An arrow pressed while a focus
eases back takes the arrows' own clock. Site and satellite rows name their
own actions.
2026-11-09 01:56:30 +01:00
Denis Groshev fad62a4ba8 feat(satellites): find the satellites nearest a position
getNearestPositions makes one bounded pass over the shown points, keeps the
nearest few in a short sorted list and reads latitude and longitude for
those alone, leaving out dense extras, asked-for ids and classes such as
debris. A caller that wants the nearest handful no longer builds and sorts
an entry for every satellite.
2026-11-09 01:56:30 +01:00
Denis Groshev 569c7cfb1b feat(installations): look a mapped site up by id or scene pick
getById returns a loaded installation with its drawn position, as getNearby
does, without selecting it; findByPick resolves a scene pick (a named
marker's installationId or a site entity) the way the layer's own click
selection reads it. Both return null while the layer is off.
2026-11-09 01:56:30 +01:00
Denis Groshev b18336d3d4 fix(cockpit): keep the orbital view on a double-click at a ground point
In the orbital view a double-click on a mapped ground point went on to
Cesium's own double-click, which tracks the picked entity and so ended the
view. The Cockpit's double-click routing now takes it in orbit: it keeps or
starts the focus on that point (a point beyond the reach is left unfocused
but still not tracked). A double-click anywhere else, one a tool owns, and
every one outside orbit go on to Cesium's as before, and stopping the
Cockpit still hands Cesium's handler back.
2026-11-09 01:56:30 +01:00
Denis Groshev dd063b0e83 docs(cockpit): describe the orbital view's ground focus 2026-11-09 01:56:30 +01:00
Denis Groshev 567c833582 feat(cockpit): keep the orbital camera on a ground target
In the satellite orbital view a single click on a mapped ground point (a
ground point of a layer that is on, such as a Mapped Installations site, a
fire or a local data pack feature) keeps the camera on it. Each frame the
look is aimed from the satellite's position to the point's Earth-fixed
position in the track-up nadir frame the arrows tilt, so the point stays
at the centre as the satellite moves. The swing onto the point, or between
points, uses the arrows' easing on a slower clock and then holds the point
exactly; reduced motion cuts.

A click elsewhere, FOCUS on the focused point, NADIR, Home or 0 ends the
focus and the view eases back to nadir; an arrow ends it and tilts on from
where the view is. The focus also ends, easing back, when the point leaves
the ±45° reach or the satellite's horizon, when its layer is turned off or
its record is dropped, and with the view. While focused the readout gives
the single off-nadir angle to the point (OFF-NADIR 23.4°), names it in a
polite live region, and the swath reads approximate as for a tilt. Clicks
still reach Cesium's own handler and never untrack the satellite; a click
a tool owns is ignored.

Under the readout, IN REACH lists the mapped points facing the satellite
that are within the reach or in the frame, nearest to nadir first and at
most 12, rebuilt once a second. A row opens the point's position, source
and OpenStreetMap tags (or its layer's properties); FOCUS focuses it,
switches to it, or ends the focus held on it, and is unavailable for a
point in frame but beyond the reach.
2026-11-09 01:56:29 +01:00
Sameh Khamis 745c11969b Merge pull request #1560 from ZloyS/denis/gev-sensor-zoom
feat(cockpit): zoom the orbital view to the imaging sensor's swath
2026-11-08 16:53:41 -08:00
Sameh Khamis 4396ba4506 fix(cockpit): the pre-load serves every lead's centre line before the frame edges, starts a fresh memory and cancels the old map's requests on a new base map, and retries only what the scheduler dropped; the sensor waits for the eased view to reach the Earth 2026-11-08 16:43:59 -08:00
Sameh Khamis b953c3f1dc fix(cockpit): no sensor swath past the limb, no strip for a GEO view or a SAR; the TLE age reads from the world-clock time shown; pre-loads cancel on exit, retry when they fail, start over on a new base map, wait while the clock is away from now and reach the frame edges 2026-11-08 16:35:23 -08:00
Sameh Khamis a274d10038 Merge pull request #1559 from ZloyS/denis/gev-orbital-view
feat(cockpit): ride a tracked satellite in an orbital view
2026-11-08 16:27:41 -08:00
Sameh Khamis 9a16fb8cbc Merge Core main into denis/gev-sensor-zoom
# Conflicts:
#	src/cockpitOrbital.test.mjs
2026-11-08 16:20:37 -08:00
Sameh Khamis e944f75275 Merge pull request #1558 from ZloyS/denis/gev-satellite-classes
feat(satellites): file satellites under six classes with a toggle each
2026-11-08 16:17:12 -08:00
Sameh Khamis 83a24b8d27 Merge Core main into denis/gev-orbital-view
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
#	src/layers/satellites/state.js
2026-11-08 16:09:30 -08:00
Sameh Khamis 4ab23733e9 test(satellites): the class test fakes carry the world-clock coverage hook main added 2026-11-08 16:07:09 -08:00
Sameh Khamis f1ed762e84 Merge Core main into denis/gev-satellite-classes
# Conflicts:
#	scripts/package-boundaries.json
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
#	src/layers/satellites/ingestion.test.mjs
#	src/layers/satellites/lifecycle.js
#	src/layers/satellites/state.js
2026-11-08 16:04:31 -08:00
Sameh Khamis 30fbfe238f Merge pull request #1557 from ZloyS/denis/gev-ship-bridge
feat(cockpit): ride the selected vessel from its bridge
2026-11-08 16:03:54 -08:00
Sameh Khamis 573479d7ab fix(satellites): the UCS pack build drops rows whose NORAD number is not positive 2026-11-08 15:55:13 -08:00
Sameh Khamis 3bf7e9edb7 fix(cockpit): the limb check reads the exact look angles; the lease note names both ways back to Recent Imagery 2026-11-08 15:55:02 -08:00
Sameh Khamis e968233a52 fix(cockpit): the swath waits for the eased view to reach the Earth, not only the look target 2026-11-08 15:55:02 -08:00
Sameh Khamis 85d0f84066 docs(cockpit): orbit's imagery lease keeps Recent Imagery out until exit 2026-11-08 15:55:02 -08:00
Sameh Khamis bf29408d00 chore(cockpit): drop a stale C-key comment and a dead fixture field, and keep the orbital stylesheet in the format scope 2026-11-08 15:55:02 -08:00
Sameh Khamis b72f453fea refactor(satellites): the UCS pack keeps only the operator, owner country and purpose the layer reads 2026-11-08 15:55:02 -08:00
Sameh Khamis 6ca53febad fix(cockpit): no swath readout while the orbital view looks past the Earth's limb 2026-11-08 15:55:02 -08:00
Sameh Khamis 5c25bc54e6 fix(cockpit): voice entry in an orbital view is refused before it touches tracking 2026-11-08 15:55:02 -08:00
Sameh Khamis a5df391d6c fix(cockpit): a catalog refresh in orbit keeps the subject and its companions hidden, and releasing the ISS leaves its ring to its class 2026-11-08 15:55:02 -08:00
Sameh Khamis af8dc37df5 Merge Core main into denis/gev-ship-bridge
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-08 15:51:34 -08:00
Denis Groshev 19d7148b06 docs(changelog): add the #1560 fragment 2026-11-09 00:25:08 +01:00
Denis Groshev 59c0997636 docs(cockpit): describe the orbital view's sensor-aware zoom
The Orbital view section said the zoom stops at 8x. It now covers the
16x to 128x steps for satellites with a confirmed sensor swath, capped
from live altitude, the mouse wheel, the SENSOR button, the to-scale
swath strip and readout (approximate off nadir), the imagery pre-load
ahead of the ground track, the TLE age on the identifier line, and that
the sensor values cite operator and agency pages per platform.
2026-11-09 00:25:07 +01:00
Denis Groshev fe5470a852 feat(cockpit): zoom the orbital view to the imaging sensor's swath
An imaging satellite with a known sensor can now zoom past 8x, up to 128x,
as far as the view stays at least as wide as the sensor's narrowest
standard swath at its live altitude; only swaths confirmed on a cited page
open those steps. The top step follows the altitude and window shape and
steps back when it falls below the zoom shown.

The view draws the sensor's swath to scale along the ground track with a
SENSOR SWATH readout (approximate off nadir), and a SENSOR button zooms to
exactly that width when it lies in range at nadir. From 16x the imagery
tiles the view will need 5 to 20 s ahead along the ground track are
pre-loaded at the matching level. The mouse wheel steps the zoom one notch
at a time, and the identity line shows how old the TLE is. The sensor
figures come from a new bundled pack of 46 Earth-observation platforms,
each value cited to its operator or agency, with its own data credit.
2026-11-09 00:25:06 +01:00
Denis Groshev d0ef5250ac docs(changelog): add the #1559 fragment 2026-11-09 00:24:22 +01:00
Denis Groshev 048293fc2a feat(cockpit): ride a tracked satellite in an orbital view
With a satellite tracked and no aircraft tracked, Cockpit can ride the
satellite. Entry needs only the Satellites layer; debris can be tracked but
not entered, and voice stays aircraft-only. Double-clicking the tracked
satellite or the entry buttons (reading ORBIT) enter it.

The camera sits on the satellite's per-frame SGP4 position and looks
straight down, turned to the ground track (north-up for a satellite with
none). The optical zoom steps 1x to 8x with a focus pull, the arrows tilt
the view up to 45 degrees off nadir, and Home or 0 returns to nadir. Off
nadir the tilt reads its total angle first and the swath readout becomes an
approximation marked with ≈. While in orbit the map leases flat Esri
imagery, the cloud pass stays off and the view opens in the optical style.

The orbital HUD names the satellite with its owner's flag (from the
flag-icons package, loaded per country) or an organisation badge, its
operator, class, NORAD and COSPAR ids and TLE epoch with the element lines
on request, altitude, orbital speed and the sub-satellite point. CURRENTLY
OVER names the country and its first-level division, or the sea, and from
8x the most prominent populated place nearby, looked up offline from the
bundled Natural Earth packs (populated places are a new pack). The
satellites layer hides the ridden satellite's own marks, dims the other
orbit rings and ignores scene clicks and Escape while held. Exit restores
the camera's field of view and the satellite's follow camera.

C no longer enters or leaves Cockpit, bridge or orbit: it is left to the
app-wide CCTV shortcut, and Escape or the exit button leave first person.
2026-11-09 00:24:22 +01:00
Denis Groshev a648c9b665 feat(satellites): owner, operator and COSPAR groundwork for the orbital view
The orbital view reads a satellite's owner country, operator, COSPAR id,
TLE epoch and whether its class can be ridden. #1558 now carries only
what the class toggles read, so this brings the rest back here, where it
is first used: the classes.js helpers and the UCS pack's operator and
owner fields.
2026-11-09 00:22:12 +01:00
Sameh Khamis eaf2b8f5b4 fix(satellites): the ISS ring follows its class through re-enable and rebuilds and stays while tracked; a mission group outranks geo; an Unknown UCS purpose is no evidence; the UNK grace is stated in calendar years 2026-11-08 15:16:01 -08:00
Sameh Khamis 83dd1af22e test(satellites): the card check reads the real record, the group checks cover every ingested group, and fixtures use NORAD ids UCS does not know 2026-11-08 15:16:01 -08:00
Sameh Khamis 608ab4adea fix(satellites): an unknown object from a launch under two years old is not debris yet; Earth/Space Observation is imaging; the data-sources table names SATCAT 2026-11-08 15:16:01 -08:00
Sameh Khamis cc24da165b fix(satellites): a current SATCAT read is kept six hours instead of re-reading eleven groups every update; the tracking regression serves SATCAT locally 2026-11-08 15:16:01 -08:00
Sameh Khamis 3bb742ae7e refactor(satellites): remove the unused owner, operator, COSPAR and orbital-view groundwork; the UCS pack keeps only the purpose the classes read 2026-11-08 15:16:01 -08:00
Sameh Khamis 7dcf671b2e fix(satellites): a hidden class stays out of analyst and voice answers; the tracked satellite still answers 2026-11-08 15:16:01 -08:00
Sameh Khamis b59eb8b18c fix(satellites): a hidden class takes the ISS ring, label and docked names
Class toggles only hid points: with Stations off the ISS's orbit ring and
its label still drew, and each catalog refresh showed the ring again. The
ring and label now follow the ISS's class, and the tracked card no longer
names a docked companion whose class is hidden.

A failed Starlink load reverted to the core catalog without applying class
visibility again, so the core catalog's own Starlink points stayed on while
the chip, legend and lists said Starlink was off, and classes.starlink kept
reading true. Both are put right on failure.
2026-11-08 15:16:01 -08:00
Sameh Khamis 8bd16b0be9 fix(satellites): classes read every group a TLE came in, and current SATCAT
Deduplication kept only the first group's tag, so a recent imaging
satellite in both visual and resource, with no UCS record or known name,
fell to Probes. The catalog entry now keeps every group, and the
last-resort class is the first of them that names one.

SATCAT groups merged in group order, so a stale cached copy read later
overwrote a current record of the same object: a satellite SATCAT now
calls dead stayed in Imaging. A stale copy no longer overrules a current
read, from the same merge or an earlier one.
2026-11-08 15:16:01 -08:00
Sameh Khamis c84cfcadeb fix(satellites): class choices survive a reload and travel in share links
Only catalog and the tracked satellite were saved for the layer, so a class
chip lasted until the next reload and no share link carried it. The choice
is now the satellites option `classes`: one letter per class shown
(`sitpd` by default, `0` for none), with Starlink left to `catalog`, and
the layer takes the letters back through setParams. A partial request
(`{debris: false}`) hides only what it names.
2026-11-08 15:16:01 -08:00
Sameh Khamis 8ae56587c3 fix(satellites): a SATCAT failure no longer stops TLE refreshes
Both CelesTrak routes shared one cooldown, and any failure set it: one bad
SATCAT answer made /api/celestrak return 502 "cooling down" for a minute,
failing the Starlink load and stalling every TLE group past its cache.
Throttling (403/429) still holds both routes off, since CelesTrak throttles
the client; any other failure holds off only the route that failed.
2026-11-08 15:16:01 -08:00
Sameh Khamis d404445da3 fix(satellites): every class but Starlink shows by default
Showing only Stations and Imaging by default hid GPS, GEO comsats, Hubble
and the rest of what the layer showed before classes, for every existing
user and saved view. Every class now starts on except Starlink, which stays
off as the DENSE switch was.
2026-11-08 15:16:01 -08:00
Denis Groshev 63e6338c19 docs(changelog): add the #1558 fragment 2026-11-08 23:35:10 +01:00
Denis Groshev c56f098d07 feat(satellites): file satellites under six classes with a toggle each
The satellites layer now sorts every object into Stations, Imaging,
Telecom, Starlink, Probes or Debris, each with a chip and a live count on
the layer row. Stations and Imaging show by default; the Starlink chip is
the former DENSE catalog switch. Hidden classes keep their points, so a
toggle needs no reload, and they leave the detection overlay and the voice
position lists.

A class comes, strongest evidence first, from CelesTrak SATCAT (object type
and status, served by a new /api/celestrak-satcat/<group> proxy route and
cached for a day), the bundled UCS Satellite Database pack (operator, owner
country and purpose), name patterns for later launches, and the CelesTrak
group. The core catalog also reads the resource, weather, planet, sar and
science groups. Payloads with no evidence of life launched 20 or more years
ago are filed as debris. The UCS pack, its build script and its data credit
are added.
2026-11-08 23:34:55 +01:00
Sameh Khamis 922712121c Merge pull request #1634 from bilawalsidhu/fix/layer-state-registered-only
Layer state keeps only layers the application registered
2026-11-08 14:28:48 -08:00
Sameh Khamis f62d947a82 Changelog: layer state keeps only registered layers 2026-11-08 14:21:09 -08:00
Sameh Khamis e9346cef19 Layer state keeps only layers the application registered
An application built with fewer layers no longer restores, stores or shares a
layer it does not have when a link or saved state from a build that has it
names that layer. LayerStateCoordinator drops unregistered enabled-layer ids
before normalizing, so dependent options (a tracked selection) go with them.
An application with every layer registered is unchanged.
2026-11-08 14:19:56 -08:00
Sameh Khamis 1b9bb844e6 revert(cockpit): no water read timeout, and loaded reads not raise-only
Both reviews of the timeout found that Cesium cannot cancel a full-detail
read: each one given up kept its nine picks traversing every frame, so
stalled tiles piled up a new set every 30 s, while slow but valid answers
were dropped. Raise-only loaded reads then locked in a wrong-high height.
Back to one full-detail read at a time whose answer always applies, and
loaded reads that may adjust a river height but never take it to the sea;
a coarse probe can again lower it partway until the full read lands.

Keeps the test for another ship selected inside the double-click memory,
and corrects CURRENT-STATE: a failed read keeps the height it had.
2026-11-08 14:09:18 -08:00
Sameh Khamis 1bc4f980e0 fix(cockpit): a stalled water read is given up, and coarse reads only raise
Before the first full-detail read lands, a loaded-tile read could still
lower a river height partway (27 m to a 15 m bank). Loaded-tile reads now
only raise it; lowering is the full-detail read's to say.

That makes a full read that never lands (stalled tile requests) hold a
wrong height for the whole bridge, so one still running after 30 s is given
up for a fresh one; its late answer is dropped.

Also tests the other half of the double-click memory fix: another ship
selected within the memory is not entered through the first ship's spot.
2026-11-08 14:09:18 -08:00
Sameh Khamis 80a33ba52c fix(cockpit): a coarse read never takes a river bridge back to the sea
Before the first full-detail water read lands (it can take many seconds
from cold tiles), a refresh's loaded-tile read with one low probe still
dropped a river height to the geoid. Before that landing, loaded-tile reads
may now find raised water but only a full-detail read lowers it.

CURRENT-STATE also says what the earlier fixes changed: C stays the CCTV
shortcut when the bridge cannot be entered, voice entry is refused on a
bridge, and the ridden ship's trail is hidden with its sprite.
2026-11-08 14:09:18 -08:00
Sameh Khamis 5c937e67e1 fix(cockpit): a ship's double-click memory belongs to that ship
The first press's picture of the ship survived a change of followed
subject: following an aircraft (or another ship) within 700 ms let a
double-click on the old ship's spot enter the new subject. The memory now
carries the ship's MMSI and is used only while that ship is still the one
followed.
2026-11-08 14:09:17 -08:00
Sameh Khamis f96a89a9ef fix(cockpit): radar contacts stay where the vessel layer draws them
The vessel layer draws other ships at their last AIS fix and does not
advance them, so dead-reckoning radar contacts moved the mismatch from own
ship to everyone else: a 15 kn ferry reported 90 s ago showed ~700 m ahead
of the ship out of the window. Own ship stays centred where the camera
rides; contacts go back to their last fixes, and the query drops the extra
reach and candidates (which could also cut the nearest ship in a crowd).
2026-11-08 14:09:17 -08:00
Sameh Khamis 0ba7ba6512 test(cockpit): the real vessel layer keeps the ridden ship on a water click
The bridge tests drive a hand-written layer, so nothing exercised the
vessel layer's own scene-click gate. A wire test now clicks open water with
the bridge holding the ship, then again after release.

The Contacts test that adopts Live AIS Vessels directly is renamed to say
so: it never rides a bridge.
2026-11-08 14:09:17 -08:00
Sameh Khamis 9abc105266 refactor(cockpit): the bridge styles get their own stylesheet
The ship bridge rules were appended to cockpit.css, taking it to 2705
lines; they move unchanged to cockpitBridge.css, imported right after it so
the cascade is the same. cockpitBridge.css and cockpitRadar.css join the
format scope and the styles package.
2026-11-08 14:09:17 -08:00
Sameh Khamis 0faa29dfbd style(cockpit): format the lastSubjectInfo rename 2026-11-08 14:09:17 -08:00
Sameh Khamis af21219911 refactor(cockpit): lastAircraftInfo is lastSubjectInfo
It holds the ridden vessel on a bridge, and the radar range button reads a
vessel from it.
2026-11-08 14:09:17 -08:00
Sameh Khamis 620a18608e fix(cockpit): only a ship's double-click remembers where it began
The first press's picture of the track is there because a click on a
selected ship re-centres the camera. It applied to followed aircraft too,
widening their double-click hit area to wherever the aircraft was drawn
as the gesture began. Aircraft are back to the hit where they are drawn.
2026-11-08 14:09:17 -08:00
Sameh Khamis bbe33b48c7 fix(vessels): hide the ridden ship's trail while its bridge is held
The sprite and card were hidden from the bridge but the selected-vessel
trail was not: at sea it ran astern from under the eye, and on a raised
river it lay buried under the water. It is now hidden while the bridge
holds its ship, keeping its history, and shown again on release.
2026-11-08 14:09:17 -08:00
Sameh Khamis 3ce3fce46f fix(cockpit): the bridge reads its floor where the ship is now
The ground clamp read the floor at the ship's last AIS fix while the camera
rode that fix dead-reckoned up to 180 s ahead, so a quay or bridge deck at
the old position could lift the eye. Vessels now read the floor at the
dead-reckoned target, as the water read already does; aircraft are
unchanged.
2026-11-08 14:09:17 -08:00
Sameh Khamis 052731202d fix(cockpit): the bridge radar matches the view out of the window
The radar centred on the ship's last AIS fix while the camera rode that fix
dead-reckoned up to 180 s ahead: at 20 kn about a nautical mile apart, so a
ship already passed still showed ahead. Own ship now sits where the camera
rides and each contact is dead-reckoned to the same moment; the query reaches
past the range for ships that have since moved into it.

Ships with no MMSI each get a key of their own instead of all sharing an
empty one on the scope.
2026-11-08 14:09:17 -08:00
Sameh Khamis 18c15d0352 fix(cockpit): a river bridge keeps its water height through empty and coarse reads
A full-detail water read that sampled nothing (tiles not loaded) fell back
to the geoid, and while a full read ran, a loaded-tile read with one coarse
low probe did the same: on the Seine either dropped the eye ~27 m under the
water until the next read. An empty read is now no answer, and once a
full-detail read has answered only full-detail reads change the height.

The read sequence numbers go: only one full-detail read runs at a time,
so a late read can never be older than one already applied.
2026-11-08 14:09:17 -08:00
Sameh Khamis 36a2216528 fix(cockpit): voice refuses entry while on a vessel bridge
Voice entry tracked an aircraft first, which cleared the vessel selection
and ended the bridge, and then failed because Cockpit was still active:
no bridge, no cockpit, no selected ship, and voice said entry was
unavailable. It now refuses before it touches tracking and says to exit
the bridge first.
2026-11-08 14:09:17 -08:00
Sameh Khamis b98bc4b0dc fix(cockpit): C stays the CCTV shortcut when the bridge cannot be entered
With a vessel selected and no aircraft tracked, Cockpit took C and stopped
it even when the bridge gate was shut, so the key did nothing and CCTV no
longer toggled. C is now taken only when the bridge can be entered.

The arrow keys also stay with comboboxes, spinbuttons, trees, grids,
menubars, scrollbars and text boxes on a bridge, which move with them.
2026-11-08 14:09:17 -08:00
Sameh Khamis 7ee7dc26b8 Merge pull request #1513 from bilawalsidhu/sameh/world-timeline-launches
Move launches with the timeline, choose its speed, and wait for data while it plays
2026-11-08 12:32:37 -08:00
Sameh Khamis ff0b4d8449 Merge branch 'main' into sameh/world-timeline-launches 2026-11-08 12:24:39 -08:00
Sameh Khamis 424bcb0843 Merge pull request #1512 from bilawalsidhu/sameh/world-clock
Keep one world clock, with a timeline to move it
2026-11-08 12:23:12 -08:00
Sameh Khamis 137639dd38 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-08 12:13:46 -08:00
Sameh Khamis 84f3476dd3 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	scripts/package-boundaries.json
#	src/app/scene.js
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
#	src/layers/launches/ingestion.js
2026-11-08 12:13:41 -08:00
Itallo BrandãoandClaude Opus 5.5 e631839a4e fix(transit): quote Halifax's Open Government Licence
The licence page refuses automated requests; its text was read in a browser.
Credit now uses the licence's own attribution statement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:08:40 -03:00
Itallo Brandão 543c607b23 Merge remote-tracking branch 'origin/main' into feat/transit-more-cities 2026-11-08 02:57:49 -03:00
Itallo BrandãoandClaude Opus 5.5 923287eb92 chore(changelog): name the transit fragment after PR #1628
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:56:19 -03:00
Itallo BrandãoandClaude Opus 5.5 71b694ab62 feat(transit): add 32 more keyless cities across Europe, Asia, Oceania and North America
Prague, Warsaw, Kraków (buses and trams), Poznań, Gdańsk, Rzeszów, Vilnius,
Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses),
Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Lisbon's city
Carris, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton,
Halifax and Victoria.

Each entry records its licence, or that none is stated and the Metro
Transit/OVapi basis it ships on. Montpellier and Toulon publish km/h and are
converted. Route ids are read as the rider-facing line; Prague, Bordeaux and
Le Havre trams and Venice waterbuses take their own mode. Adelaide needs
`Accept: */*` to get binary protobuf. Every feed was fetched through the dev
proxy on 2026-11-08.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:50:32 -03:00
Itallo BrandãoandClaude Opus 5.5 e0ba12e5a9 feat(transit): add Lisbon's Carris Metropolitana buses
Keyless GTFS-Realtime feed from Carris Metropolitana, documented in the
operator's public API repository. No licence is stated for the realtime
endpoint (the static GTFS is CC BY 4.0), so it is credited as a courtesy on
the same basis as the Metro Transit and OVapi feeds. The feed publishes km/h,
so a new `speedUnit` registry field lets the proxy convert to m/s. Route ids
wrap the GTFS route id in an area prefix and are read as the line number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:38:24 -03:00
daika c5bdcdf37f chore(format): adopt responseCompression.test.mjs into the format scope 2026-11-08 04:02:47 +07:00
daika 83bd48102a Merge remote-tracking branch 'upstream/main' into rb/opensky-scope 2026-11-08 04:02:08 +07:00
Denis Groshev d55acd2ac5 docs(changelog): add the #1557 fragment 2026-11-07 19:52:33 +01:00
Denis Groshev 72112ecb45 feat(cockpit): ride the selected vessel from its bridge
With no aircraft tracked, Cockpit can ride the selected AIS vessel. Entry
needs only Live AIS Vessels and a selected vessel; C, double-click and the
entry buttons (reading BRIDGE with a ship icon) enter it, while voice stays
aircraft-only.

The eye sits on the AIS position at a representative bridge height for the
vessel's type family, looks along true heading and moves along course over
ground. The last fix is dead-reckoned for up to 180 s between the layer's
refreshes, and on rivers and lakes the waterline is read from the rendered
surface around the ship rather than the geoid.

The bridge HUD shows speed and course over ground, a straight heading
tape, window mullions, the AIS destination as a reported voyage and a
heading-up radar of nearby ships (3 to 24 NM). The arrow keys look around,
Home or 0 looks ahead again. The vessel layer hides the ridden ship's
sprite and card, ignores scene clicks while held, and returns the camera
to the vessel's map view on exit.
2026-11-07 19:52:30 +01:00
Said Turksever 52187c91d1 Merge pull request #1506 from inoukt/fix/taiwan-highway-cctv-fallback
feat(cctv): expand Taiwan, Québec, Ottawa, and World cameras
2026-11-07 18:39:58 +00:00
Said Turksever 084a63ab5f Merge upstream/main into fix/taiwan-highway-cctv-fallback 2026-11-07 17:54:15 +00:00
Said Turksever 6af14f7f13 fix(cctv): leave Iceland to its own pack, credit World and Canada packs
Main now ships an Iceland pack that loads the full live Vegagerðin list
(about 500 views) with its CC BY 4.0 credit. The World catalog's 166
Vegagerðin rows point at the same frames under different ids, so every one
of those cameras would be drawn twice. Drop them from the World export and
its loader policy; World is now 2,230 rows and the default catalog limit
7,350 (4,000 shared slots plus 3,350 reserved).

Register Data attribution entries for Ottawa, Québec, TripCheck and MLIT,
as every other CCTV pack has. MLIT's terms ask for the source in the form
"出典:国土交通省 川の防災情報" with the page link, and ODOT asks for
"Camera courtesy of ODOT"; TripCheck partner cameras keep their owner's
name in the label.

The Houston guard test from main checked the old 4,000 default ceiling;
check the 4,000 shared slots instead, like the other pack tests.
2026-11-07 17:53:55 +00:00
Said Turksever 8a14029501 Merge pull request #1619 from NeoXrated23/feat/giroc-camera-hls-playback
feat(cctv): add Giroc camera pack and support larger HLS segments
2026-11-07 17:52:27 +00:00
Said Turksever cc3d390911 Merge upstream/main into fix/taiwan-highway-cctv-fallback 2026-11-07 17:46:53 +00:00
Said Turksever b99c2f1ade Merge remote-tracking branch 'upstream/main' into pr1619-fix2 2026-11-07 17:43:20 +00:00
quanticsoul4772 6150e21ebb Merge branch 'main' into upstream/nominatim-cache-mode-windows 2026-11-07 09:38:35 -08:00
quanticsoul4772 a1d3003b57 Merge branch 'main' into upstream/tools-heading-wrap 2026-11-07 09:38:22 -08:00
Said Turksever ca6eace79e Merge pull request #1617 from daikaginza/feat/cctv-iceland
feat(cctv): add Iceland (Vegagerðin / IRCA) road cameras as a keyless source pack
2026-11-07 15:34:07 +00:00
Said Turksever 39d70fa1c0 fix(cctv): keep Icelandic labels intact and treat 'og' facings as ambiguous
- Label repeat-collapse uses letter lookarounds instead of ASCII-only \b, so
  'Þverá Árnessýslu' is no longer joined and 'séð séð' collapses.
- 'til austurs og vesturs' and 'í norður og suður' name two facings and keep
  the low-confidence pose.
- Fallback name uses the frame stem when the station number is missing.
- Tests: label and heading regressions, dedupe keeps the last row, cap clamp,
  high-confidence pose, frame redirect pin; file added to format scope.
- Credit names the dataset, links the terms and states no endorsement.
2026-11-07 14:38:52 +00:00
Said Turksever 8455c4bce1 Merge branch 'main' into feat/cctv-iceland 2026-11-07 14:38:51 +00:00
Said Turksever 872bbefb63 docs(cctv): README HLS cap to 8 MiB; Giroc pack local-only note 2026-11-07 14:29:06 +00:00
Said Turksever 2213d3d8cf Merge remote-tracking branch 'upstream/main' into pr1619-fix 2026-11-07 14:28:44 +00:00
Sameh Khamis 6cadfc9583 Merge pull request #1620 from bilawalsidhu/fix/cctv-initial-nearest-camera
CCTV opens on the camera nearest the viewer, not the first in the catalog
2026-11-07 04:04:38 -08:00
Sameh Khamis 2cac463c6f Changelog: CCTV opening camera 2026-11-07 03:52:54 -08:00
Sameh Khamis 479286a6a6 CCTV opens on the camera nearest the viewer, not the first in the catalog
Turning CCTV on picked the first catalog record, so at King's Cross it
showed a Sydney camera. enable() now picks the camera nearest the viewer.
Until someone chooses a camera, each settled move re-picks the nearest,
so a share link whose flight lands after CCTV turned on still opens
nearby.
2026-11-07 03:52:38 -08:00
NeoXrated23 9b65d41bab docs: add changelog entry for Giroc camera contribution 2026-11-07 11:48:07 +02:00
NeoXrated23 891eb56091 feat(cctv): add optional Giroc camera and support larger HLS segments 2026-11-07 11:46:48 +02:00
Sameh Khamis c0a5d4141d Merge pull request #1616 from bilawalsidhu/fix/chat-tool-schema-patterns
fix(chat): send OpenAI chat tool schemas Chat Completions accepts
2026-11-06 22:21:05 -08:00
daika f5d5da7023 docs(changelog): add the Iceland CCTV pack fragment 2026-11-07 12:57:48 +07:00
daika 021e887f94 feat(cctv): add Iceland (Vegagerðin / IRCA) as a keyless CCTV source pack
One JSON list from gagnaveita.vegagerdin.is (about 500 camera views at roughly
170 road stations). Frames are JPEG stills in a single directory on
www.vegagerdin.is; only links inside that directory are registered, and the
media proxy pins the same directory.

The list decides ids, coordinates and which frame path is proxied, so it is
fetched over https with redirects refused and the body read capped at 2 MiB;
every rejection path releases the response body. Camera ids come from the frame
file name, which is unique per view in the live feed, and repeated frames
collapse before the cap.

The feed's view description (`Skyring`) is the label. An explicit Icelandic
facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading
(264 of 499 views); a view that states none, or two different ones, keeps the
id-hash low-confidence pose. A bare road direction ("Miklabraut austur") is not
read as a facing.

IRCA publishes road camera images under CC BY 4.0 and asks that the licensor is
acknowledged without implying endorsement; the attribution is in the Data
attribution popover and DATA_SOURCES.md, and every camera carries the licence
string. Default cap is 250 with anchors at Reykjavík and four regional centres so
a cap keeps every region. CCTV_ICELAND_ENABLED=0 turns the pack off.

Verified against the live feed through fetchCctvImageFromUpstream: 499 of 499
frames returned a JPEG, all distinct, all under 20 minutes old.
2026-11-07 12:57:08 +07:00
Sameh Khamis 8ec92eb48c docs(changelog): OpenAI chat tool patterns (#1616) 2026-11-06 21:52:03 -08:00
Sameh Khamis 5e42a83d88 fix(chat): send OpenAI chat tool schemas Chat Completions accepts
OpenAI refused every typed chat request with 400 invalid_function_parameters:
resolve_area's area-id pattern uses Unicode property classes (\p{L}), which
Chat Completions does not accept as a regex. OpenAI chat now leaves such
patterns out of the tool schemas it sends; each action still checks its
arguments against its own contract, and realtime voice keeps the patterns.
2026-11-06 21:51:46 -08:00
Enzo Abyie 36c29d6ab4 changelog: french catalog review 2026-11-06 20:14:15 -05:00
Enzo Abyie b86c650428 i18n(fr): typography, terminology and wording fixes 2026-11-06 20:14:03 -05:00
Sameh Khamis 6e4da23cf7 Merge pull request #1609 from bilawalsidhu/feat/traffic-lines-toggle
Street Traffic: Lines and Dots toggles; hosts choose which road modes they offer
2026-11-06 13:46:00 -08:00
Sameh Khamis 574430fb1d docs(changelog): Street Traffic Lines and Dots toggles (#1609) 2026-11-06 11:44:17 -08:00
Sameh Khamis 9c8bb206ef feat(traffic): Lines and Dots toggles; hosts choose which road modes they offer
createTrafficSource({ roadModes }) lets a host offer a subset of the TomTom,
OSM and Hybrid road modes (all three by default). Without OSM the layer never
draws simulated roads, and a single mode shows no road chip.

While flow is live the Street Traffic row offers Lines (every road with flow
as a steady green/amber/red line, from the heat-line renderer) and Dots (the
moving dots), saved in layer state and share links. Defaults: Lines off,
Dots on.
2026-11-06 11:37:57 -08:00
German Khokhlov c06eb89190 Merge branch 'main' into german/transit-bikeshare-empty-state 2026-11-06 23:37:24 +04:00
Mazeyar Moeini Feizabadi c56d5f2f06 Merge pull request #1578 from bilawalsidhu/german/space-missions-failed-entry
Roll back a Space Missions entry whose mission index fails to load

Thanks @gekh. Maintainer review fixes (separate commits): exit cancels the layer's own camera flights again, a failed index load shows a specific toast, tests and docs.
2026-11-06 20:26:55 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8da8c05eaf test(launches): keep the fixture's toast recorder for the failure-message test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:34 +01:00
Mazeyar Moeini Feizabadi 5dd7f41436 Merge branch 'pr-1578-new' into review/pr-1578
# Conflicts:
#	changelog.d/1578-space-missions-failed-entry.md
#	docs/CURRENT-STATE.md
#	src/layers/launches/controls.js
2026-11-06 20:10:01 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 efa4374644 fix(launches): hand the camera over before stopping, and only blame a fresh index error
- releaseCameraOwnership marks the camera released before stopping, so a
  flight navigation asked to preserve survives; exit also releases it, so an
  inactive layer never cancels another owner's flight.
- The failure toast names the mission index only when its error is from the
  current Context attempt (stats.errorAt), not an earlier one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:28:56 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 13219828e3 fix(launches): cancel mission flights on exit and name a failed index load
Review fixes on top of #1578:
- Exiting Space Missions cancels the layer's own camera flights again (entry,
  roster preview, mission focus); only a flight navigation started after
  releaseCameraOwnership() runs on.
- A failed first index load says so: "Space Missions unavailable: the
  mission index could not load. Try again later." on both the Context and
  Data Layers routes, instead of a generic transition toast. Translated.
- Regression tests for both; docs and changelog describe the real feedback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:11 +01:00
German Khokhlov ea76fc4bc0 Fold duplicate coverage tests into their neighbours 2026-11-06 22:01:39 +04:00
German Khokhlov 096b891099 Simplify failed-entry camera handling and tests 2026-11-06 22:01:39 +04:00
Mazeyar Moeini Feizabadi 3cd7ed68aa Merge remote-tracking branch 'origin/main' into review/pr-1578 2026-11-06 18:55:07 +01:00
Mazeyar Moeini Feizabadi 4effadd3e9 Merge pull request #1601 from bilawalsidhu/fix/street-level-keyless-default
fix(street-level): without a Mapillary key, start on a keyless source

Thanks @asturksever. Maintainer review fixes (separate commits): filter-only updates keep an explicitly chosen source, keyless Street Level QA gate updated, tests and docs.
2026-11-06 18:45:17 +01:00
Mazeyar Moeini Feizabadi ddc14c8f51 Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 18:32:31 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 f7b9a2e5af test(street-level): keyless QA branch handles a keyed bundle behind a keyless server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:20:47 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8a7402d80c fix(street-level): keep explicit provider choices on filter-only updates
Review fixes on top of #1601:
- Only redirect a provider the params switch on to a keyless source, so a
  filter-only setParams (360°, SINCE) no longer moves an explicitly chosen
  keyless Mapillary to KartaView.
- Keyless QA gate: assert the new KartaView default and that the Mapillary
  chip explains its key instead of switching (it used to expect KEY REQUIRED).
- Tests: Graph-proxy keyReady, filter-only updates; shared test helper.
- README and CURRENT-STATE describe the keyless default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:14:16 +01:00
German Khokhlov 72e2eba70d Merge pull request #1581 from bilawalsidhu/german/i18n-new-strings-fixes
Fix the translations of the voice geometry, fire filter, airspace and sea mark strings
2026-11-06 21:03:09 +04:00
German Khokhlov 41cc57d630 Merge branch 'main' into german/i18n-new-strings-fixes 2026-11-06 20:55:36 +04:00
Mazeyar Moeini Feizabadi 61dfdc771e Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 17:54:23 +01:00
Mazeyar Moeini Feizabadi b35c810284 Merge pull request #1528 from dylanmaniatakes/codex/s2-ghostmaps-age-filter
feat: add adjustable age filtering across S2 GhostMaps

Thanks @dylanmaniatakes. Maintainer review fixes (separate commits): age-window narrowing/restore consistency, card kept across same-report reloads, date-only events cover their whole UTC day, and the age choice as a labelled Event age switch.
2026-11-06 17:52:28 +01:00
Mazeyar Moeini Feizabadi d5d824f705 Merge remote-tracking branch 'origin/main' into review/pr-1528 2026-11-06 17:40:33 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 91ab9b5811 fix(ghostmaps): follow-up review fixes for the age switch
- Keep the open card across a widened reload only when its index still
  holds the same report; the server may answer a pinned version with newer
  bytes.
- Narrowing to a window the loaded data already covers clears a failed
  wider reload, so the row no longer stays in error.
- Label the every-age option "All" (contextual) so the six-option switch
  fits the row in every locale; tooltip and info line keep "All markers".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:37:29 +01:00
German Khokhlov d544613aae Merge pull request #847 from kvnloo/exp/cyclone-shared-context
feat(cyclones): publish explicit storm selection to shared context
2026-11-06 20:35:22 +04:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 c4298c4450 fix(ghostmaps): keep age-window changes consistent and make the age choice a switch
Review fixes on top of #1528:
- Narrowing the age window only removes markers; a product loaded with a
  narrower window is reloaded when the window widens again. Fixes markers
  missing after 7d -> 1d -> 7d and avoids reparsing every KMZ on narrowing.
- A wider window over the same edition keeps the open report card.
- Date-only event dates cover their whole UTC day, so 1d still shows
  yesterday's reports.
- The age options are one labelled "Event age" radio switch (row
  `segmented` control, as FIRMS confidence) instead of toggle chips mixed
  with the product chips.
- Un-nest the per-product age test; QA compares data-source counts with the
  live defaults instead of a hard-coded 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:25:01 +01:00
German Khokhlov 5c4918affb Tighten coverage prompt comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 8917eebe12 Tighten failed-entry comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 5bab5ec26d refactor(cyclones): drop defensive shared-context state and checks 2026-11-06 20:15:27 +04:00
German Khokhlov 5e64e599e1 docs(cyclones): tighten shared-context comments, changelog and notes 2026-11-06 20:07:56 +04:00
Mazeyar Moeini Feizabadi 7673351ce0 Merge remote-tracking branch 'origin/main' into review/pr-1528
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-06 17:02:29 +01:00
German Khokhlov 3a78f40024 Merge main into german/transit-bikeshare-empty-state
Conflict in src/layers/transit/queries.js: keep the branch's shared note with the gate's enter altitude and main's distinct region count.
2026-11-06 20:00:58 +04:00
German Khokhlov e1ea524b68 Merge main into german/space-missions-failed-entry 2026-11-06 20:00:21 +04:00
German Khokhlov ea22d16c1a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 19:44:54 +04:00
German Khokhlov 4e518821b5 fix(cyclones): name the classification and mark a stale cache in shared context
- Publish classificationName next to the raw NHC code; unknown codes stay
  unexpanded.
- Publish staleCacheFetchedAt only while the proxy serves its cache.
- Touch the shared store on clear only after this layer published, so a
  feed failure no longer evicts another layer's dangling selection.
- Drop the unreachable no-storm branch and the unused return value.
- Tests: split the heading fixture into due north and stationary, cover
  the unavailable and failure paths with the real store, and check that
  clear/disable leave another layer's selection alone; fold the fake-store
  test into the real-store one.
2026-11-06 19:43:04 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
German Khokhlov aca5317a2a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 16:03:13 +04:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever dda5ac6c00 Changelog: 1601 fragment 2026-11-06 11:59:31 +00:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
Said Turksever 579acf5081 Street Level: without a Mapillary key, light a keyless source instead of opening on KEY REQUIRED 2026-11-06 11:51:34 +00:00
German Khokhlov 697b6b59e6 Repaint coverage rows after loads, nudges and cleared failures 2026-11-06 14:47:47 +04:00
German Khokhlov 0de347c328 Leave other camera flights running when Space Missions stops without a replay 2026-11-06 14:45:36 +04:00
Itallo BrandãoandClaude Sonnet 5.5 433b336c2d feat(transit): add Szczecin buses and trams (ZDiTM, CC0)
Keyless GTFS-Realtime feed published by ZDiTM Szczecin under CC0 1.0.
Speeds are m/s (checked against vehicle movement), the file refreshes about
every 20 s, and the feed carries no route ids, so vehicles take the default
bus mode.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 02:50:42 -03:00
German Khokhlov 7f8556c691 Merge branch 'main' into exp/cyclone-shared-context
# Conflicts:
#	src/app/constructCatalog.js
2026-11-05 23:01:23 +04:00
quanticsoul4772 f717f8a0d7 Merge branch 'main' into upstream/nominatim-cache-mode-windows 2026-11-05 10:23:39 -08:00
quanticsoul4772 d0bf21b8c3 Merge branch 'main' into upstream/tools-heading-wrap 2026-11-05 10:23:22 -08:00
German Khokhlov 90db4eb2c9 Name the real gate altitude and repaint coverage rows at once
The Transit prompt said "Fly below 3,000 km", but the closed gate opens at
2,850 km; it now names the enter threshold, as Bike Share does. When the
camera changes coverage, Transit and Bike Share ask the Layers panel to
repaint instead of leaving the old prompt up until the next 15 s / 60 s tick.
2026-11-05 21:21:03 +04:00
German Khokhlov 655d93cc92 Keep a newer camera view when a failed Space Missions entry rolls back 2026-11-05 21:21:02 +04:00
quanticsoul4772andClaude Opus 5.5 df87419e64 test(tools): pin the raw near-360 satellite azimuths and list the tools with the heading helper
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:43:42 -08:00
quanticsoul4772andClaude Opus 5.5 e5ab5f945a test(nominatim): share one POSIX guard and say the cache is private only on POSIX
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:43:36 -08:00
German Khokhlov ed0ad9cf0f Add the changelog entry for #1581 2026-11-05 14:32:39 +04:00
German Khokhlov c64ed40c62 Add the changelog entry for #1578 2026-11-05 12:54:36 +04:00
German Khokhlov 25c33893f6 Add the changelog entry for #1576 2026-11-05 12:53:09 +04:00
German Khokhlov b6ad32009f Say why Transit and Bike Share rows are empty outside coverage
Over a city with no feed or system (London, Tokyo) both rows read
"ON · — · never", the same as a feed that never answered. Transit already
computed a coverage note but put it only in `coverage`, which the row does
not show; Bike Share reported nothing at all.

Both layers now report a `zoom-in` guidance status with a `statusMessage`:
"Fly below 3,000 km to a covered region" / "No feed here yet · N regions
available" for Transit, and "Fly below 48 km to a covered city" / "No bike
share here yet · N systems available" for Bike Share. A loading or failed
Bike Share city keeps its own text. Leaving coverage clears the layer's
last-update time, so the row no longer dates data it no longer draws.

The coverage-note translations move from the voice card into
`translateStatusMessage` (ui/statusText.js), which the Layers row and the
voice card now share.
2026-11-05 12:52:28 +04:00
German Khokhlov 5c04b12164 Roll back a Space Missions entry whose mission index fails to load
The launches layer swallowed every fetch error in its update, so the
manager treated a 429 or timeout on /api/launches as a successful start.
Context then committed the isolating mode with no missions: the other
layers stayed off, unrelated enables were refused, the camera stayed at
the full-globe view and the roster kept reading "loading".

Until the first index loads, the update now reports the failure. The
manager fails the enable closed and both entry routes roll back to the
pre-entry layers. The camera pose captured at entry is restored when the
mode is left before any index loaded, and the roster shows a failed
state instead of loading. A valid empty index still enters, and a
refresh failure after a loaded index keeps its records.
2026-11-05 12:43:49 +04:00
German Khokhlov 33a2876bc0 Fix the translations of the voice geometry, fire filter, airspace and sea mark strings 2026-11-05 12:36:13 +04:00
Itallo BrandãoandClaude Opus 5.5 b37025414d test(firms): pin the refresh call count and read sources from production
Review follow-up for #1552:
- environmentProviders: assert exactly 3 upstream calls per refresh again,
  so a returning fourth source would fail the test.
- firmsProxy: build the test's SOURCES from server/providers/firms.js, so
  there is one list instead of a mirrored copy.
- firmsProxy: drop the unused fourth count entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:57:27 -03:00
Rudy Celekli 79c867c8cb docs(changelog): add the perimeter size fragment for #1569 2026-11-04 22:14:16 -05:00
Rudy Celekli e2ccac56ae fix(perimeters): show a 0.25-acre fire as 0.25 ac, not 0 ac
The incident card rounded the WFIGS size to whole acres, so every fire
under half an acre read "0 ac". The live perimeter feed carries many of
these (0.01 to 0.37 acres). Sizes under one acre now keep the reported
hundredths.
2026-11-04 21:58:42 -05:00
quanticsoul4772 a1cee50d86 Merge branch 'main' into upstream/tools-heading-wrap 2026-11-04 12:46:59 -08:00
quanticsoul4772 c2a04c11b7 Merge branch 'main' into upstream/nominatim-cache-mode-windows 2026-11-04 12:46:44 -08:00
Itallo BrandãoandClaude Opus 5.5 b342e405d7 docs(changelog): add fragment for #1552
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:20:09 -03:00
Itallo BrandãoandClaude Opus 5.5 84c1fef8d3 fix(firms): stop requesting the retired Suomi NPP source
NASA stopped delivering Suomi NPP data on 2026-11-02 (FIRMS news page).
With a live MAP_KEY on 2026-11-04, VIIRS_SNPP_NRT came back ok with zero
rows while NOAA-20, NOAA-21 and MODIS returned 57k, 49k and 11k, so the
source only spent one transaction per refresh. The proxy now requests
NOAA-20, NOAA-21 and MODIS NRT. The "N" satellite label stays, so cached
or archived Suomi NPP rows still read SNPP.

Tests count sources from the list instead of assuming four, and a
regression test keeps the retired source out of the production list.

Refs #849

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:15:34 -03:00
quanticsoul4772 29a95211c5 Merge branch 'main' into upstream/tools-heading-wrap 2026-11-04 07:57:42 -08:00
quanticsoul4772andClaude Opus 5.5 1c2cdc6775 changelog: Nominatim cache mode test on Windows (#1547)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:06:45 -08:00
quanticsoul4772andClaude Opus 5.5 4f43ded5da test(nominatim): check cache file modes only where POSIX modes exist
The disk-cache test asserts the cache directory is 0o700 and its files
0o600. Windows has no POSIX mode bits (the directory stats as 0o666), so
the test always failed there. Both checks now run only off Windows, as
localServices.test.mjs and mcpPanelKey.test.mjs already do for their
0o600 checks. The cache code is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:05:17 -08:00
quanticsoul4772 13c3b011be Merge branch 'main' into upstream/tools-heading-wrap 2026-11-03 20:30:35 -08:00
daikaginza a64b41b3f0 Merge upstream/main 73c14ac74 2026-11-04 11:21:01 +07:00
daikaginza 0e0557e7f5 Merge upstream/main 73c14ac74 2026-11-04 11:21:01 +07:00
Dylan Maniatakes 134597c3cf docs: add S2 age filter changelog for PR 1528 2026-11-03 13:23:40 -05:00
Dylan Maniatakes 36fceb5675 Add adjustable event age filtering to S2 GhostMaps 2026-11-03 13:15:33 -05:00
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever 6dca8d943a Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr766 2026-11-03 16:17:34 +00:00
Said Turksever 4e40594d4f Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr762 2026-11-03 16:17:34 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 50cd03eb03 fix: resolve review findings and refresh PR #766 against main 2026-11-03 16:17:34 +00:00
Said Turksever b47c91965c fix: resolve review findings and refresh PR #762 against main 2026-11-03 16:17:33 +00:00
Said Turksever 90f9050b4c Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr1524 2026-11-03 16:17:32 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
Said Turksever 5f686b7289 fix: resolve review findings and refresh PR #1524 against main 2026-11-03 16:17:24 +00:00
Said Turksever b7de7c1113 Merge branch 'main' into fix/taiwan-highway-cctv-fallback 2026-11-03 16:11:35 +00:00
Said Turksever 44581dccaf Merge remote-tracking branch 'upstream/main' into pr1506-fix
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-03 15:57:37 +00:00
Said Turksever db8afbae81 fix(cctv): key-gate Ottawa, opt-in Québec video, keep shared slots
Review fixes for #1506.

- Ottawa: use the documented camera list and the city's Traffic Camera
  Images API. The pack is skipped with no request unless
  OTTAWA_CAMERA_CERTIFICATE is set; the certificate is added only at
  fetch time inside the pinned /opendata/ path, and each camera is
  fetched at most once a minute through the shared frame cache.
- Québec 511 video is opt-in (CCTV_QUEBEC_VIDEO_ENABLED=1) and loads
  only after the viewer presses Play; selection, auto-hop, scenes and
  voice no longer contact Québec 511. Credit splits locations (MTQ,
  CC BY 4.0) from video (Québec 511). Interrupted play() is no longer
  reported as an unavailable source; the per-frame listener runs only
  while a Québec camera is selected and the canvas rect is cached.
- Catalog cap: reserved Québec and World slots come only from capacity
  above the 4,000 shared slots, so a lower CCTV_MAX_SOURCES thins every
  pack instead of starving the existing ones. dev-fresh and
  .env.example default to 7,516.
- Throttled frames (World, Ottawa): inside the refresh floor with no
  cached frame the proxy serves the placeholder, never Street View; a
  failed fetch backs off for a minute; the cache is bounded by bytes.
- MJPEG first-frame extraction runs only for sources registered as
  MJPEG (Taiwan Highway Bureau), caps a frame at 2 MB, reads in linear
  time, accepts a CRLF preamble and a boundary that already starts with
  "--", and ends a part without Content-Length at the next delimiter.
  THB hosts are registered in the redirect pin list.
- Tests: World loader policy, Québec loader and opt-in, proxy cooldown
  without Street View, Ottawa signing, cap reservations at 7,516, 4,000
  and 48, overlay Play gate, refresh, visibility and teardown. Guard
  tests check the 4,000 shared slots again.
- Remove the unused standalone Québec preview module and public page.
- Docs: DATA_SOURCES, SECURITY, CURRENT-STATE, CCTV_INTEGRATION, README,
  .env.example, changelog fragment renamed to its full scope.
2026-11-03 15:31:08 +00:00
quanticsoul4772andClaude Opus 5.5 ccd83c5fa2 docs(tools): say heading values wrap into 0-359, not that 359.5 and up read 0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:00:27 -08:00
quanticsoul4772andClaude Opus 5.5 d1ed2e8e6f changelog: tool headings wrap at 360 (#1524)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:49:55 -08:00
quanticsoul4772andClaude Opus 5.5 ae55963a0e fix(tools): wrap whole-degree headings at 360 to 0
The query tools rounded courses, headings, bearings and azimuths with
Number(value.toFixed(0)) and no wrap, so a direction of 359.5 degrees or
more came back as 360: find_vessel with course 359.7 and heading 359.6
returned course_deg 360 and heading_deg 360.

Round them through the shared roundHeadingDegrees helper the layers and
UI already use (src/data/headingDegrees.js), which wraps into 0 to 359:
course_deg and heading_deg (vessels), course_deg (aircraft), bearing_deg
(transit), heading_deg (cameras), and rise_azimuth_deg and azimuth_deg
(satellites). Other rounding is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:32:07 -08:00
German Khokhlov 92666fdbaf fix(cyclones): preserve movement in selected voice context 2026-11-03 15:10:20 +04:00
InoukT fa4ce4b2a5 feat(cctv): add Quebec, Ottawa, and World feeds 2026-11-02 22:57:22 -05:00
Sameh Khamis bc61a39738 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 17:01:07 -08:00
Sameh Khamis 9ab54675ba Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	scripts/package-boundaries.json
2026-11-02 17:01:02 -08:00
Sameh Khamis 3cecc8efbe Offer a minute a second as a play speed
Between real time and an hour a second there was nothing to watch a few
minutes of weather or a launch play out at. The speed menu now offers a
minute a second as its second choice.
2026-11-02 16:47:35 -08:00
Sameh Khamis 8eab6a76a8 Drag the timeline along, and play through it smoothly
The zoomed track now drags: the handle still drags the time, while the
track around it drags the view along (or the time, with all of it shown),
a tap goes to the time under it, two fingers pinch it, and a sideways swipe
or a shift-wheel drags it too, the same with a mouse, a trackpad or a
finger. A view dragged off the handle stays put until the time moves.

Play moved in steps: the world clock told of its time four times a second,
so the handle and the globe jumped, and play stopped at every weather frame
while it drew, loaded ahead or not. The clock now tells of its time each
animation frame, and play no longer waits on the frame it is at: that
frame loads as play goes on, and play holds only at the next frame while
the one before it still loads. Weather loads its next frame ahead while the
world plays too, tells its listeners of frames as they change rather than
every frame, and the sun and moon move on each minute of world time.
2026-11-02 16:13:27 -08:00
Sameh Khamis 4fc9722356 Show a spinner by the time while play waits on data
Play holds while a layer loads the frame for its time, and the bar's only
sign of it was a faint pulse, so a stopped handle read as a stall. The
bubble now shows a small spinner beside the time while play is held. It
comes on once a hold lasts long enough to notice and stays through a run of
slow frames, so neither a quick frame nor the gaps between frames flash it.
2026-11-02 15:11:47 -08:00
Sameh Khamis 287c881c17 Zoom the timeline track around the handle
With layers of very different reach on together, a month of launches and
a few hours of lightning, the short-range ticks were crushed at the end of
the one shared track. Scroll or pinch on the track now zooms it around the
handle, plus and minus zoom from the keyboard, and zero or a double-click
shows it all again. Everything still plays together; zooming only changes
what the track shows, and the view follows the handle as it plays, steps or
is left behind by now. A scrub's live edge goes by the stretch shown, so a
zoomed track can land minutes from now, and a double-click or pinch puts
back the time its first press moved.
2026-11-02 13:53:08 -08:00
Sameh Khamis 497016b084 Add the changelog entry for launches, speed and buffering on the timeline 2026-11-02 13:17:28 -08:00
Sameh Khamis 724109ee17 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:17:08 -08:00
Sameh Khamis 09d833ed96 Add the changelog entry for the world clock and timeline 2026-11-02 13:17:06 -08:00
Sameh Khamis 0d96fe6acd Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:14:32 -08:00
Sameh Khamis 32e08f5043 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-02 13:14:32 -08:00
InoukT 72ceeb2fce Merge branch 'main' into fix/taiwan-highway-cctv-fallback 2026-11-02 12:43:17 -05:00
Sameh Khamis 056b3b58c6 Wait for each frame while the world timeline plays, and quiet the bar
Play no longer runs ahead of the data. The world clock gains a wait, which
holds continuous play where it is while a layer loads what the time needs,
and a gate, which keeps play from passing a time a layer has yet to load.
Weather gates play at its next frame and waits there until the frame shows,
so every frame is seen however fast play runs; it waits no more than eight
seconds on a frame that never comes, and takes a wait only when its frames
change. Play pressed while a frame loads waits for it too. A scrub still
drops what loads and moves on.

The bar loses its long date and the dates at its ends: the time rides in a
bubble over the handle, which pulses while play waits, and the bar runs
left to right in every language. Play starts at real time.
2026-11-02 08:03:46 -08:00
Sameh Khamis d04e304502 Choose how fast the world timeline plays
A speed menu beside play offers real time, an hour, a day or a week a
second, a day a second to start with. A speed chosen while playing applies
at once; chosen while weather's own replay steps its frames, it takes
playback over from where it is. Play starts again from the top only at live
or at the very end, so a pause just before the end plays on. On phones the
menu sits beside the track, a full-size target.
2026-11-02 07:41:50 -08:00
Sameh Khamis e7973e81cf Stop zooming out while the globe is still in view
Cesium sets no limit, so zooming out left the globe a speck among the stars.
On the globe a hand now stops zooming out where the globe spans 30% of the
screen's height, which follows the screen's shape as the app's own
whole-globe view does. A camera already past it, after a flight or a turned
screen, stops there rather than jumping in at the next zoom. Flat maps keep
zooming out unlimited.
2026-11-02 07:27:53 -08:00
Sameh Khamis 0aa54dfa97 Bring launches up as the world time passes them, and play the whole track
Away from live, a launch shows from its lift-off on: its marker, label,
paths and orbit stay hidden while the world time is before it, so scrubbing
or playing brings the launches up in the order they flew. The selected
mission stays whenever it flies, and live shows every launch as before.

Play on the timeline runs the whole track in about a minute, from its start
when at live, and runs the clock itself past weather's own replay. It goes
live when it reaches now, or holds at an end that lies ahead of now. Going
live puts the rate back to 1.
2026-11-02 07:21:01 -08:00
InoukT 16965c4fbc docs(changelog): add Taiwan CCTV fallback entry 2026-11-02 10:00:13 -05:00
InoukT ed2aaa2c50 fix(cctv): add Taiwan Highway Bureau fallback 2026-11-02 09:58:57 -05:00
Sameh Khamis 48093108b1 Make the world timeline a scrubber under the dock
The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
2026-11-01 23:37:12 -08:00
Sameh Khamis 9faec3028e Show the world time on the HUD clock
The HUD's UTC and collection readouts follow the world clock with the sky,
so away from live they read the time the globe is drawn at.
2026-11-01 23:17:53 -08:00
Sameh Khamis 3534398771 Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from
the start; live feeds such as flights, military, vessels and transit stay
live. Launches register the span of the launches loaded, so turning them on
brings up the timeline. Each timed layer registers its coverage once and
reports none while off, telling the clock after each step has settled. The
launch feed keeps its own window, and an orbit ring is drawn again once the
world time has moved far from when it was drawn.
2026-11-01 22:53:51 -08:00
Sameh Khamis 690a6637e2 Show the timeline only while a layer with time to show is on
The bar now needs more than the world clock being away from live: a layer
that moves with the clock must be on with time to show. In Core that is
weather radar, satellite or lightning with frames; a host adds layers by
following them. Turning the last one off hides the bar; turning it back
on brings it back.

The world clock reports this as `supported` in its state. Weather products
register their frames as coverage one by one, suspended or not, so the way
back to live stays on screen with reduced motion. Weather refreshes and
satellite catalog changes tell the clock their coverage may have changed.
A coverage that throws counts as none, and the clock reads its state once
per notification round.
2026-11-01 22:36:45 -08:00
Sameh Khamis 434fc9602d Show a basic timeline while the world time is away from live
A bar above the command dock, like a video player's: play and pause on
the left, the world time's date and time in UTC beside it, and Live to
return. It stays hidden while live, so Core looks as before until weather
history is scrubbed or replayed, a host moves the clock, or a link made
away from live is opened. Adds the pause and play_arrow glyphs and the
strings in every catalog.
2026-11-01 21:55:33 -08:00
Sameh Khamis 32ec6ed436 Share links carry the world time when made away from live
Field 28, worldTime, holds the world time of a link made away from live:
zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the
link's creation (a forecast moment). Opening such a link pauses the world
clock at that time; a link without it leaves the clock alone, so a page
opens live and Back or Forward does not stop a replay. Times no JS Date
can hold are dropped.
2026-11-01 21:50:09 -08:00
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
2026-11-01 21:47:32 -08:00
BreezeDelegate bbfb23a765 fix(ci): run allocation gates on Node 26 2026-10-25 03:15:11 +01:00
daikaginza 5f23c785ca Merge upstream main 9d3548c27 into fix/api-method-guard
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/tooling/liveProviders.test.mjs
2026-10-24 13:21:09 +07:00
daikaginza 73f03771d8 Merge upstream main 9d3548c27 into feat/opensky-compress-cached-body
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/tooling/liveProviders.test.mjs
2026-10-24 13:20:59 +07:00
daikaginza a160a9e953 Merge remote-tracking branch 'upstream/main' into feat/opensky-compress-cached-body
# Conflicts:
#	server/providers/aircraft/opensky.js
#	src/tooling/liveProviders.test.mjs
2026-10-23 13:30:47 +07:00
daikaginza 6a086cb16f Merge remote-tracking branch 'upstream/main' into fix/api-method-guard
# Conflicts:
#	src/tooling/liveProviders.test.mjs
2026-10-23 13:26:42 +07:00
Kevin Rajan deb02f635e Merge current upstream main into explicit cyclone context PR
Preserve reviewed selection event-ordering semantics and real-store tests.
Register the new app-layer wrapper in both package boundary sections.
2026-10-22 17:39:51 -05:00
Said Turksever c6decfced4 Merge latest main before publishing PR #847 fixes 2026-10-20 14:42:17 +01:00
Said Turksever b358b5dba1 fix(cyclones): distinguish deliberate clears from feed eviction 2026-10-20 14:17:39 +01:00
Said Turksever 1330605c18 Merge main into PR #847 preparation candidate 2026-10-20 14:14:38 +01:00
Said Turksever 9a5cafca27 Merge upstream/main into exp/cyclone-shared-context 2026-10-19 14:23:54 +01:00
daikaginza 6977770067 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/795-opensky-compress-cached-body.md.
2026-10-18 13:36:42 +07:00
daikaginza 2b7c4d1c65 Merge upstream/main 93ea088f4 2026-10-18 13:36:42 +07:00
daikaginza d3370d6bc4 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/794-api-method-guard.md.
2026-10-18 13:36:40 +07:00
daikaginza 1133e6327a Merge upstream/main 93ea088f4 2026-10-18 13:36:40 +07:00
Kevin Rajan 0e2ae76f0a test(cyclones): assert source-owned context label 2026-10-17 03:36:09 -05:00
Kevin Rajan 3a7c7b7c6a fix(cyclones): keep selected names source-owned for i18n 2026-10-17 03:36:07 -05:00
Kevin Rajan abaaf0c254 fix(cyclones): reconcile explicit shared selection with current main
Carry reviewer-verified stale A-to-B record eviction, explicit selection
and refresh semantics over latest i18n/cyclone source ownership.
2026-10-17 03:31:28 -05:00
Kevin Rajan a803be1af2 fix(cyclones): evict prior shared selection on storm switch
Regression: explicit storm A→B→A keeps one active record.
Reviewer finding: quanticsoul4772 (#847).
2026-10-16 23:01:44 -05:00
daikaginza 46717548c4 Merge remote-tracking branch 'upstream/main' into fix/api-method-guard
main renamed /api/opensky to /api/flights and /api/adsblol/mil to
/api/military, and each mount now also matches its /track route, handing it on
with next(). The GET check therefore sits after that hand-off, so a /track
request is never answered with the snapshot route's 405; a new test pins that,
and fails if the check is moved above the hand-off. Tests and notes use the new
paths.
2026-10-16 09:31:49 +07:00
daikaginza 85ce864bc4 Merge remote-tracking branch 'upstream/main' into feat/opensky-compress-cached-body
main renamed the flights proxy mount from /api/opensky to /api/flights. Git
took the new mount cleanly but left this branch's own tests and notes on the
old path, and the compression test then failed. They now use /api/flights.
2026-10-16 09:24:33 +07:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
Kevin Rajan 28f3243090 feat(cyclones): publish explicit selection to shared context 2026-09-29 00:20:16 -05:00
daikaginza e7dd71e9bc fix(server): read the whole q value, not a numeric prefix
Number.parseFloat accepts any valid numeric prefix, so `gzip;q=1oops` read as 1
and the out-of-range `gzip;q=2` was accepted — both contradicting the comment
two lines above, which promises that a quality this cannot read confidently
fails closed. Matching the qvalue grammar of RFC 9110 section 12.4.2 makes the
code do what it already claimed to do.

Thanks to kvnloo for catching it on review.
2026-09-29 11:13:36 +07:00
daikaginza 47b13ab18e docs(changelog): note OpenSky snapshot compression (#795) 2026-09-29 10:58:18 +07:00
daikaginza daed0be99a feat(server): compress the cached OpenSky snapshot for clients that accept gzip
The ~860 KB snapshot is held for an adaptive 9-300 s TTL and was served as
identity bytes to every request; no response in server/ set Content-Encoding or
Vary. Gzip it once per refresh beside the cached body, off the event loop, and
return the body and the header that names it together so the two can never
disagree. Live against an anonymous snapshot: 874,002 B to 258,012 B, 3.39x,
decompressing byte-identical to the uncompressed response.
2026-09-29 10:57:45 +07:00
daikaginza 2e5dfce1af fix(server): declare Allow: GET on the new 405 responses
RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts, and
/api/radio/stations already does this in-tree. Thanks to kvnloo for catching it
on review. The same gap on the pre-existing handlers is tracked in #809 and is
deliberately left alone here.
2026-09-29 10:56:20 +07:00
daikaginza fb6b4e34f0 docs: changelog entry for the read-only proxy method contract (#794) 2026-09-29 10:52:57 +07:00
daikaginza b09db55149 fix(server): declare the method contract on the three read-only query proxies
/api/opensky, /api/adsblol/mil and /api/route answered any verb with the same
body while ignoring the request entirely. Measured against a running server
before the change: POST /api/opensky returned 200 with 833,759 bytes of live
state vectors, POST /api/adsblol/mil returned 47,372 bytes. Their siblings do
not: /api/geocode, /api/weather and /api/transit answer 405, and /api/overpass
is deliberately POST-only.

These three are read-only query proxies, so they now answer 405 to anything but
GET, using the wording and shape /api/geocode already uses. The refusal comes
first, before rate limiting and before any upstream call, which the tests
assert by stubbing fetch to throw.

Scope, stated plainly: this is a consistency and intent fix. No rate limiter
was being bypassed — /api/route already limits every request regardless of
verb, and the two aircraft routes have no limiter at all, only 9s and 12s
server-side caches that bound upstream cost for any method.

One existing fixture in src/search/geospatial.test.mjs built a request without
a method, which a real Node request always has; it now sets GET.

Verified on a running server after the change: GET still 200 on all three,
POST 405 on all three, /api/overpass and the other proxies unchanged.
Suite goes from 5090 to 5092 tests, 0 failures.
2026-09-29 10:52:26 +07:00
monwissandClaude Opus 5.5 da689e03ac refactor(providers): move the tile budget helpers out of the TomTom module
utcDayKey, normalizeBudget and isOverBudget move unchanged to
src/data/tileBudget.js. Nothing in them was TomTom-specific; the next
keyed tile proxy (Xweather) counts a monthly allowance through the same
two functions. tomtomTiles.js re-exports them, so the traffic provider
and its tests keep their imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 11:34:40 -04:00
604 changed files with 75058 additions and 17340 deletions

No files matched your search

+9 -6
View File
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
# 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
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
# 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
@@ -238,6 +239,8 @@ 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_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views 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
@@ -268,15 +271,15 @@ AISSTREAM_API_KEY=
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
# Optional: Data Library (DATA LAYERS ▸ Browse data library). Every entry is
# fetched live from its provider; nothing is hosted here. See docs/DATA-LIBRARY.md.
# GEV_LIBRARY_ENABLE= # comma-separated ids of operator opt-in entries to serve (packs use their host's own switch, e.g. CCTV_NYC_ENABLED)
# GEV_LIBRARY_DISABLE= # comma-separated ids to remove from this instance entirely
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
OVERPASS_UPSTREAMS=
-4
View File
@@ -53,10 +53,6 @@ jobs:
if: github.event_name == 'pull_request'
run: npm run layer-token:check -- --base-ref origin/main
- name: Check Data Library manifests and share tokens
if: github.event_name == 'pull_request'
run: npm run library:check -- --base-ref origin/main
- name: Run unit tests
run: npm test
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
- An application's own tool can take the pointer as Draw does, and its click
handlers can yield while a tool holds it, as the layers' do (`pointer` from
`createApplicationTools`) (samehkhamis, #1641).
## [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
+3 -4
View File
@@ -128,9 +128,8 @@ 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 a Data Library entry.** A regional or niche source that is not a core layer belongs in the Data Library: one JSON manifest pointing at a live API, provider-hosted file or tile endpoint, with its licence and attribution, and no JavaScript. See [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). Library entries use their own share-token ledger, not the layer tokens below.
- **🛰️ 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 a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
- **🛰️ 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).
@@ -229,7 +228,7 @@ ownership and adoption process.
1. Branch off `main`.
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
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 (for a Data Library entry, `npm run library:docs` writes its row). **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
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).
+74 -126
View File
@@ -30,8 +30,9 @@ How to read this:
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
@@ -65,9 +66,11 @@ How to read this:
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
| **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 |
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
| **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" |
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
| **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) |
@@ -101,6 +104,40 @@ How to read this:
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy |
| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana |
| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio |
| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) |
| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) |
| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT |
| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) |
| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku |
| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton |
| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax |
| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit |
| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) |
| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie |
| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie |
| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) |
| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) |
| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità |
| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) |
| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. |
| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. |
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro |
| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA |
| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) |
| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca |
| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași |
| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów |
| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga |
| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris |
| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my |
| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my |
| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) |
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
@@ -122,7 +159,8 @@ 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).
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
- **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)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
@@ -163,13 +201,16 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
contributors, ODbL 1.0; GPSJAM for aggregation only.
- **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.
- **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 (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), 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.
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
- **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 `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.
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
@@ -178,6 +219,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **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`.
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_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.
@@ -188,7 +230,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_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 eighteen registered feeds, covering seventeen 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, YRT and Rio’s SMTR 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.
- **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 fifty-two registered feeds, covering forty-nine 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, YRT and Rio’s SMTR 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 country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/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; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist 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.
@@ -209,124 +251,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
---
<!-- data-library:start (generated by npm run library:docs) -->
## Data Library entries (fetched at runtime — not stored in this repo)
Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.
| Entry | How it is served | Licence / terms | Attribution |
| --- | --- | --- | --- |
| **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" |
| **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" |
| **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" |
| **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" |
| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" |
| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" |
| **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" |
| **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" |
| **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" |
| **Florida cameras (FL511)** (`cctv-florida-fl511`, `fl511.com`) | CCTV pack; operator sets `CCTV_FLORIDA_ENABLED=1` | [FL511 content: individual personal use only](https://fl511.com/privacy#disclaimer) | "Florida DOT — FL511.com" |
| **Lithuania road cameras (Via Lietuva)** (`cctv-lithuania`, `eismoinfo.lt`) | CCTV pack; operator sets `CCTV_LITHUANIA_ENABLED=1` | [Via Lietuva open-data statement; camera photo coverage unconfirmed](https://eismoinfo.lt/eismoinfo-backend/content?id=44) | "Via Lietuva / eismoinfo.lt" |
| **New York City cameras (NYC DOT TMC)** (`cctv-nyc-dot`, `webcams.nyctmc.org`) | CCTV pack; operator sets `CCTV_NYC_ENABLED=1` | [NYC DOT camera feed: data-sharing agreement required](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) | "NYC DOT Traffic Management Center" |
| **Connecticut Statewide Imagery (2019)** (`ct-statewide-imagery`, `cteco.uconn.edu`) | tiles, bbox, browser-direct; operator adds `ct-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/) | "CT ECO — UConn CLEAR / CT DEEP" |
| **Dakota County, Minnesota Air Photo** (`dakota-county-mn`, `gisimg.co.dakota.mn.us`) | tiles, bbox, browser-direct; operator adds `dakota-county-mn` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis.co.dakota.mn.us/) | "Dakota County GIS" |
| **District of Columbia Statewide Imagery (2025)** (`dc-statewide-imagery`, `imagery.dcgis.dc.gov`) | tiles, bbox, browser-direct | [Open data — DC public domain terms](https://opendata.dc.gov/pages/terms-of-use) | "Office of the Chief Technology Officer" |
| **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." |
| **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" |
| **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" |
| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" |
| **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" |
| **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" |
| **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" |
| **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" |
| **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" |
| **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" |
| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" |
| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" |
| **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" |
| **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
| **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
| **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" |
| **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" |
| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" |
| **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" |
| **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" |
| **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" |
| **Maritime chokepoints (IMF PortWatch)** (`imf-portwatch-chokepoints`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-chokepoints` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org)" |
| **Port disruptions (IMF PortWatch)** (`imf-portwatch-disruptions`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-disruptions` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org), with GDACS event data" |
| **Indiana Statewide Imagery (2016)** (`in-statewide-imagery`, `di-ingov.img.arcgis.com`) | tiles, bbox, browser-direct | [CC0 1.0 Universal (public domain dedication)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | "Indiana Geographic Information Office (CC0)" |
| **Surface water occurrence (JRC)** (`jrc-surface-water`, `storage.googleapis.com`) | tiles, xyz, browser-direct | [Copernicus programme: free, without restriction of use](https://global-surface-water.appspot.com/download) | "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water" |
| **Norway counties and municipalities (Kartverket)** (`kartverket-admin-units`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway cadastral map (Kartverket)** (`kartverket-cadastre`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway sea depths (Kartverket)** (`kartverket-depth`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway hillshade (Kartverket)** (`kartverket-hillshade`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway nautical charts (Kartverket)** (`kartverket-nautical-charts`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway terrain relief (Kartverket DTM)** (`kartverket-terrain-relief`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic map (Kartverket)** (`kartverket-topo`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic map, greyscale (Kartverket)** (`kartverket-topo-grey`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Norway topographic raster map (Kartverket)** (`kartverket-toporaster`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
| **Kansas Statewide Imagery (2021)** (`ks-statewide-imagery`, `services.kgs.ku.edu`) | tiles, bbox, browser-direct; operator adds `ks-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.kansasgis.org/) | "FSA, USDA," |
| **Kentucky Statewide Imagery** (`ky-statewide-imagery`, `kyraster.ky.gov`) | tiles, bbox, browser-direct | [Public domain with attribution](https://kygeonet.ky.gov/) | "KyFromAbove" |
| **Lake County, Indiana Aerial Imagery** (`lake-county-in`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lake-county-in` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lakecountyin.org/) | "Lake County Surveyor's Office" |
| **Lawrence, Kansas Aerial Photos** (`lawrence-ks`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lawrence-ks` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis-lawrenceks.opendata.arcgis.com/) | "City of Lawrence GIS" |
| **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" |
| **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" |
| **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" |
| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" |
| **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" |
| **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" |
| **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" |
| **MARIS Imagery, Mississippi** (`maris-ms`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `maris-ms` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.ms.gov/) | "MARIS" |
| **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" |
| **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" |
| **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" |
| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" |
| **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" |
| **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" |
| **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" |
| **NAIP — National Agriculture Imagery Program** (`naip`, `imagery.nationalmap.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://naip-usdaonline.hub.arcgis.com/) | "USDA NAIP" |
| **Earth at night (NASA Black Marble 2016)** (`nasa-black-marble`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.earthdata.nasa.gov/engage/open-data-services-software-policies) | "NASA Earth Observatory / GIBS — VIIRS Black Marble" |
| **NASA EONET open natural events** (`nasa-eonet-events`, `eonet.gsfc.nasa.gov`) | dataset, geojson, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.nasa.gov/nasa-brand-center/images-and-media/) | "NASA Earth Observatory Natural Event Tracker (EONET)" |
| **North Carolina Statewide Imagery (2023)** (`nc-statewide-imagery`, `services.nconemap.gov`) | tiles, bbox, browser-direct | [Public domain — NC OneMap terms of use](https://www.nconemap.gov/pages/terms) | "NC911, NCCGIA" |
| **North Central Texas (NCTCOG) Ortho** (`nctcog-tx`, `mapitimage.tarrantcounty.com`) | tiles, bbox, browser-direct; operator adds `nctcog-tx` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nctcog.org/) | "North Central Texas Council of Governments" |
| **North Dakota Statewide Imagery (2013)** (`nd-statewide-imagery`, `ndgishub.nd.gov`) | tiles, xyz, browser-direct; operator adds `nd-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.nd.gov/) | "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub" |
| **New Orleans Regional Planning Commission Imagery** (`new-orleans-rpc-la`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `new-orleans-rpc-la` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.norpc.org/) | "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)" |
| **New Hampshire Statewide Imagery (2022)** (`nh-statewide-imagery`, `nhgeodata.unh.edu`) | tiles, bbox, browser-direct; operator adds `nh-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — NH GRANIT terms](https://www.granit.unh.edu/) | "NH GRANIT, University of New Hampshire" |
| **New Jersey Statewide Imagery (2015)** (`nj-statewide-imagery`, `maps.nj.gov`) | tiles, xyz, browser-direct; operator adds `nj-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — State of New Jersey provides data 'as is'](https://njgin.nj.gov/) | "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by" |
| **US tide stations (NOAA CO-OPS)** (`noaa-tide-stations`, `api.tidesandcurrents.noaa.gov`) | dataset, json, browser-direct | [NOAA data: U.S. public domain](https://tidesandcurrents.noaa.gov/disclaimers.html) | "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)" |
| **Digitale Orthophotos NRW (DOP 10 cm)** (`nrw-dop`, `www.wms.nrw.de`) | tiles, bbox, browser-direct | [Datenlizenz Deutschland – Zero (public-domain-like)](https://www.govdata.de/dl-de/zero-2-0) | "Geobasis NRW (dl-de/zero-2-0)" |
| **NSW Imagery (Six Maps)** (`nsw-imagery`, `maps.six.nsw.gov.au`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.spatial.nsw.gov.au/products_and_services/web_services) | "© Spatial Services NSW" |
| **US bridges and their condition (NBI)** (`ntad-national-bridges`, `services.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. government work: unrestricted public use](https://www.bts.gov/ntad) | "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory" |
| **Northwest Arkansas (Benton & Washington County) Imagery** (`nwa-benton-washington-ar`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `nwa-benton-washington-ar` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nwarpc.org/) | "NWARPC; Washington County; Benton County" |
| **US weather alerts with drawn areas (NWS)** (`nws-active-alerts`, `api.weather.gov`) | dataset, geojson, browser-direct | [NWS data: U.S. public domain](https://www.weather.gov/disclaimer) | "NOAA National Weather Service" |
| **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" |
| **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" |
| **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" |
| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" |
| **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" |
| **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" |
| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" |
| **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" |
| **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" |
| **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" |
| **Ortofotos de Portugal Continental (DGT)** (`pt-dgt-ortos`, `ortos.dgterritorio.gov.pt`) | tiles, bbox, browser-direct | [CC BY 4.0](https://www.dgterritorio.gov.pt/) | "© Direção-Geral do Território" |
| **Rhode Island Statewide Imagery (2019)** (`ri-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ri-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — RIGIS terms](https://www.rigis.org/) | "URI EDC, RIGIS" |
| **Switzerland aerial photos (SWISSIMAGE 10 cm)** (`swisstopo-swissimage`, `wmts.geo.admin.ch`) | tiles, xyz, browser-direct | [swisstopo free geodata (OGD): free use, including commercial, with source credit](https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices) | "©swisstopo — SWISSIMAGE 10 cm" |
| **Texas Statewide Imagery (2022)** (`tx-statewide-imagery`, `imagery.geographic.texas.gov`) | tiles, bbox, browser-direct | [Public domain — TxGIO open data](https://geographic.texas.gov/) | "Texas Geographic Information Office (TxGIO)" |
| **Union County, Ohio Aerial Imagery** (`union-county-oh`, `www7.co.union.oh.us`) | tiles, bbox, browser-direct; operator adds `union-county-oh` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://union-county-auditor-unionco.hub.arcgis.com/) | "Union County / City of Marysville GIS" |
| **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" |
| **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" |
| **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" |
| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" |
| **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" |
| **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" |
| **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" |
| **Global power plants (WRI)** (`wri-global-power-plants`, `raw.githubusercontent.com`) | dataset, csv, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "World Resources Institute, Global Power Plant Database (2021)" |
| **West Virginia Statewide Imagery** (`wv-statewide-imagery`, `services.wvgis.wvu.edu`) | tiles, bbox, browser-direct; operator adds `wv-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WV GIS Technical Center terms](https://wvgis.wvu.edu/) | "WV GIS Technical Center, West Virginia University" |
<!-- data-library:end -->
## Bundled snapshots
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
@@ -338,10 +262,15 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **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) |
| **Natural Earth populated places** (7,342 points: name, position, rank) | `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) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
@@ -397,11 +326,20 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21)
and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
transaction quota. Requires a free `FIRMS_MAP_KEY`
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`)
is not requested: NASA stopped delivering it on 2026-11-02.
### UCS Satellite Database (`ucs_satellites/`)
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
### Imaging sensor pack (`imaging_sensors/`)
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
### Natural Earth physical regions (`natural_earth/`)
@@ -422,6 +360,16 @@ 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).
### Natural Earth populated places (`natural_earth/populated_places.json`)
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
### Sky background (`sky/`)
Sources, licences, the changes CC BY asks to state, hashes and build commands
@@ -592,4 +540,4 @@ NOAA density imagery with the raw GLM product.
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
+7 -7
View File
@@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
---
@@ -436,15 +436,15 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
| 🌍 **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, 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) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · 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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; 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 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, Rio de Janeiro (buses and BRT), 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 | 🟢 |
| 🚌 **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, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, 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) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
**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.
**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 Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **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. 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.
**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 when its token is set; otherwise KartaView is. 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.
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`.
@@ -639,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
pack with `CCTV_DELDOT_ENABLED=0`.
The server allows two concurrent sessions. Each retains at most 12 segments
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
second deadline. There are no segment files or ffmpeg processes. Redirects,
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
Each decoder has its own client lease (at most eight per session), including
+3 -3
View File
@@ -69,14 +69,14 @@ browser-key fallback for existing single-key setups.
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
- **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.
- **The Data Library proxy fetches manifest upstreams only.** `/api/library/data/<id>`, `/api/library/tile/<id>/<z>/<x>/<y>` and `/api/library/index/<id>` (a partitioned GeoParquet entry's file index, built from an S3 ListObjectsV2 listing and one ranged footer read per file, cached in memory for the entry's `cacheSeconds`) resolve the id against the validated manifests in `src/library/entries/`; the browser never supplies a URL, and every derived request must stay on the manifest's https host. Redirects are refused, dataset bodies are capped while streaming at the manifest's `maxBytes` (at most 32 MiB) and tiles at 2 MiB of `image/png`, `image/jpeg` or `image/webp`, responses are cached in memory only (at most 512 tiles), requests are rate-limited per client, and upstream error text is never returned. A keyed entry must be proxied; its key is read from this server's environment through the key registry and its routes refuse cross-site and forwarded requests like the other cost-bearing routes. Browser-direct entries are fetched by the browser with no cookies or referrer.
- **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.
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
- **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.
- **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. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. 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.
+2 -17
View File
@@ -1,8 +1,8 @@
# Third-party software notices
The project depends on third-party npm packages whose own licenses apply. This
file records the packages added for the browser-local SDR feature, the
Street Level viewer and the Data Library's GeoParquet reader; the complete resolved dependency inventory remains in
file records the packages added for the browser-local SDR feature and the
Street Level viewer; the complete resolved dependency inventory remains in
`package-lock.json`.
## Web RTL-SDR
@@ -45,18 +45,3 @@ Street Level viewer and the Data Library's GeoParquet reader; the complete resol
- Source: <https://github.com/mrdoob/three.js>
- License: MIT
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
## hyparquet
- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library
GeoParquet entry is switched on)
- Author: Hyperparam
- Source: <https://github.com/hyparam/hyparquet>
- License: MIT
## fzstd (hyparquet ZSTD decompressor)
- Package: `fzstd` 0.1.1
- Author: Copyright (c) 2020 Arjun Barrett
- Source: <https://github.com/101arrowz/fzstd>
- License: MIT
@@ -0,0 +1,8 @@
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
Québec camera locations, City of Ottawa traffic cameras (needs a free city
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
image-backed cameras from Oregon and Japan (InoukT, #1506).
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
loads only after the viewer presses Play; enforce provider-specific frame
refresh limits, and report recognized MLIT maintenance images as degraded
instead of healthy camera snapshots.
+5
View File
@@ -0,0 +1,5 @@
- One world clock now drives everything with a time of its own: the sky,
the HUD clock, satellites, launches and weather. A timeline along the
bottom of the screen, shown while one of those layers is on, plays, pauses
and scrubs it, with a tick for each launch or frame and a way back to
live. Share links made away from live carry the time (samehkhamis, #1512).
@@ -0,0 +1,7 @@
- The timeline brings launches up as the time passes them, offers play
speeds from real time to a week a second, and plays smoothly, holding only
when the next weather frame has yet to load, with a spinner by the time
while it waits. The time rides in a bubble over the handle. The track zooms
with a scroll or a pinch, drags along, and goes to the time under a tap, on
a phone as on a desktop. Zooming out on the globe stops while the globe is
still in view instead of leaving it a speck (samehkhamis, #1513).
+3
View File
@@ -0,0 +1,3 @@
- The agent and voice query tools wrap whole-degree directions into 0–359, so
a vessel course or heading, aircraft course, transit bearing, camera heading
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
@@ -0,0 +1,4 @@
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
Preserve undated features, save the selected window and expire old events
while the map stays open. Thanks @dylanmaniatakes (#1528).
@@ -0,0 +1,3 @@
- The Nominatim disk-cache test checks the cache directory and file modes
only where POSIX modes exist, so it no longer always fails on Windows
(quanticsoul4772, #1547).
+4
View File
@@ -0,0 +1,4 @@
- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data
on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing
a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20,
VIIRS NOAA-21 and MODIS (IMBrandao, #1552).
+4
View File
@@ -0,0 +1,4 @@
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
with the arrow keys, and a waterline read from the scene so river boats sit on
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
+4
View File
@@ -0,0 +1,4 @@
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
Probes and Debris, each with a toggle; all but Starlink show by default.
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
(Denis Groshev, @ZloyS, #1558).
+4
View File
@@ -0,0 +1,4 @@
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
+4
View File
@@ -0,0 +1,4 @@
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
confirmed swath allows at the live altitude, with a to-scale swath strip, a
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
the TLE's age (Denis Groshev, @ZloyS, #1560).
@@ -0,0 +1,3 @@
- Show a fire perimeter under one acre at its reported size. The incident card
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
(Rudy Celekli, @rudycelekli, #1569).
@@ -0,0 +1,4 @@
- Say why Transit and Bike Share are empty outside coverage. Over a city with
no feed or system (London, Tokyo) their Layers rows say so and count the
covered regions or systems, or say how low to fly, instead of `never`;
leaving coverage clears the last-update time (German, @gekh, #1576).
@@ -0,0 +1,4 @@
- Space Missions no longer locks the workspace when its mission index fails to
load (429, timeout or bad payload): entry rolls back to the previous layers
and camera view and a toast says the index could not load (German, @gekh,
#1578).
+4
View File
@@ -0,0 +1,4 @@
- Hold the orbital camera on a mapped site as the satellite passes over it,
from a click on its marker or its Live Signals row, and list nearby sites
and satellites in orbit's Live Signals, where a row focuses a site or rides
a satellite (Denis Groshev, @ZloyS, #1580).
@@ -0,0 +1,4 @@
- Fix the translations of the voice area cards, the fire confidence filter,
the drone airspace layer and Sea Marks: one word for "landmark" per
language, the airspace layer names the airspace again, and Traditional
Chinese follows Simplified Chinese again (German, @gekh, #1581).
@@ -0,0 +1,4 @@
- Without a Mapillary token, Street Level starts on the first keyless source
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
the globe. A share link that turns a source off is respected
(asturksever, #1601).
-29
View File
@@ -1,29 +0,0 @@
- Add the Data Library. New regional and niche sources no longer join the
Data Layers panel: **Browse data library** at its foot lists live entries
(a manifest each in `src/library/entries/`, fetched from the provider, never
bundled) with their licence, coverage and what this instance needs to serve
them, and **Add** puts one under a Library heading. Datasets (GeoJSON, CSV,
ArcGIS), raster tiles and host-layer packs are supported; selections persist
and travel in share links as `lib=`, separate from the layer tokens; one
generic `/api/library` proxy serves entries without CORS. Seeded with NASA
EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations,
Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the
opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's
traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in
#251). FEMA USA Structures and the
National Bridge Inventory load what is in view once you zoom in, and
Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on
S3, coloured A–G with live legend counts. Open tile overlays (geology,
biodiversity, population, surface water, national aerial photos and maps)
join GDACS alerts and EPA Superfund and TRI facilities. 64 aerial photo
layers from the Open Imagery Register (national programmes across Europe,
NSW, NAIP and US state, county and city services, WMS and ArcGIS export
included) show their resolution, true-ortho flag, years flown and refresh
cycle in Browse; aerial photos stay above every other drape, are clipped to
their coverage, and the providers' blank fill no longer whitens the globe.
Kartverket's open Norwegian maps (topographic, nautical charts, hillshade,
terrain relief, sea depths, boundaries and the cadastre) and Mapterhorn's
global terrain, drawn in the browser as shaded relief with an elevation
legend, are in the library too. Stat tiles head the dialog. A library layer's row has
Zoom to layer and Remove icons, and the dialog has Zoom and a confirmed
Remove all (asturksever, #1604).
@@ -0,0 +1,4 @@
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
Lines draws each road with flow in green, amber or red, and Dots shows or
hides the moving dots. A host can choose which road modes it offers; without
OpenStreetMap the layer never draws simulated roads (#1609).
+2
View File
@@ -0,0 +1,2 @@
- French catalog review: missing narrow no-break spaces, apostrophes, one
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
@@ -0,0 +1,4 @@
- Typed chat on OpenAI answers again: OpenAI refused every message because
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
not accept. Chat leaves those patterns out; area ids are still checked
before the tool runs (samehkhamis, #1616).
+12
View File
@@ -0,0 +1,12 @@
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
and only links inside that directory are registered. The catalog is fetched
over https with redirects refused and a 2 MiB body cap. An explicit facing in
the view description ("til vesturs", "í suður", "séð austur") sets a
high-confidence heading; other views keep the low-confidence fallback pose.
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
@@ -0,0 +1,5 @@
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
4 MiB can play, while retaining the 24 MiB per-session cache and existing
origin and session limits (NeoXrated23, #1619).
+3
View File
@@ -0,0 +1,3 @@
- Turning CCTV on opens the camera nearest you instead of the first camera in
the catalog, which showed a Sydney camera in London. A share link opens on
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
+14
View File
@@ -0,0 +1,14 @@
- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern
Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris
Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań,
Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice
(buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre,
Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver,
Edmonton, Halifax and Victoria. Each feed records its licence, or that none
is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h
(Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids
read as the line on the vehicle; Prague metro, trains, trams and ferries,
Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice
waterbuses take their own mode. The Warsaw and Poznań credits carry the
data date their terms ask for, and the data.gov.my feeds are fetched at
most twice a minute to stay inside the API's rate limit (IMBrandao).
@@ -0,0 +1,3 @@
- An application built with fewer layers no longer restores, saves or shares a
layer it does not have when a link or saved state from a full build names it
(samehkhamis, #1634).
+4
View File
@@ -0,0 +1,4 @@
- Draw rows take an application's own actions: `createApplicationTools({
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
The standalone build passes none and is unchanged (samehkhamis, #1636).
@@ -0,0 +1,4 @@
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
satellite owner flags at startup from one generated 15 KB sheet, and name
"Place names" in the loading status while the place-name packs load on first
lookup (Sameh Khamis, @samehkhamis, #1637).
@@ -0,0 +1,5 @@
- Voice on OpenAI connects again: OpenAI Realtime refused every session, so
the mic failed with "Failed to create Realtime token", because
`resolve_area`'s area-id pattern uses Unicode classes it no longer accepts.
Voice leaves those patterns out, as typed chat does; area ids are still
checked before the tool runs (avaldini, #1638).
@@ -0,0 +1,7 @@
- Keep buses and trains off rooftops and out of bridge decks on the photoreal
3D tiles: the ground floor probes the rendered mesh where each vehicle is,
keeps each reading at the point it was taken (up to sixteen a cell) and
answers from the reading nearest the vehicle, instead of one probe at each
110 m cell's centre that lifted every vehicle in the cell onto whatever lay
there. Transit probes each vehicle's latest point, and grounded flights read
the floor at their own position (Sameh Khamis, @samehkhamis, #1642).
+6
View File
@@ -0,0 +1,6 @@
- Show a photo of the tracked aircraft. Tracking a civil flight asks
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
shows it with the photographer's credit and a link to the photo's page. A
flight lookup (type, route or photo) that failed temporarily is now asked
again after a minute instead of staying empty for the session, and a callsign
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
+4
View File
@@ -0,0 +1,4 @@
- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and
`isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so
other keyed tile proxies can share them. `src/data/tomtomTiles.js`
re-exports them; behaviour does not change (monwiss, #762).
@@ -0,0 +1 @@
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
+4
View File
@@ -0,0 +1,4 @@
- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on
`/api/flights`, `/api/military` and `/api/route`, as the other query proxies
do. They answered every verb with the same body. The refusal comes before
rate limiting and any upstream call (daikaginza, #794).
@@ -0,0 +1,5 @@
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
cached global snapshot and its regional adsb.lol fallback once per refresh
and sends that frame to clients that offer gzip: about 860 KB becomes about
250 KB. Other clients get the same bytes uncompressed, and both answers carry
`Vary: Accept-Encoding` (daikaginza, #795).
@@ -0,0 +1,3 @@
- Let the voice assistant read the storm you select in Cyclone advisories:
its official advisory, position, intensity and movement. Clearing the
selection and the storm leaving the feed are reported apart (kvnloo, #847).
+4
View File
@@ -0,0 +1,4 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
+23
View File
@@ -0,0 +1,23 @@
[
{
"id": "ro-giroc-webcamromania",
"name": "Giroc — Webcam Romania",
"city": "Giroc, Timiș",
"cityId": "giroc",
"provider": "Webcam Romania · webcamromania.ro",
"sourceKind": "configured",
"feedType": "hls",
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
"lat": 45.70028947266781,
"lon": 21.233675914004227,
"headingDeg": 0,
"headingConfidence": "low",
"pitchDeg": -17,
"fovDeg": 74,
"rangeM": 700,
"mountHeightM": 24,
"groundElevationM": 87,
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
}
]
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
# CCTV integration workflow
Use this workflow when adding a camera provider or repairing an existing feed.
The CCTV catalog comes through the local server; media normally does too. Keep
provider-specific URL policy on the server, including any validated browser-only
media URL permitted by the exception below.
## 1. Check the source before coding
- Identify the catalog endpoint and the actual frame or live-media endpoint.
They may be different hosts or use different formats.
- Read the provider's license, terms, attribution, rate limits, and access
requirements. Publicly reachable data is not automatically licensed for
reuse. Record whether the app may display it and whether any use is
restricted.
- Prefer a public, keyless feed. If credentials are required, keep them in a
server-only environment variable; never put a secret in browser code or a
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
the variable unset it makes no request and logs once (Ontario 511 and
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
the provider's documented API, not a website endpoint that skips its key. If
access terms require an agreement or remain unconfirmed, make the pack
opt-in and ensure it makes no request by default.
- Confirm the feed can be fetched from the app's Node server environment. A URL
opening in a browser does not prove the server can reach it, and browser CORS
behavior does not diagnose a server-side fetch.
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
as discovery aids. Trace each listing to the original agency or camera owner;
their data and media terms govern reuse. For example, SkylineWebcams says
only camera hosts may embed its live feed and its terms restrict copying or
retransmitting footage, so do not proxy its stream without written
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
- Decide whether the provider supplies still snapshots or continuous video.
Prefer stills unless live video is necessary and supported by the existing
proxy path.
## 2. Add and register the provider pack
Use the smallest existing provider as a template. Current owners are:
- `server/providers/cctv/sources.js` for standard open-data loaders.
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
fallback, or more involved URL policy.
- `server/providers/cctv/catalog.js` to import the loader and add it to
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
allocates space across packs.
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
timeouts.
Return normalized camera records with stable unique IDs, readable names,
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
`feedType`. Include camera heading only when the source actually provides a
bearing; descriptive direction text is not a numeric heading. Mark uncertain
heading data with the existing low-confidence conventions. Set `sourceKind` so
health and source diagnostics identify the provider. Apply a bounded per-pack
camera limit and geographic filtering where appropriate; avoid one provider
silently consuming the whole shared catalog cap.
Camera coordinates need their own verification. Prefer the original provider's
camera coordinates, then corroborate them against its location description,
route/mile marker, or official map. An aggregator's map dot, a town/county name,
or a geocoded attraction name is not a precise camera position. Do not pin a
camera to a city center or nearby landmark; if its actual location cannot be
verified, leave it out until it can. For manually curated coordinates, record
the evidence in the provider data or an adjacent comment. For example, TW Live
camera pages identify the originating agency; use that agency's catalog for
the camera point when available.
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
enabled-by-default flag pattern and document how to disable the pack.
## 3. Keep frame and media requests server-registered
The browser should request the local `/api/cctv/frame/:id` or
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
browser, and do not fetch an unvalidated URL supplied by a catalog record.
Every provider must have a server-side rule that pins frame and media URLs to
the expected scheme, host, and path; use HTTPS where the provider supports it.
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
or a stricter provider-specific validator when hosts or paths vary. Register
any new permitted image/media origin in the existing
`server/providers/cctv` media policy.
Catalog and media loaders must also:
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
registered origin/path, and malformed identifiers;
- set request deadlines and cap catalog and buffered frame bodies;
- parse only the expected format and validate coordinates before returning a
camera;
- handle offline cameras and partial provider failures without failing the
entire merged catalog; and
- avoid persistent frame storage unless the provider's terms explicitly allow
it and the design has been reviewed.
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
a source whose terms allow embedding when the browser can play it but the app
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
other pack, and the browser loads it only after an explicit play action, never
on selection, because the provider sees the viewer's IP address. Build its URL
from a validated provider ID on the server; never accept a browser-supplied
media URL. Render it in a native DOM
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
or a Cesium texture. If a map preview is needed, a second video request may be
paused as a DOM overlay at the camera location; label it as an approximate
still and scale it with map distance. If terms disallow embedding, use a source
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
validation, redirects, or proxy behavior.
When an image provider sets a minimum refresh interval, enforce it at the
server frame boundary as well as in the client. A bounded in-memory cache and
single-flight request per camera prevent projection, panel, and re-selection
paths from multiplying upstream requests; do not persist frames unless the
provider's terms explicitly allow it.
## 4. Add focused regression coverage
Add provider tests under `src/data/` following the existing CCTV test naming
and fixtures. Cover at least:
- representative valid catalog rows and their normalized output;
- missing or invalid IDs, coordinates, and frame URLs;
- duplicate records and the pack's cap/filter behavior;
- unsafe host/path, credentials, and redirect cases;
- timeout, oversized, malformed, and unavailable upstream responses; and
- the frame or live-media content type the provider actually serves.
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
browser-only source, verify native playback, denied canvas pixel access, the
paused second-player overlay, and its cleanup separately from proxy tests. Run
the focused provider and proxy tests, then the standard checks from
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
successful frame is from the upstream camera by checking its content type and
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
## 5. Update source documentation and submit
- Add the provider, endpoint, terms/license, required attribution, key status,
caching behavior, and any restrictions to `DATA_SOURCES.md`.
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
behavior, flags, caps, or fallback behavior changes.
- Register in-app attribution through the existing source/provider credit
fields; update `src/data/dataCredits.js` when the source needs a layer-level
credit.
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
change, and include the source, license, attribution, key/runtime-fetch
details, and verification evidence in the PR description.
- For a new layer, also follow the camera/source requirements in the
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
+5 -1
View File
@@ -136,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart.
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
records without importing Node middleware or a renderer. Browser layer/controller
separation is outside this server extraction.
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
photo answer (upstream or a server's cached shape) into one host-checked,
credited thumbnail; it has no fetch, DOM or Node dependency.
## Place-search and routing providers
@@ -163,7 +166,8 @@ local composition mounts them in the same order. Importing the entry performs
no acquisition; factory calls create independent cache state.
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
30-day detailed feed.
Callers supply the group or end date and own validation, credentials, transport,
response limits and cache policy. The boundary gate checks this portable entry
separately from the Node providers. Satellite rendering and launch replay remain
+569 -293
View File
File diff suppressed because it is too large. Load diff
-183
View File
@@ -1,183 +0,0 @@
# Data Library
DATA LAYERS is the curated set of core layers. The Data Library holds
everything else: regional sources, niche themes and community contributions.
A viewer opens it from **DATA LAYERS ▸ Browse data library**, adds what they
want, and the entry appears in the panel under **Library**. Nothing in the
library is on until someone adds it.
Runtime behaviour is described in [CURRENT-STATE.md](CURRENT-STATE.md#data-library--october-21-2026).
This page is the authoring guide.
## The one rule: live sources only
A library entry points at data its provider hosts. That can be a query API, a
static file at a stable URL the provider controls, or a tile endpoint. The
data is never copied into this repository. If the upstream goes away, the
entry goes stale; this project never becomes the host of record. That is what
lets every self-hosted instance serve the whole library with no data of its
own.
It also keeps the existing rule in [CONTRIBUTING.md](../CONTRIBUTING.md): do
not add data you have no right to redistribute. A live source still needs a
licence that allows this use, and the manifest must say what it is.
## Kinds
| Kind | What it is | Renders through |
| --- | --- | --- |
| `dataset` | Vector features: `geojson`, `geojsonl`, `csv` (lat/lon columns), `json` (a record list with lat/lon fields), `arcgis` (a FeatureServer or MapServer layer, paged) or `geoparquet` (one file, or a partitioned set on S3) | The GeoJSON layer Datacenters and Dams use; view-bounded and GeoParquet sets draw as ground-clamped dots and polygons |
| `tiles` | Raster tiles: `xyz` with a `{z}/{x}/{y}` template (`{reverseY}` for TMS rows), or `bbox` with a `{bbox-epsg-3857}` template (WMS GetMap, ArcGIS `export`/`exportImage`) | The active map's imagery host (globe or Google 3D tiles) |
| `pack` | A source pack an existing host layer already loads (today: CCTV) | The host layer, switched on by its own environment variable |
A `dataset` or `tiles` entry needs no JavaScript. A `pack` documents a host
layer's opt-in source so viewers can see it and operators know the switch; the
host layer owns its code. Provider entries (code implementing a family
contract, such as a Street Level provider) are not supported yet and are
refused by the validator.
## Add an entry
1. Create `src/library/entries/<id>.json`. The id is 3–48 lowercase letters,
digits or dashes and is permanent.
2. Add one import line for it in `src/library/catalog.js` (keep the list in
file-name order).
3. For a `dataset` or `tiles` entry, run `npm run library:next -- <id>` and
append `["<id>", "<token>"]` to `src/library/ledger.json`. Tokens are
permanent: never change or reuse a row, even when an entry is removed.
Packs take no token.
4. Run `npm run library:docs` to regenerate the DATA_SOURCES.md table.
5. Run `npm run library:check -- --base-ref origin/main`,
`npm run library:probe`, `npm test` and `npm run qa:data-library`.
6. Open a pull request with a `changelog.d/` fragment.
Start new entries at `"status": "community"`. Maintainers move an entry to
`verified` after reviewing its licence and seeing it stay healthy; `stale` and
`archived` mark entries whose upstream has stopped answering. Archived entries
are hidden from Browse unless a viewer already added them.
## Manifest reference
```json
{
"id": "eia-power-plants",
"kind": "dataset",
"family": "infrastructure",
"title": "US power plants, 100 MW and up (EIA)",
"provider": "EIA",
"description": "Operating US power plants with at least 100 MW of capacity.",
"icon": "ϟ",
"maintainer": "github:your-handle",
"coverage": { "countries": ["US"], "bbox": [-180, 13, -64, 72] },
"source": {
"format": "arcgis",
"url": "https://services2.arcgis.com/…/Power_Plants_in_the_US/FeatureServer/0",
"query": { "where": "Total_MW >= 100", "outFields": "Plant_Name,PrimSource,Total_MW" },
"fetch": "browser-direct",
"cacheSeconds": 86400,
"maxFeatures": 4000,
"maxPages": 3
},
"display": {
"color": "#ffc23d",
"label": "Plant_Name",
"fields": ["PrimSource", "Total_MW"],
"rank": { "field": "Total_MW", "order": "desc" }
},
"licence": { "name": "EIA data: public domain", "url": "https://www.eia.gov/about/copyrights_reuse.php" },
"attribution": { "text": "U.S. Energy Information Administration", "url": "https://atlas.eia.gov/" },
"status": "community",
"reviewed": "2026-10-21"
}
```
Unknown fields are errors, so a typo cannot silently drop a cap or a licence.
| Field | Required | Notes |
| --- | --- | --- |
| `id`, `kind`, `family`, `title` (≤ 60), `provider` (≤ 32), `description` (≤ 240), `maintainer`, `status` | yes | `family` is one of movement, cameras, infrastructure, events, weather, environment, imagery. `provider` is the short name the panel row shows. |
| `coverage` | yes | `global: true`, ISO 3166-1 alpha-2 `countries`, and/or a `bbox` `[west, south, east, north]`. |
| `source.url` | yes | https, a real hostname, no credentials or fragment. |
| `source.format` | yes | Datasets: `geojson`, `geojsonl`, `csv`, `json`, `arcgis`, `geoparquet`. Tiles: `xyz` (`{z}`, `{x}` and `{y}` or `{reverseY}`) or `bbox` (one `{bbox-epsg-3857}`, filled with the tile's Web Mercator bounds in metres, as MapLibre does). Packs: `host`. |
| `source.records` | no | JSON only: the property path to the record list, such as `stations` for `{ "stations": [ … ] }`. Omit when the response is the list itself. |
| `source.fetch` | datasets, tiles | `browser-direct` when the upstream sends CORS headers and needs no key; otherwise `proxied`. |
| `source.query` | no | Query parameters added to the URL. For `arcgis`, `where` and `outFields`; geometry is always WGS84 GeoJSON. |
| `source.columns` | no | CSV or JSON: `{ "lat": "…", "lon": "…" }` when the fields are not lat/latitude/y and lon/lng/longitude/x. |
| `source.cacheSeconds` | no | 60–86,400, default 3,600. How long the proxy keeps a response. |
| `source.maxBytes` | no | Body cap, default 16 MiB, at most 32 MiB. |
| `source.maxFeatures` | no | Feature cap, default 10,000, at most 20,000. A capped dataset reads PARTIAL. |
| `source.maxPages` | no | ArcGIS pages to follow, default 5, at most 10. |
| `source.view` | no | `{ "maxAltitudeM": 300–50000, "sample": [w, s, e, n] }`. Makes a huge ArcGIS layer view-bounded: below that camera height it loads what is in view, one page at a time; above it the row asks the viewer to zoom in. Needs `arcgis` or `geoparquet`, and `browser-direct`. `sample` is where Zoom to layer lands and what `library:probe` queries. |
| `source.partitions` | no | GeoParquet only: `{ "listUrl": "<S3 ListObjectsV2 URL with prefix>", "keyBase": "<URL keys are relative to>" }`, both on the source host. Needs `source.view`. The library indexes each file by its footer bbox and reads only the files in view. |
| `source.minZoom`, `source.maxZoom` | no | Tiles only, 0–22. |
| `source.encoding` | no | Tiles (`xyz`): `terrarium` when the pixels are elevation as terrain-RGB (height = R × 256 + G + B / 256 − 32768 m). The browser draws them as shaded relief: one orange ramp in six elevation classes (legend in metres), lit from the north-west; sea (exactly 0 m) is transparent. |
| `source.tileSize` | no | Tiles: 256 (default) or 512 px. |
| `source.bounded` | no | Tiles with `coverage.bbox`: `true` requests tiles inside the bbox only. Required for aerial imagery, whose servers paint a fill (white, a flat ocean, a coarse world mosaic) everywhere they have no photos. A 4xx tile answer is treated as "no imagery here", never retried or counted as an error. |
| `source.key` | no | `{ "id": "<key registry id>", "in": "query" \| "header", "name": "…" }`. The id must exist in `src/keySetupCore.mjs`. A keyed entry must be `proxied`, so the key never reaches the browser. |
| `display.color`, `display.label`, `display.fields` (≤ 4, two shown) | no | Datasets only, except `opacity`. |
| `display.rank` | no | `{ "field": "…", "order": "desc" \| "asc" }`. Keeps the most important features when capped and lets the map prefer them at global zoom. Numbers or ISO dates. Without it, a zoomed-out view shows whichever features come first. |
| `display.colorBy` | no | View-bounded datasets: `{ "field": "…", "colors": { "<value>": "#rrggbb" }, "labels": { "<value>": "…" } }`, up to 12 values. Colours each feature by value; the row legend lists each label with the count in view. |
| `display.legend` | no | `[{ "label": "…", "color": "#rrggbb" }]`, up to 12: a static legend, such as a tile layer's colour scale. Take the colours from the provider's own legend or colour map. |
| `display.outline` | no | Tiles: `countries` outlines every part of each covered country, `coverage` only the parts inside `coverage.bbox`, `bbox` the bbox rectangle itself (a state, county or city inside a country). Use it for aerial imagery so its edge shows. |
| `display.aerial` | no | Tiles: `true` for aerial or satellite photography. It drapes above every other imagery layer, including core overlays, and stays there when another layer is switched on. Pair it with `source.bounded`. |
| `display.noDataColor` | no | Tiles: `#rrggbb` of the provider's no-imagery fill, drawn transparent (within a small tolerance for JPEG noise along its edge). Measure it from a tile outside the coverage; do not guess. Globe maps only: Google 3D tiles ignore it. |
| `display.opacity` | no | Tiles: (0, 1]. |
| `imagery` | no | Tiles: what a photo layer is. `type` (`ortho`, `satellite-mosaic`, `oblique`, `historic`), `resolution` as the provider publishes it ("10 cm (plains) / 25 cm (Alps)"), `resolutionM` (the finest, in metres), `trueOrtho`, and optional `vintage` ("2021–present"), `updateCycle`, `orthoQuality` and `registry` (an https link to where the metadata came from). Browse shows it as a spec list on the card. |
| `pack` | packs | `{ "host": "cctv", "env": "CCTV_EXAMPLE_ENABLED" }`. Packs are always `licence.operatorOptIn: true`. |
| `licence` | yes | `name`, an https `url`, and `operatorOptIn: true` when the terms need the operator's own agreement or consent. |
| `attribution` | yes | `text` and an optional https `url`; shown in the Data attribution popover while the entry is in use. |
| `regionsServed` | no | ISO codes, when the upstream only answers from some countries. |
| `reviewed` | no | `YYYY-MM-DD` of the last licence and endpoint review. |
## Aerial imagery from the Open Imagery Register
The aerial photo entries came from the [Open Imagery Register](https://imagery-registry.netlify.app/)
on 2026-10-23: every register source with a map preview, less the ones that
fail the library's rules. Each entry keeps the register's resolution, product
type, true-ortho flag, years flown and refresh cycle in `imagery`. Before an
entry was written, a tile inside its coverage was fetched in a browser from
the app's origin: it had to be a real photo (not a blank fill), and the
provider had to send CORS headers (or the entry would be `proxied`). The
fill a provider paints at its coverage corners became `display.noDataColor`.
Left out, with the reason:
| Register source | Why |
| --- | --- |
| NASA GIBS true colour | Already in core (Recent imagery). |
| Sentinel-2 cloudless (EOX) | CC BY-NC-SA: non-commercial. |
| SWISSIMAGE Bern and Marion County PMTiles | Extracts hosted by the register, not the provider. |
| Cabarrus County NC, Hawaii, Nashville, Sioux Falls | Credit a commercial imagery vendor (EagleView, Maxar WorldView, Pictometry, Nearmap). |
| DVRPC, Missouri, Washington | The preview serves a blank white tile across the coverage centre. |
| Sarasota County FL | The service now requires a token. |
Entries whose register licence reads "see publisher terms" or "verify portal
terms" are `licence.operatorOptIn: true`: an operator reads the publisher's
terms and lists the id in `GEV_LIBRARY_ENABLE`. Re-check an entry's terms and
drop the opt-in when its publisher states an open licence.
## Choosing a fetch mode
Use `browser-direct` when the upstream answers a browser on another origin
(`Access-Control-Allow-Origin`) and needs no key. Tile servers must send CORS
headers for WebGL to use them; without them, use `proxied`.
Use `proxied` otherwise. The proxy is generic: there is no per-entry server
code. It reaches only the manifest's host, refuses redirects, caps bodies,
caches per `cacheSeconds` and never returns upstream error text. The browser
names an entry id, never a URL, so the proxy cannot be pointed anywhere else.
## Operator switches
| Variable | Effect |
| --- | --- |
| `GEV_LIBRARY_ENABLE` | Comma-separated ids of `operatorOptIn` datasets and tiles to serve. |
| `GEV_LIBRARY_DISABLE` | Comma-separated ids to remove from this instance. |
| A pack's own variable | For example `CCTV_NYC_ENABLED=1`; Browse then shows the pack as switched on. |
## When an entry belongs in core instead
A library entry can be proposed for DATA LAYERS when it has global or
near-global coverage, is keyless or uses a key the registry already has, has
stayed `verified` with no stale period for about six months, and a core
maintainer will own it. Promotion and demotion are maintainer decisions.
+5
View File
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 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 |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
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.
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Field 28 is the world time the link was made at, written only when it was away
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
link's creation (a forecast moment), and it opens paused at that time.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" 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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+8 -14
View File
@@ -15,9 +15,7 @@
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"fzstd": "0.1.1",
"hls.js": "^1.7.3",
"hyparquet": "1.31.2",
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
@@ -30,6 +28,7 @@
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
@@ -2413,6 +2412,13 @@
}
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"dev": true,
"license": "MIT"
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -2454,12 +2460,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fzstd": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
"integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
"license": "MIT"
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -2570,12 +2570,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/hyparquet": {
"version": "1.31.2",
"resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz",
"integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+3 -7
View File
@@ -38,10 +38,6 @@
"i18n": "node scripts/i18n.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"library:check": "node scripts/library.mjs check",
"library:next": "node scripts/library.mjs next",
"library:docs": "node scripts/library.mjs docs",
"library:probe": "node scripts/library.mjs probe",
"changelog": "node scripts/assemble-changelog.mjs --write",
"changelog:check": "node scripts/assemble-changelog.mjs --check",
"dev": "vite",
@@ -58,7 +54,6 @@
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs",
"qa:data-library": "node scripts/qa-data-library.mjs",
"mcp": "node server/mcp/stdio.js"
},
"dependencies": {
@@ -68,9 +63,7 @@
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"fzstd": "0.1.1",
"hls.js": "^1.7.3",
"hyparquet": "1.31.2",
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
@@ -83,6 +76,7 @@
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
@@ -97,6 +91,7 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
@@ -104,6 +99,7 @@
"node": "./server/providers/live.js"
},
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
"./sources/planespotters": "./src/data/planespottersPhoto.js",
"./server/providers/places": {
"node": "./server/providers/places.js"
},
+1
View File
@@ -0,0 +1 @@
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
+57 -1
View File
@@ -7,13 +7,16 @@
* Natural Earth 10m admin-1 states and provinces (public domain).
* src/data/local_data/us_census_counties/counties.json
* US Census Bureau cartographic boundary counties (public domain).
* src/data/local_data/natural_earth/populated_places.json
* Natural Earth 10m populated places as points, for the orbital view's
* "near <city>" (`src/data/placeBelow.js`; public domain).
*
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
* byte for byte. Downloads are cached outside the repository (default: the
* system temp directory) and are never committed.
*
* Usage:
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
*
* The provenance READMEs beside each pack record these parameters.
*/
@@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({
elsewhereMinPopulation: 50_000,
cityStateMaxKm2: 1_000,
},
// Every populated place, kept as a point: name, position and the two
// prominence ranks the orbital view picks "near <city>" by.
places: { decimals: 3 },
});
const args = process.argv.slice(2);
@@ -688,6 +694,47 @@ async function buildAdmin1() {
};
}
async function buildPlaces() {
const params = PARAMS.places;
const source = await fetchPinned('places');
const factor = 10 ** params.decimals;
const round = (value) => Math.round(value * factor) / factor;
const places = JSON.parse(source.bytes.toString('utf8'))
.features.map((f) => f.properties)
.filter(
(p) =>
p.name &&
Number.isFinite(p.latitude) &&
Number.isFinite(p.longitude) &&
Number.isFinite(p.scalerank),
)
.map((p) => [
p.name,
round(p.latitude),
round(p.longitude),
p.scalerank,
Number.isFinite(p.rank_max) ? p.rank_max : 0,
])
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
return {
meta: {
title: 'Natural Earth populated places',
source: 'ne_10m_populated_places_simple',
url: SOURCES.places.url,
sha256: source.digest,
commit: NE_COMMIT,
license:
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
schema:
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
decimals: params.decimals,
curation: params,
script: 'scripts/build-admin-packs.mjs',
},
places,
};
}
async function buildCounties() {
const params = PARAMS.counties;
const source = await fetchPinned('counties');
@@ -768,4 +815,13 @@ if (isMain) {
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
if (!only || only === 'places') {
const relative = 'src/data/local_data/natural_earth/populated_places.json';
const pack = await buildPlaces();
const text = JSON.stringify(pack) + '\n';
await writeFile(path.join(ROOT, relative), text);
console.log(
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
);
}
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
*
* src/data/local_data/owner_flags/owner_flags.png the sheet
* src/ui/ownerFlagSheet.js its layout (generated)
*
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
* reproduces both files.
*
* Usage: node scripts/build-owner-flags.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as prettier from 'prettier';
import sharp from 'sharp';
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const FLAGS_DIR = path.join(
path.dirname(require.resolve('flag-icons/package.json')),
'flags/4x3',
);
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
/** One flag's cell, CSS pixels × 2. */
export const CELL = Object.freeze({ width: 40, height: 30 });
/**
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
* flag (a 125% display) never reaches into its neighbour.
*/
export const GUTTER = 2;
export const COLUMNS = 16;
const SHEET = path.join(
ROOT,
'src/data/local_data/owner_flags/owner_flags.png',
);
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
async function flagCell(code) {
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
return sharp(svg, { density: 300 })
.resize(CELL.width, CELL.height, { fit: 'fill' })
.png()
.toBuffer();
}
const codes = [...OWNER_COUNTRY_CODES];
const rows = Math.ceil(codes.length / COLUMNS);
const pitchX = CELL.width + GUTTER;
const pitchY = CELL.height + GUTTER;
const cells = await Promise.all(codes.map(flagCell));
const sheet = await sharp({
create: {
width: COLUMNS * pitchX,
height: rows * pitchY,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
cells.map((input, i) => ({
input,
left: (i % COLUMNS) * pitchX,
top: Math.floor(i / COLUMNS) * pitchY,
})),
)
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
.toBuffer();
await writeFile(SHEET, sheet);
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
export const OWNER_FLAG_SHEET = Object.freeze({
cellWidthPx: ${CELL.width},
cellHeightPx: ${CELL.height},
gutterPx: ${GUTTER},
columns: ${COLUMNS},
rows: ${rows},
codes: Object.freeze(${JSON.stringify(codes)}),
});
`;
await writeFile(
LAYOUT,
await prettier.format(layout, {
...(await prettier.resolveConfig(LAYOUT)),
filepath: LAYOUT,
}),
);
console.log(
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
);
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
Reads the UCS Excel release and writes the compact pack the satellites layer
loads: one row per satellite, keyed by NORAD number, with string-table indexes
for the repeated text columns. Starlink rows are left out: the layer files
every STARLINK object under its own class and operator without them.
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
'''
import argparse
import json
import logging
from pathlib import Path
import re
import openpyxl
LOG = logging.getLogger('build-ucs-satellites')
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/ucs_satellites'
VERSION = 'UCS-Satellite-Database 5-1-2023'
DATA_CURRENT_TO = '2023-05-01'
URL = 'https://www.ucsusa.org/satellite_database'
# Output column -> spreadsheet header.
COLUMNS = {
'operator': 'Operator/Owner',
'operatorCountry': 'Country of Operator/Owner',
'purpose': 'Purpose',
}
STARLINK = re.compile(r'\bstarlink\b', re.I)
def clean(value: object) -> str:
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
text = re.sub(r'\s+', ' ', str(value or '')).strip()
return '' if text.lower() in ('', 'none', 'n/a') else text
def build(xlsx: Path) -> dict:
'''Read the release's first sheet into the pack the layer loads.'''
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
rows = sheet.iter_rows(values_only=True)
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
norad_col = header['NORAD Number']
tables = {key: [''] for key in COLUMNS}
index = {key: {'': 0} for key in COLUMNS}
records = {}
source_rows = skipped_starlink = invalid_norad = 0
for row in rows:
if not any(row):
continue
source_rows += 1
if any(STARLINK.search(clean(row[i])) for i in names):
skipped_starlink += 1
continue
try:
norad = int(float(row[norad_col]))
except (TypeError, ValueError):
invalid_norad += 1
continue
if norad <= 0:
invalid_norad += 1
continue
record = [norad]
for key, column in COLUMNS.items():
text = clean(row[header[column]])
if text not in index[key]:
index[key][text] = len(tables[key])
tables[key].append(text)
record.append(index[key][text])
# A duplicate NORAD number keeps the first row, as UCS lists it.
records.setdefault(norad, record)
ordered = [records[n] for n in sorted(records)]
return {
'meta': {
'source': 'Union of Concerned Scientists Satellite Database',
'version': VERSION,
'url': URL,
'dataCurrentTo': DATA_CURRENT_TO,
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
'curation': {
'sourceRows': source_rows,
'starlinkRowsDropped': skipped_starlink,
'invalidNoradDropped': invalid_norad,
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
'columns': COLUMNS,
},
'recordCount': len(ordered),
},
'fields': ['noradId', *COLUMNS],
'strings': tables,
'records': ordered,
}
def main() -> None:
'''Build the pack from the release given on the command line.'''
logging.basicConfig(level=logging.INFO, format='%(message)s')
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
pack = build(parser.parse_args().xlsx)
PACK.mkdir(parents=True, exist_ok=True)
out = PACK / 'ucs_satellites.json'
# LF on every platform: the repository stores text with LF endings.
out.write_text(
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
encoding='utf-8',
newline='\n',
)
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
if __name__ == '__main__':
main()
+1 -1
View File
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
+18 -1
View File
@@ -112,6 +112,8 @@
"src/data/terrainHeightsProxy.test.mjs",
"src/data/tomtomTiles.js",
"src/data/tomtomTiles.test.mjs",
"src/data/tileBudget.js",
"src/data/tileBudget.test.mjs",
"src/data/firmsCsv.js",
"src/data/firmsCsv.test.mjs",
"src/tooling/environmentProviders.test.mjs",
@@ -328,6 +330,12 @@
"src/ui/styles/responsive.css",
"src/ui/styles/voice-cost.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/cockpitOrbital.css",
"src/layers/satellites/sensors.js",
"src/layers/satellites/sensors.test.mjs",
"src/data/imagingSensors.js",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
@@ -347,6 +355,7 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
@@ -474,6 +483,7 @@
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
@@ -581,6 +591,7 @@
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvIceland.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs",
@@ -1117,6 +1128,7 @@
"src/sky/starrySky.test.mjs",
"src/tooling/skyAssets.test.mjs",
"src/tooling/skyAssets.paintOut.test.mjs",
"src/tooling/responseCompression.test.mjs",
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
@@ -1354,5 +1366,10 @@
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs"
"server/providers/inaturalist/proxy.fixture.mjs",
"scripts/build-owner-flags.mjs",
"src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs"
]
-321
View File
@@ -1,321 +0,0 @@
#!/usr/bin/env node
/**
* Data Library maintenance. See docs/DATA-LIBRARY.md.
*
* npm run library:check [-- --base-ref origin/main]
* Validate every manifest, the share-token ledger and the generated
* DATA_SOURCES.md section. With --base-ref, also prove the ledger only
* appended rows since that ref (published tokens never change).
* npm run library:next -- <entry-id>
* Print the next free share token for a new switchable entry.
* npm run library:docs
* Rewrite the generated Data Library section of DATA_SOURCES.md.
* npm run library:probe [-- --json] [-- --strict]
* Hit every dataset and tile upstream once (bounded) and report
* status, latency and feature count. --strict exits 1 on a failure.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js';
import { libraryTileUrl } from '../src/library/manifest.js';
import {
arcgisPageUrl,
datasetRequestUrl,
parseLibraryBody,
} from '../src/library/formats.js';
import {
nextLibraryToken,
readLibraryLedger,
} from '../src/library/selection.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md');
const START = '<!-- data-library:start (generated by npm run library:docs) -->';
const END = '<!-- data-library:end -->';
const LEDGER_PATH = 'src/library/ledger.json';
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' ');
/** The generated DATA_SOURCES.md section, one row per entry. */
export function libraryDocsSection(entries = LIBRARY_ENTRIES) {
const rows = [...entries]
.sort((a, b) => a.id.localeCompare(b.id))
.map((entry) => {
const host = (() => {
try {
return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host;
} catch {
return '';
}
})();
const how =
entry.kind === 'pack'
? `CCTV pack; operator sets \`${entry.pack.env}=1\``
: `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` +
(entry.licence.operatorOptIn
? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\``
: '') +
(entry.source.key
? `; needs the \`${entry.source.key.id}\` key`
: '');
return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`;
});
return [
START,
'',
'## Data Library entries (fetched at runtime — not stored in this repo)',
'',
'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.',
'',
'| Entry | How it is served | Licence / terms | Attribution |',
'| --- | --- | --- | --- |',
...rows,
'',
END,
].join('\n');
}
function replaceSection(text, section) {
const start = text.indexOf(START);
const end = text.indexOf(END);
if (start < 0 || end < start) {
const anchor = text.indexOf('\n## Bundled snapshots');
if (anchor < 0)
throw new Error('DATA_SOURCES.md has no Bundled snapshots heading');
return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`;
}
return text.slice(0, start) + section + text.slice(end + END.length);
}
function check(baseRef) {
const errors = [];
const ledger = readLibraryLedger(LIBRARY_LEDGER);
errors.push(...ledger.errors.map((error) => `ledger ${error}`));
for (const entry of LIBRARY_ENTRIES) {
const switchable = entry.kind !== 'pack';
if (switchable && !ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: no share token (npm run library:next -- ${entry.id})`,
);
if (!switchable && ledger.tokenById.has(entry.id))
errors.push(
`${entry.id}: packs are switched by their host and take no token`,
);
}
if (baseRef) {
let published = [];
try {
published = JSON.parse(
execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}),
);
} catch {
published = [];
}
published.forEach(([id, token], index) => {
const row = LIBRARY_LEDGER[index];
if (!row || row[0] !== id || row[1] !== token)
errors.push(
`ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`,
);
});
LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => {
const expected = nextLibraryToken(
LIBRARY_LEDGER.slice(0, published.length + offset),
);
if (token !== expected)
errors.push(
`ledger row for ${id} should take ${expected}, not ${token}`,
);
});
}
const text = readFileSync(DATA_SOURCES, 'utf8');
if (!text.includes(libraryDocsSection()))
errors.push(
'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)',
);
if (errors.length) {
for (const error of errors) console.error(`✗ ${error}`);
process.exit(1);
}
console.log(
`Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`,
);
}
/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */
const PROBE_POINTS = [
[0.5, 0.5],
[0.4, 0.45],
[0.6, 0.55],
[0.25, 0.25],
[0.75, 0.75],
];
/**
* A Web Mercator tile to probe. Regional entries try points inside their
* coverage bbox (a regional server answers 404 or a blank fill outside its
* photos, and a bbox centre can be sea); global entries probe a mid-world
* tile.
*/
function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) {
const n = 2 ** z;
const bbox = entry.coverage?.bbox;
if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) };
const lon = bbox[0] + (bbox[2] - bbox[0]) * fx;
const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy));
const rad = (lat * Math.PI) / 180;
const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value)));
return {
x: clamp(((lon + 180) / 360) * n),
y: clamp(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
),
};
}
async function probeEntry(entry) {
const started = Date.now();
const signal = AbortSignal.timeout(30_000);
try {
if (entry.kind === 'tiles') {
// Regional imagery is probed at z13, the Open Imagery Register's
// validation zoom (some caches start there); global layers at their
// coarsest level.
const minZoom = entry.source.minZoom ?? 0;
const z = entry.coverage.bbox
? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13))
: minZoom;
const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]];
let status = null;
let lastError = null;
for (const point of points) {
const { x, y } = probeTile(entry, z, point);
const url = libraryTileUrl(entry.source.url, z, x, y);
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(20_000),
redirect: 'error',
});
await response.arrayBuffer();
status = response.status;
if (
response.ok &&
/^image\//.test(response.headers.get('content-type') || '')
)
return { id: entry.id, ok: true, status, ms: Date.now() - started };
} catch (error) {
lastError = error;
}
}
if (status === null && lastError) throw lastError;
return { id: entry.id, ok: false, status, ms: Date.now() - started };
}
if (entry.source.format === 'geoparquet') {
// List the partitions and read one footer by range request: proof the
// bucket answers and the files are GeoParquet, without reading data.
const { listPartitions, rangeBuffer, geoParquetInfo } =
await import('../src/library/parquet.js');
const { parquetMetadataAsync } = await import('hyparquet');
const fetchImpl = (target, options = {}) =>
fetch(target, { ...options, signal });
const objects = entry.source.partitions
? await listPartitions(entry, { fetchImpl, signal })
: [{ key: entry.source.url, size: null }];
const first = objects[0];
const url = entry.source.partitions
? new URL(first.key, entry.source.partitions.keyBase).href
: entry.source.url;
const size =
first.size ??
Number(
(await fetch(url, { method: 'HEAD', signal })).headers.get(
'content-length',
),
);
const metadata = await parquetMetadataAsync(
rangeBuffer(url, size, { fetchImpl, signal }),
{ initialFetchSize: 16 * 1024 },
);
return {
id: entry.id,
ok: Boolean(geoParquetInfo(metadata).column),
status: 200,
ms: Date.now() - started,
features: Number(metadata.num_rows),
partitions: objects.length,
};
}
const url =
entry.source.format === 'arcgis'
? arcgisPageUrl(
entry.source.url,
entry.source.query,
0,
// Some huge view services refuse a page smaller than their own.
entry.source.view ? 2000 : 100,
// A view-bounded layer is probed over its sample area.
entry.source.view?.sample ?? null,
)
: datasetRequestUrl(entry.source);
const response = await fetch(url, { signal, redirect: 'error' });
const text = await response.text();
const parsed = response.ok
? parseLibraryBody(entry.source.format, text, entry.source)
: null;
return {
id: entry.id,
ok: Boolean(parsed?.candidates.length),
status: response.status,
ms: Date.now() - started,
features: parsed?.candidates.length ?? 0,
};
} catch (error) {
return {
id: entry.id,
ok: false,
status: null,
ms: Date.now() - started,
error: error.message,
};
}
}
const [command = 'check', ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => {
const index = rest.indexOf(name);
return index >= 0 ? rest[index + 1] : null;
};
if (command === 'check') check(option('--base-ref'));
else if (command === 'next') {
const id = rest.find((arg) => !arg.startsWith('--'));
if (!id) throw new Error('usage: npm run library:next -- <entry-id>');
if (LIBRARY_LEDGER.some(([existing]) => existing === id))
throw new Error(`${id} already owns a token`);
console.log(nextLibraryToken(LIBRARY_LEDGER));
} else if (command === 'docs') {
const text = readFileSync(DATA_SOURCES, 'utf8');
writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection()));
console.log('DATA_SOURCES.md Data Library section updated.');
} else if (command === 'probe') {
const results = [];
for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack'))
results.push(await probeEntry(entry));
if (flag('--json')) console.log(JSON.stringify(results, null, 2));
else
for (const result of results)
console.log(
`${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`,
);
if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1);
} else {
console.error(`unknown command: ${command}`);
process.exit(2);
}
+189 -171
View File
@@ -39,6 +39,10 @@
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/views/panels/CockpitRadar.vue",
"src/ui/views/panels/CockpitOrbital.vue",
"src/ui/ownerFlag.js",
"src/ui/ownerFlagSheet.js",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
@@ -155,10 +159,19 @@
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/ui/kit/UiIcon.vue",
"src/ui/stores/worldTime.js",
"src/ui/views/panels/WorldTimeline.vue",
"src/ui/surfaceKeyboard.js"
],
"external": ["vue", "pinia", "vue-i18n"]
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
"external": []
},
"viewer": {
"exports": ["./application/viewer"],
"modules": [
@@ -192,6 +205,7 @@
"modules": [
"src/sources/adsbFields.js",
"server/providers/live.js",
"server/providers/common/compress.js",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
@@ -228,6 +242,11 @@
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
"external": []
},
"planespotters-source": {
"exports": ["./sources/planespotters"],
"modules": ["src/data/planespottersPhoto.js"],
"external": []
},
"place-providers": {
"runtime": "node",
"exports": ["./server/providers/places"],
@@ -305,6 +324,7 @@
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
],
@@ -312,7 +332,7 @@
},
"traffic-source": {
"exports": ["./sources/traffic"],
"modules": ["src/data/tomtomTiles.js"],
"modules": ["src/data/tomtomTiles.js", "src/data/tileBudget.js"],
"external": []
},
"firms-csv": {
@@ -363,6 +383,9 @@
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/taiwan.js",
"server/providers/cctv/quebec.js",
"server/providers/cctv/ottawa.js",
"server/providers/cctv/worldCameras.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -806,6 +829,7 @@
"cctv-controls": {
"exports": ["./ui/cctv"],
"modules": [
"src/data/iconOrientation.js",
"src/data/headingDegrees.js",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
@@ -814,6 +838,7 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/ui/quebecCctvOverlay.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
@@ -833,7 +858,7 @@
"src/ui/stores/status.js",
"src/ui/i18n.js"
],
"external": ["vue", "pinia", "vue-i18n"]
"external": ["vue", "pinia", "vue-i18n", "cesium"]
},
"context-controls": {
"exports": ["./ui/context", "./ui/context/policy"],
@@ -885,6 +910,19 @@
"src/ui/cockpitInstrumentState.js",
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/layers/installations/classLabel.js",
"src/data/installationNames.js",
"src/i18n/marker.js",
"src/data/placeBelow.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
@@ -899,6 +937,13 @@
"src/cockpitMath.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
@@ -967,6 +1012,13 @@
"src/cockpitTracking.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
@@ -974,6 +1026,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/militaryAwarenessEngine.js",
@@ -1006,6 +1059,8 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/data/iconOrientation.js",
"src/ui/quebecCctvOverlay.js",
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
@@ -1014,6 +1069,20 @@
"src/ui/cockpitInstrumentState.js",
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/layers/installations/classLabel.js",
"src/data/placeBelow.js",
"src/ui/placePacksLoading.js",
"src/ui/ownerFlag.js",
"src/ui/ownerFlagSheet.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
@@ -1172,6 +1241,7 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/ui/kit/UiSegmented.vue",
"src/ui/wildlifeSightingCard.js",
"src/layers/wildlifeSightings/records.js",
@@ -1193,10 +1263,13 @@
"src/ui/styles/location.css",
"src/ui/styles/status.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/overlays.css",
"src/ui/styles/layers.css",
"src/ui/styles/radio.css",
"src/ui/styles/cctv.css",
"src/ui/styles/quebecCctvOverlay.css",
"src/ui/styles/scenes.css",
"src/ui/styles/bhote-koshi.css",
"src/ui/styles/recording.css",
@@ -1327,7 +1400,8 @@
"src/sources/live/contract.js",
"src/sources/live/index.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js"
"src/sources/live/vessels.js",
"src/data/planespottersPhoto.js"
],
"external": []
},
@@ -1350,6 +1424,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
@@ -1391,7 +1466,10 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/data/planespottersPhoto.js"
],
"external": ["cesium"]
},
@@ -1413,6 +1491,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
"src/data/aircraftModelLighting.js",
@@ -1448,7 +1527,9 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js",
"src/time/julian.js"
],
"external": ["cesium"]
},
@@ -1467,6 +1548,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -1507,6 +1589,8 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/geoDistance.js",
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
@@ -1606,9 +1690,11 @@
"exports": ["./layers/satellites"],
"modules": [
"src/data/satelliteClass.js",
"src/data/satelliteClassMask.js",
"src/data/satellitePass.js",
"src/sources/tle.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/cockpitSubject.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
"src/layers/satellites/ingestion.js",
@@ -1637,7 +1723,14 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/layers/satellites/classes.js",
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/time/worldClock.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -1677,7 +1770,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": ["cesium"]
},
@@ -1701,6 +1795,7 @@
"src/layers/installations/viewport.js",
"src/data/inputOwnership.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/sources/capability.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
@@ -1789,6 +1884,7 @@
"src/data/cameraSensitivity.js",
"src/data/flowMatch.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/data/trafficBounds.js",
"src/geoDistance.js",
"src/data/trafficFlowStyle.js",
@@ -2002,7 +2098,8 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/time/worldClock.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -2020,6 +2117,7 @@
"src/data/inputOwnership.js",
"src/layers/alpr/records.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/layers/alpr/tileRecords.js",
"src/sources/capability.js",
"src/sources/httpBody.js",
@@ -2050,6 +2148,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
"src/layers/radio/catalogModel.js",
@@ -2336,6 +2435,7 @@
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js",
"src/app/layers/firms.js",
"src/app/layers/flights.js",
@@ -2374,6 +2474,11 @@
"src/cockpitTracking.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
@@ -2438,6 +2543,7 @@
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js",
@@ -2483,6 +2589,7 @@
"src/data/telegeographySubmarineCables.js",
"src/data/terrainHeights.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/data/tr3bRegistry.js",
"src/data/trackedCamera.js",
"src/data/trackedModelRegime.js",
@@ -2667,6 +2774,7 @@
"src/layers/radio/tuningNoise.js",
"src/layers/radio/volume.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/cockpitSubject.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
"src/layers/satellites/ingestion.js",
@@ -2807,6 +2915,7 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/ui/quebecCctvOverlay.js",
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
@@ -2815,6 +2924,15 @@
"src/ui/cockpitInstrumentState.js",
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/data/placeBelow.js",
"src/ui/placePacksLoading.js",
"src/ui/ownerFlag.js",
"src/ui/ownerFlagSheet.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
@@ -2934,6 +3052,7 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMode.js",
@@ -3208,155 +3327,6 @@
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/libraryBrowser.js",
"src/library/manifest.js",
"src/library/selection.js",
"src/library/ledger.json",
"src/library/entries/ak-statewide-imagery.json",
"src/library/entries/amsterdam-traffic-cameras.json",
"src/library/entries/ar-statewide-imagery.json",
"src/library/entries/az-statewide-imagery.json",
"src/library/entries/bangor-me.json",
"src/library/entries/basemap-at-orthofoto.json",
"src/library/entries/bayern-dop40.json",
"src/library/entries/bismarck-mandan-nd.json",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/cctv-florida-fl511.json",
"src/library/entries/cctv-lithuania.json",
"src/library/entries/cctv-nyc-dot.json",
"src/library/entries/ct-statewide-imagery.json",
"src/library/entries/dakota-county-mn.json",
"src/library/entries/dc-statewide-imagery.json",
"src/library/entries/de-statewide-imagery.json",
"src/library/entries/ee-maa-amet-ortofoto.json",
"src/library/entries/eia-power-plants.json",
"src/library/entries/emodnet-bathymetry.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/epc-england-wales.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/geneva-sitg-ortho.json",
"src/library/entries/gpw-population-density.json",
"src/library/entries/grand-forks-nd.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/ia-statewide-imagery.json",
"src/library/entries/icgc-orto-catalunya.json",
"src/library/entries/id-statewide-imagery.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/il-statewide-imagery.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/in-statewide-imagery.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/kartverket-admin-units.json",
"src/library/entries/kartverket-cadastre.json",
"src/library/entries/kartverket-depth.json",
"src/library/entries/kartverket-hillshade.json",
"src/library/entries/kartverket-nautical-charts.json",
"src/library/entries/kartverket-terrain-relief.json",
"src/library/entries/kartverket-topo-grey.json",
"src/library/entries/kartverket-topo.json",
"src/library/entries/kartverket-toporaster.json",
"src/library/entries/ks-statewide-imagery.json",
"src/library/entries/ky-statewide-imagery.json",
"src/library/entries/lake-county-in.json",
"src/library/entries/lawrence-ks.json",
"src/library/entries/lee-county-fl.json",
"src/library/entries/loudoun-county-va.json",
"src/library/entries/louisville-lojic-ky.json",
"src/library/entries/lu-ortho-latest.json",
"src/library/entries/ma-statewide-imagery.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/mapterhorn-terrain.json",
"src/library/entries/maris-ms.json",
"src/library/entries/md-statewide-imagery.json",
"src/library/entries/me-statewide-imagery.json",
"src/library/entries/mi-statewide-imagery.json",
"src/library/entries/mn-statewide-imagery.json",
"src/library/entries/mountain-village-co.json",
"src/library/entries/ms-statewide-imagery.json",
"src/library/entries/mt-statewide-imagery.json",
"src/library/entries/naip.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/entries/nc-statewide-imagery.json",
"src/library/entries/nctcog-tx.json",
"src/library/entries/nd-statewide-imagery.json",
"src/library/entries/new-orleans-rpc-la.json",
"src/library/entries/nh-statewide-imagery.json",
"src/library/entries/nj-statewide-imagery.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nrw-dop.json",
"src/library/entries/nsw-imagery.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/nwa-benton-washington-ar.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/ny-statewide-imagery.json",
"src/library/entries/oakland-county-mi.json",
"src/library/entries/oh-statewide-imagery.json",
"src/library/entries/openseamap-seamarks.json",
"src/library/entries/or-statewide-imagery.json",
"src/library/entries/pa-statewide-imagery.json",
"src/library/entries/pdok-aerial-netherlands.json",
"src/library/entries/pl-ortofotomapa.json",
"src/library/entries/pnoa-ma.json",
"src/library/entries/portland-metro-or.json",
"src/library/entries/pt-dgt-ortos.json",
"src/library/entries/ri-statewide-imagery.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/tx-statewide-imagery.json",
"src/library/entries/union-county-oh.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/usgs-topo.json",
"src/library/entries/va-statewide-imagery.json",
"src/library/entries/vlaanderen-omw.json",
"src/library/entries/vt-statewide-imagery.json",
"src/library/entries/wallonie-ortho.json",
"src/library/entries/wi-statewide-imagery.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/wv-statewide-imagery.json",
"src/library/formats.js",
"src/library/entries/gvp-holocene-volcanoes.json",
"src/library/entries/nasa-black-marble.json",
"src/library/entries/nasa-eonet-events.json",
"src/library/entries/openseamap-seamarks.json",
"src/library/catalog.js",
"src/library/source.js",
"src/library/layers.js",
"src/library/controller.js",
"src/library/extent.js",
"src/library/camera.js",
"src/library/entries/imf-portwatch-disruptions.json",
"src/library/entries/imf-portwatch-chokepoints.json",
"src/library/entries/noaa-tide-stations.json",
"src/library/entries/nws-active-alerts.json",
"src/library/entries/wri-global-power-plants.json",
"src/library/entries/fema-usa-structures.json",
"src/library/entries/epc-england-wales.json",
"src/library/parquet.js",
"src/library/entries/bkg-topplus-open.json",
"src/library/entries/emodnet-bathymetry.json",
"src/library/entries/epa-superfund-sites.json",
"src/library/entries/epa-toxic-release-facilities.json",
"src/library/entries/gbif-occurrence-density.json",
"src/library/entries/gpw-population-density.json",
"src/library/entries/gdacs-disaster-alerts.json",
"src/library/entries/gsi-japan-map.json",
"src/library/entries/ign-orthophotos-france.json",
"src/library/entries/gsi-japan-photo.json",
"src/library/entries/jrc-surface-water.json",
"src/library/entries/ntad-national-bridges.json",
"src/library/entries/usgs-hydrography.json",
"src/library/entries/macrostrat-geology.json",
"src/library/entries/pdok-aerial-netherlands.json",
"src/library/entries/swisstopo-swissimage.json",
"src/library/entries/usgs-topo.json",
"src/library/outlines.js",
"src/library/relief.js",
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js",
"src/visualStylePolicy.js",
@@ -3423,6 +3393,9 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/time/viewerClock.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3468,7 +3441,12 @@
"src/ui/wildlifeSightingPlacement.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
"src/layers/wildlifeSightings/performance.js",
"src/layers/satellites/classes.js",
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
],
"external": [
"hls.js",
@@ -3480,8 +3458,6 @@
"satellite.js",
"mapillary-js",
"@jtarrio/webrtlsdr",
"hyparquet",
"fzstd",
"vue",
"pinia",
"vue-i18n"
@@ -3531,6 +3507,7 @@
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js",
"src/app/layers/firms.js",
"src/app/layers/flights.js",
@@ -3581,6 +3558,7 @@
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
@@ -3609,6 +3587,7 @@
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/data/tr3bRegistry.js",
"src/data/trackedCamera.js",
"src/data/trackedModelRegime.js",
@@ -3782,6 +3761,7 @@
"src/layers/radio/tuningNoise.js",
"src/layers/radio/volume.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/cockpitSubject.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
"src/layers/satellites/ingestion.js",
@@ -3869,6 +3849,7 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/data/inputOwnership.js",
"src/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js",
@@ -4086,6 +4067,8 @@
"src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -4119,7 +4102,12 @@
"src/layers/wildlifeSightings/sprites.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
"src/layers/wildlifeSightings/performance.js",
"src/layers/satellites/classes.js",
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
],
"external": [
"hls.js",
@@ -4198,6 +4186,7 @@
"src/sources/phaseTiming.js",
"src/sources/militaryTileGeometry.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/data/installationNames.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
@@ -4219,11 +4208,7 @@
},
"layer-lifecycle": {
"exports": ["./data/lifecycle"],
"modules": [
"src/data/lifecycle.js",
"src/data/cameraArrival.js",
"src/library/manifest.js"
],
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"],
"external": []
},
"ui-navigation": {
@@ -4257,6 +4242,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js",
@@ -4276,7 +4262,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": []
},
@@ -4289,6 +4276,14 @@
"src/ui/radioPanelPlacement.js",
"src/bloom.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/cockpitOrbitalSatellites.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
"src/data/detectionPolicy.js",
"src/styles/anime.js",
@@ -4555,6 +4550,7 @@
"src/layers/alpr/records.js",
"src/layers/alpr/source.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/layers/alpr/tileRecords.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
@@ -4593,6 +4589,7 @@
"src/data/militaryInstallationData.js",
"src/data/installationNames.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/sources/capability.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
@@ -4620,13 +4617,22 @@
},
"satellites-source": {
"exports": ["./layers/satellites/source"],
"modules": ["src/layers/satellites/source.js"],
"modules": [
"src/layers/satellites/source.js",
"src/layers/satellites/classes.js",
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js"
],
"external": []
},
"traffic-source-factory": {
"exports": ["./layers/traffic/source"],
"modules": [
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/layers/traffic/flowDecode.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/footprint.js",
@@ -4819,6 +4825,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js",
"src/overlays/worldOverlayChrome.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
@@ -4872,7 +4879,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": ["cesium", "pbf"]
},
@@ -4989,12 +4997,14 @@
"src/view/index.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/data/idSwitches.js",
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js",
"src/visualStylePolicy.js"
"src/visualStylePolicy.js",
"src/time/worldClock.js"
],
"external": []
},
@@ -5114,6 +5124,7 @@
"src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
"src/sources/tle.js",
@@ -5196,6 +5207,7 @@
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/data/tomtomTiles.js",
"src/data/tileBudget.js",
"src/sources/capability.js",
"src/data/militaryInstallationData.js",
"src/layers/launches/source.js",
@@ -5237,7 +5249,12 @@
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js"
"src/sources/eventPacks.js",
"src/layers/satellites/classes.js",
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
@@ -5248,6 +5265,7 @@
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/satelliteClassMask.js",
"src/data/layerStateTokenReservations.json",
"src/visualStylePolicy.js"
],
+3
View File
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
const failures = [];
const consoleErrors = [];
const localHttpErrors = [];
@@ -825,6 +827,7 @@ try {
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
// assertion when camera motion supersedes the manager's initial update.
const readinessPage = await browser.newPage();
await stubPlanespotters(readinessPage);
let deferredInstallationReadiness;
try {
await readinessPage.setViewport({ width: 1440, height: 900 });
+2
View File
@@ -28,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
const argv = process.argv.slice(2);
@@ -77,6 +78,7 @@ const browser = await puppeteer.launch({
async function openPage({ aircraft = false } = {}) {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1440, height: 900 });
page.on('console', (message) => {
if (message.type() === 'error') result.consoleErrors.push(message.text());
-636
View File
@@ -1,636 +0,0 @@
#!/usr/bin/env node
/**
* Data Library live gate. Run against this candidate's dev or preview server:
*
* npm run qa:data-library -- --url http://localhost:4173
*
* Every upstream a library entry names is answered from fixtures in the
* browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied
* volcano route), so the gate is deterministic and offline apart from the
* app itself. It drives the real panel and dialog: Browse opens a modal,
* Add registers a layer under the Library heading and switches it on, the
* share link carries `lib=`, a reload restores the selection, Escape and
* clean view close the dialog, and Remove destroys the layer.
*/
import assert from 'node:assert/strict';
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const point = (lon, lat, properties) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties,
});
const collection = (features, extra = {}) =>
JSON.stringify({ type: 'FeatureCollection', features, ...extra });
// 1×1 transparent PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const seen = {
eonet: 0,
arcgisPages: [],
tiles: 0,
proxied: 0,
structureBoxes: [],
};
function fixtureFor(url) {
const cors = { 'Access-Control-Allow-Origin': '*' };
if (url.hostname === 'eonet.gsfc.nasa.gov') {
seen.eonet += 1;
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
point(-120, 38, {
title: 'Fixture Fire',
date: '2026-10-20T00:00:00Z',
}),
point(140, 35, {
title: 'Fixture Storm',
date: '2026-10-19T00:00:00Z',
}),
]),
};
}
if (
url.hostname === 'services2.arcgis.com' &&
url.pathname.includes('USA_Structures')
) {
seen.structureBoxes.push(url.searchParams.get('geometry'));
const square = (lon, lat) => ({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[lon, lat],
[lon + 0.0004, lat],
[lon + 0.0004, lat + 0.0003],
[lon, lat + 0.0003],
[lon, lat],
],
],
},
properties: { OCC_CLS: 'Government', HEIGHT: 30 },
});
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection([
square(-77.035, 38.892),
square(-77.03, 38.893),
square(-77.025, 38.894),
]),
};
}
if (url.hostname === 'services2.arcgis.com') {
const offset = Number(url.searchParams.get('resultOffset'));
seen.arcgisPages.push(offset);
const features =
offset === 0
? [
point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }),
point(-112.9, 33.4, {
Plant_Name: 'Fixture Nuclear',
Total_MW: 3937,
}),
]
: [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })];
return {
status: 200,
headers: cors,
contentType: 'application/json',
body: collection(features, {
properties: { exceededTransferLimit: offset === 0 },
}),
};
}
if (url.hostname === 'gibs.earthdata.nasa.gov') {
seen.tiles += 1;
return { status: 200, headers: cors, contentType: 'image/png', body: PNG };
}
if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') {
seen.proxied += 1;
return {
status: 200,
contentType: 'application/json',
body: collection([
point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }),
]),
};
}
return null;
}
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
defaultViewport: { width: 1440, height: 900 },
protocolTimeout: 300_000,
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const errors = [];
let count = 0;
const step = async (label, fn) => {
await fn();
console.log(`ok ${++count} ${label}`);
};
async function open(page, hash = '') {
await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevDataLibrary?.library.ready &&
window.__godsEyeView?.dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 180_000 },
);
}
const libraryRows = (page) =>
page.evaluate(() =>
[...document.querySelectorAll('#data-toggles [data-library-entry]')].map(
(row) => ({
id: row.dataset.libraryEntry,
on: row.querySelector('.data-toggle-btn')?.classList.contains('active'),
count: row.querySelector('.data-count')?.textContent,
}),
),
);
try {
const page = await browser.newPage();
page.on('console', (message) => {
if (message.type() === 'error' && /Library|library/.test(message.text()))
errors.push(message.text());
});
page.on('pageerror', (error) => errors.push(String(error)));
await page.setRequestInterception(true);
page.on('request', (request) => {
const fixture = fixtureFor(new URL(request.url()));
if (fixture) request.respond(fixture);
else request.continue();
});
await page.evaluateOnNewDocument(() => {
try {
// Start empty once; the reload step needs what this run stored.
if (!sessionStorage.getItem('qa-data-library')) {
localStorage.removeItem('gev:library:v1');
sessionStorage.setItem('qa-data-library', '1');
}
// Skip the first-run chooser (src/firstRunExperience.js).
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
} catch {
/* storage may be blocked; the gate still runs */
}
});
await open(page);
await step(
'the panel offers Browse and lists no library layer by default',
async () => {
assert.deepEqual(await libraryRows(page), []);
const hidden = await page.$eval(
'#data-library-browse',
(node) => node.hidden,
);
assert.equal(hidden, false);
},
);
await step(
'Browse opens a labelled modal with addable entries first and packs last',
async () => {
await page.evaluate(() => {
document.getElementById('data-panel')?.classList.remove('collapsed');
document.getElementById('data-library-browse').click();
});
const dialog = await page.$eval('#data-library', (node) => ({
open: node.open,
modal: node.matches(':modal'),
title: document.getElementById(node.getAttribute('aria-labelledby'))
?.textContent,
focus: document.activeElement?.type,
order: [...node.querySelectorAll('.data-library-item')].map(
(item) => item.dataset.libraryId,
),
}));
assert.equal(dialog.open, true);
assert.equal(dialog.modal, true);
assert.equal(dialog.title, 'DATA LIBRARY');
assert.equal(dialog.focus, 'search');
assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join());
assert.ok(!dialog.order[0].startsWith('cctv-'));
},
);
await step(
'a pack explains the operator switch instead of offering Add',
async () => {
const pack = await page.$eval(
'#data-library .data-library-item[data-library-id="cctv-nyc-dot"]',
(item) => ({
action: Boolean(item.querySelector('.data-library-action')),
reason: item.querySelector('.data-library-reason')?.textContent,
}),
);
assert.equal(pack.action, false);
assert.match(pack.reason, /CCTV_NYC_ENABLED=1/);
},
);
for (const [id, expected] of [
['nasa-eonet-events', '2'],
['eia-power-plants', '3'],
['gvp-holocene-volcanoes', '1'],
['nasa-black-marble', 'DRAPED'],
])
await step(
`Add ${id} registers it under Library and switches it on`,
async () => {
await page.click(
`#data-library button[data-library-id="${id}"][data-library-action="add"]`,
);
await page.waitForFunction(
(layerId, text) => {
const row = document.querySelector(
`#data-toggles [data-layer-id="${layerId}"]`,
);
return (
row
?.querySelector('.data-toggle-btn')
?.classList.contains('active') &&
row.querySelector('.data-count')?.textContent === text
);
},
{ timeout: 60_000 },
`lib-${id}`,
expected,
);
},
);
await step(
'the fixtures were reached the way each manifest says',
async () => {
assert.ok(seen.eonet >= 1);
assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]);
assert.ok(seen.proxied >= 1);
const draped = await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-nasa-black-marble')
.module.getDiagnostics().draped,
);
assert.equal(draped, true);
// Imagery requests tiles on the following frames, not on enable.
for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250)
await new Promise((resolve) => setTimeout(resolve, 250));
assert.ok(seen.tiles >= 1, 'the drape requested its tiles');
const headings = await page.$$eval(
'#data-toggles .data-layer-group-heading',
(nodes) => nodes.map((node) => node.textContent),
);
assert.equal(headings.at(-1), 'Library');
},
);
await step(
'the stats strip sits above the intro and counts what is on the map',
async () => {
await page.waitForFunction(
() =>
document.querySelector(
'[data-stat="features"] .data-library-stat-value',
)?.textContent === '6',
{ timeout: 15_000 },
);
const strip = await page.evaluate(() => {
const stats = document.querySelector(
'#data-library .data-library-stats',
);
const read = (id) =>
[...document.querySelectorAll(`[data-stat="${id}"] dd`)].map(
(node) => node.textContent,
);
return {
beforeIntro:
stats?.nextElementSibling?.classList.contains('data-library-intro'),
label: stats?.getAttribute('aria-label'),
entries: read('entries'),
added: read('added'),
features: read('features'),
};
});
assert.equal(strip.beforeIntro, true);
assert.equal(strip.label, 'Data Library at a glance');
// Counted from the shipped catalog, so a new entry does not break this.
const kinds = await page.evaluate(() => {
const counts = { dataset: 0, tiles: 0, pack: 0 };
for (const { entry } of window.__gevDataLibrary.library.list())
counts[entry.kind] += 1;
return counts;
});
assert.deepEqual(strip.entries, [
String(kinds.dataset + kinds.tiles + kinds.pack),
`${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`,
]);
assert.deepEqual(strip.added, ['4', '4 on the map']);
// Fixtures: 2 EONET events, 3 power plants, 1 volcano.
assert.deepEqual(strip.features, ['6', 'from 3 datasets']);
},
);
await step('the share link carries lib= with the ledger tokens', async () => {
await page.waitForFunction(
() => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)),
{ timeout: 15_000 },
);
});
await step(
'Escape closes the dialog and returns focus to Browse',
async () => {
await page.focus('#data-library input[type="search"]');
await page.keyboard.press('Escape');
const state = await page.evaluate(() => ({
open: document.getElementById('data-library').open,
focus: document.activeElement?.id,
}));
assert.deepEqual(state, { open: false, focus: 'data-library-browse' });
},
);
await step('clean view closes an open dialog', async () => {
const open = await page.evaluate(async () => {
window.__gevDataLibrary.browser.open();
document.body.classList.add('ui-clean-view');
await new Promise((resolve) => setTimeout(resolve, 50));
const result = document.getElementById('data-library').open;
document.body.classList.remove('ui-clean-view');
return result;
});
assert.equal(open, false);
});
await step(
'a reload restores the added entries and what was on',
async () => {
await page.evaluate(() => {
document
.querySelector(
'#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn',
)
.click();
});
await page.waitForFunction(
() =>
JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3,
);
await open(page, '');
await page.waitForFunction(
() =>
document.querySelectorAll('#data-toggles [data-library-entry]')
.length === 4,
{ timeout: 60_000 },
);
const rows = await libraryRows(page);
assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [
['eia-power-plants', true],
['gvp-holocene-volcanoes', false],
['nasa-black-marble', true],
['nasa-eonet-events', true],
]);
},
);
const cameraCenter = () =>
page.evaluate(() => {
const { camera } = window.__godsEyeView.viewer;
const carto = camera.positionCartographic;
return {
lon: (carto.longitude * 180) / Math.PI,
lat: (carto.latitude * 180) / Math.PI,
height: carto.height,
};
});
const waitForFlight = () =>
page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 30_000, polling: 200 },
);
await step(
'Zoom to layer on the row works while off: it switches on and flies',
async () => {
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]';
await page.evaluate(() =>
document.getElementById('data-panel')?.classList.remove('collapsed'),
);
// The icons work while the layer is off; Zoom switches it back on.
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'),
{ timeout: 30_000 },
);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N.
assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center));
assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center));
},
);
await step(
'ZOOM in the dialog closes it and flies to that entry',
async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
const zoom =
'#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]';
assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off');
await page.click(
'#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]',
);
const open = await page.$eval('#data-library', (node) => node.open);
assert.equal(open, false);
await new Promise((resolve) => setTimeout(resolve, 300));
await waitForFlight();
const center = await cameraCenter();
// Fixture events at (-120, 38) and (140, 35): the frame sits between them.
assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center));
},
);
await step('Remove destroys the layer and forgets it', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click(
'#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]',
);
await page.waitForFunction(
() =>
!document.querySelector(
'#data-toggles [data-layer-id="lib-nasa-black-marble"]',
) &&
!window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'),
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.equal(stored.added.includes('nasa-black-marble'), false);
});
await step(
'the row delete icon removes that entry, even while it is off',
async () => {
await page.evaluate(() => {
window.__gevDataLibrary.browser.close();
document.getElementById('data-panel')?.classList.remove('collapsed');
});
const chip =
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]';
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'lib-eia-power-plants',
false,
{ origin: 'user' },
),
);
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
const label = await page.$eval(chip, (node) => ({
name: node.getAttribute('aria-label'),
glyph: node.textContent,
pressed: node.getAttribute('aria-pressed'),
}));
assert.deepEqual(label, {
name: 'Remove from Data Layers',
glyph: 'delete',
pressed: null,
});
await page.evaluate(
(selector) => document.querySelector(selector).click(),
chip,
);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers.has(
'lib-eia-power-plants',
) &&
!document.querySelector(
'#data-toggles [data-layer-id="lib-eia-power-plants"]',
),
{ timeout: 30_000 },
);
},
);
await step(
'a view-bounded layer asks to zoom in, then loads only what is in view',
async () => {
await page.evaluate(() =>
window.__gevDataLibrary.library.add('fema-usa-structures'),
);
const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]';
await page.waitForFunction(
(selector) =>
/zoom in to load this layer/.test(
document.querySelector(`${selector} .data-toggle-meta`)
?.textContent || '',
),
{ timeout: 30_000 },
row,
);
assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit');
await page.evaluate(() =>
window.__gevDataLibrary.library.zoom('fema-usa-structures'),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().count === 3,
{ timeout: 60_000, polling: 250 },
);
const altitude = (await cameraCenter()).height;
assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`);
const [west, south, east, north] = seen.structureBoxes
.at(-1)
.split(',')
.map(Number);
assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box');
assert.ok(
west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893,
);
// Climbing back out clears the buildings and shows the guidance again.
await page.evaluate(() => {
const { viewer, requestRender } = window.__godsEyeView;
viewer.camera.zoomOut(60_000);
requestRender?.('qa-data-library');
});
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('lib-fema-usa-structures')
.module.getDiagnostics().tooHigh,
{ timeout: 30_000, polling: 250 },
);
},
);
await step('Remove all asks twice, then clears the library', async () => {
await page.evaluate(() => window.__gevDataLibrary.browser.open());
await page.click('#data-library .data-library-reset');
const armed = await page.$eval(
'#data-library .data-library-reset',
(node) => ({
text: node.textContent,
armed: node.classList.contains('is-armed'),
}),
);
assert.equal(armed.armed, true);
assert.match(armed.text, /CONFIRM/);
assert.ok(
(await page.$$('#data-toggles [data-library-entry]')).length > 0,
'one press removes nothing',
);
await page.click('#data-library .data-library-reset');
await page.waitForFunction(
() =>
!document.querySelector('#data-toggles [data-library-entry]') &&
document.querySelector('#data-library .data-library-reset').hidden,
{ timeout: 30_000 },
);
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('gev:library:v1')),
);
assert.deepEqual(stored, { added: [], on: [] });
});
assert.deepEqual(errors, [], 'no library errors on the page');
console.log(`# ${count} passed`);
} finally {
await browser.close();
}
+2
View File
@@ -27,6 +27,7 @@
*/
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -223,6 +224,7 @@ async function main() {
const errors = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
const { lon, lat } = CENTER;
+2
View File
@@ -21,6 +21,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
const argv = process.argv.slice(2);
const getOpt = (name, fallback = null) => {
@@ -651,6 +652,7 @@ async function main() {
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport(VIEWPORT);
const consoleMessages = [];
page.on('console', (message) => {
+73
View File
@@ -28,6 +28,8 @@
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
* seen from over the Indian Ocean no pin clearly behind the planet is
* shown; back over North America the near-side pins show again.
* (viii) AGE — seven days is the default; shorter/longer windows and All
* markers update the globe, and the age choice survives a reload.
*
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
*
@@ -224,6 +226,77 @@ async function main() {
record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
const ageChips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
),
].map((option) => ({
id: option.dataset.chipId,
active: option.getAttribute('aria-checked') === 'true',
})),
LAYER_ID,
);
record(
'AGE: seven days is the default with an All markers option',
live.ageDays === 7 &&
ageChips.length === 6 &&
ageChips
.filter((chip) => chip.active)
.map((chip) => chip.id)
.join() === 'age-7' &&
ageChips.some((chip) => chip.id === 'age-all'),
);
const selectAge = async (days) => {
await showRow(page);
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
await page.waitForSelector(selector, { timeout: 15000 });
// Dispatch through the rendered button, matching the product-chip gate.
await page.$eval(selector, (button) => button.click());
await page.waitForFunction(
(id, age) => {
const s = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return s.ageDays === age && !s.loading;
},
{ timeout: TIMEOUT_MS },
LAYER_ID,
days,
);
return stats(page);
};
const all = await selectAge(null);
record(
'AGE: All markers restores old events without duplicate data sources',
all.count > live.count &&
(await sources(page)).length === live.products.length,
`${live.count} → ${all.count}`,
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await enableAndSettle(page);
const persistedAge = await stats(page);
record(
'AGE: All markers survives a reload',
persistedAge.ageDays === null && persistedAge.count === all.count,
);
const thirty = await selectAge(30);
const one = await selectAge(1);
const seven = await selectAge(7);
record(
'AGE: counts follow the window order and seven days restores the default',
one.count <= live.count &&
thirty.count >= live.count &&
thirty.count <= all.count &&
seven.count === live.count &&
seven.ageDays === 7,
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
);
// ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => {
const gev = window.__godsEyeView;
+2
View File
@@ -99,6 +99,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
// ---------------------------------------------------------------------------
// Args (same shape as track-regression.mjs)
@@ -466,6 +467,7 @@ async function main() {
const failedResponses = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
+12 -12
View File
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
* re-resolve the interpreter from PATH and hand the gate a different Node than
* the one the check believes it selected.
* @param {string} version e.g. "v24.19.0"
* @returns {boolean} true only for a parseable Node 24.
* @returns {boolean} true for a parseable supported Node 24 or 26.
*/
function isCalibratedAllocationRuntime(version) {
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
return maj === 24;
return maj === 24 || maj === 26;
}
/**
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
// ─── A · REPO GATES ───────────────────────────────────────────────────────
check({
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)',
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
run: async () => {
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
const range = pkg.engines?.node || '(unset)';
@@ -557,7 +557,7 @@ check({
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
// An off-range runtime is an environment problem, not a product defect —
// but it silently disables the allocation gate, so say so loudly. A3 then
// runs that gate under a discovered Node 24.
// runs that gate under a discovered calibrated Node 24 fallback.
return env.node24
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
@@ -579,13 +579,13 @@ check({
});
check({
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)',
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
run: async () => {
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT
// proof the gate ran. Force it, under Node 24 when one is discoverable.
// exits 0 when the runtime is outside the calibrated supported majors. A green
// suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
//
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
// ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
// re-resolves the interpreter from PATH and can land back on the system
// Node even when THIS process is already 24: running the whole matrix under
// `mise exec [email protected] --`, the npm shell-out still re-execed system
@@ -597,10 +597,10 @@ check({
const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
const version = (v.out || '').trim();
if (!isCalibratedAllocationRuntime(version)) {
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24,
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
// so a wrong binary means the gate never ran. That is this check failing
// to establish its own claim — not evidence about the product.
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`);
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
}
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
@@ -611,9 +611,9 @@ check({
};
const [maj] = process.versions.node.split('.').map(Number);
if (maj === 24) return runGate(process.execPath, 'this runtime');
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
if (env.node24) return runGate(env.node24.bin, env.node24.label);
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
},
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Keep browser QA off third-party aircraft photo services.
*
* Tracking a civil flight asks Planespotters for the aircraft's photo. QA
* runs track synthetic hexes, so a real request only adds a third-party
* dependency and, when the service refuses a headless browser, a console
* error that fails the "no console errors" checks. Every photo lookup is
* answered with no photos instead, without leaving the browser.
*/
/** Origin of the aircraft photo lookup. */
export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net';
/** The answer every stubbed lookup gets: no photos for this aircraft. */
export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] });
/** Whether a URL is a photo lookup the stub answers. */
export function isPlanespottersRequest(url) {
try {
return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN;
} catch {
return false;
}
}
/**
* Answer an intercepted photo lookup from a page that already uses request
* interception. Returns false for any other request, which the caller
* handles as before.
* @param {import('puppeteer').HTTPRequest} request
* @returns {boolean} Whether the request was answered.
*/
export function respondToPlanespotters(request) {
if (!isPlanespottersRequest(request.url())) return false;
request.respond({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: PLANESPOTTERS_EMPTY_BODY,
});
return true;
}
/**
* Stub photo lookups in a page that does not intercept requests: wraps
* `fetch` before any app code runs, so a lookup never reaches the network.
* Composes with other `fetch` shims in either order.
* @param {import('puppeteer').Page} page
* @returns {Promise<void>}
*/
export async function stubPlanespotters(page) {
await page.evaluateOnNewDocument(
(origin, body) => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const href =
typeof input === 'string' ? input : input?.url || String(input);
let target = '';
try {
target = new URL(href, location.href).origin;
} catch {
/* not a URL */
}
if (target === origin) {
return Promise.resolve(
new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
}
return original(input, init);
};
},
PLANESPOTTERS_ORIGIN,
PLANESPOTTERS_EMPTY_BODY,
);
}
+2
View File
@@ -32,6 +32,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
@@ -186,6 +187,7 @@ async function main() {
const httpErrors = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
+33 -1
View File
@@ -296,6 +296,22 @@ async function expandPanel(page) {
);
}
/** A keyless bundle opens on the first keyless source with live controls. */
async function assertKeylessDefault(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await uiUntil(page, litAlone, 'kartaview');
const shown = await page.evaluate(() => ({
status: document.getElementById('sl-status').textContent,
disabled: document.getElementById('sl-controls').disabled,
}));
assert.notEqual(shown.status, 'KEY REQUIRED');
assert.equal(shown.disabled, false, 'controls stay live');
}
async function assertKeyRequired(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
@@ -528,11 +544,25 @@ async function main() {
);
await expandPanel(page);
if (!configured) {
if (!configured && litAlone(await getUI(page), 'mapillary')) {
// A keyed bundle behind a server without the token keeps Mapillary.
await step('keyless server: KEY REQUIRED gates the controls', () =>
assertKeyRequired(page),
);
skip('no Mapillary key', 'the keyed steps');
} else if (!configured) {
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
assertKeylessDefault(page),
);
await step(
'keyless server: the MAPILLARY chip explains its key instead of switching',
async () => {
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await new Promise((resolve) => setTimeout(resolve, 500));
assert.ok(litAlone(await getUI(page), 'kartaview'));
},
);
skip('no Mapillary key', 'the keyed steps');
} else {
await park(page);
await step(
@@ -751,6 +781,8 @@ async function main() {
await park(page);
await fixtureStep(
// The fixture bundle carries a token, so this covers a keyless
// server behind a keyed bundle; the keyless default is checked above.
'keyless server (second page): KEY REQUIRED gates the controls',
async () => {
const keyless = await open(
+11 -11
View File
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
'src/overlays/worldOverlayAllocation.test.mjs',
]);
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
export function isCalibratedAllocationRuntime(version = process.versions.node) {
return Number.parseInt(String(version).split('.')[0], 10) === 24;
const major = Number.parseInt(String(version).split('.')[0], 10);
return major === 24 || major === 26;
}
/** Require the runtime on which allocation budgets were calibrated. */
export function assertNode24AllocationRuntime(version = process.versions.node) {
/** Require a runtime on which allocation budgets are calibrated. */
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
if (!isCalibratedAllocationRuntime(version)) {
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
}
return version;
}
@@ -92,19 +93,18 @@ export function runUnitTests() {
const parallelStatus = runTests(['--test', ...plan.parallel]);
if (parallelStatus !== 0) return parallelStatus;
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
// other allocators. A contributor's suite must stay green on any supported
// engine (package.json permits >=24), so uncalibrated runtimes skip the
// The GC-bracketed budgets are calibrated on every supported Node major.
// Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
// release batteries) to make an uncalibrated runtime a hard failure.
if (!isCalibratedAllocationRuntime()) {
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
assertNode24AllocationRuntime();
assertCalibratedAllocationRuntime();
}
console.warn(
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
+ `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
+ 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
);
return 0;
}
+12
View File
@@ -94,6 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { respondToPlanespotters } from './qa-photo-stub.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -287,6 +288,8 @@ async function main() {
});
return;
}
// Aircraft photo lookups never reach the third party: no photos.
if (respondToPlanespotters(request)) return;
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
@@ -409,6 +412,15 @@ async function main() {
status: 'ready',
}));
}
// CelesTrak SATCAT, which the satellite classes read beside the TLEs.
// An empty CSV (header only) keeps the run hermetic: the classes fall
// back to names and groups, as they do when SATCAT is unavailable.
if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) {
return Promise.resolve(new Response(
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n',
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
));
}
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
// the satellite assertions are deterministic and the run never depends
// on an upstream that rate-limits (it was answering 403 the night this
+9
View File
@@ -83,6 +83,15 @@ export function adsbLolProxy() {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
// After the /track hand-off, so /track keeps its own method handling.
if (req.method !== 'GET') {
res.writeHead(405, {
'Content-Type': 'application/json',
Allow: 'GET',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT');
+69 -22
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { encodedBody, gzipBody } from '../common/compress.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
@@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0;
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/**
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it.
* Null when the body is not worth compressing.
*/
let _openskyCacheGzip = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
@@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) {
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
);
const normalized = normalizeAdsbLolPointResponse(payload);
const body = JSON.stringify(normalized);
const record = {
body: JSON.stringify(normalized),
body,
// Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
// extra memory.
gzip: await gzipBody(body),
cachedAt: Date.now(),
count: normalized.states.length,
};
@@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) {
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
const encoded = encodedBody(req, fallback.body, fallback.gzip);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
@@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
...encoded.headers,
});
res.end(fallback.body);
res.end(encoded.body);
return true;
}
@@ -484,6 +496,15 @@ export function openSkyProxy() {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
// After the /track hand-off, so /track keeps its own method handling.
if (req.method !== 'GET') {
res.writeHead(405, {
'Content-Type': 'application/json',
Allow: 'GET',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
res.setHeader('X-Feed-Source', 'OpenSky Network');
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
@@ -514,9 +535,13 @@ export function openSkyProxy() {
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
@@ -532,8 +557,9 @@ export function openSkyProxy() {
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
@@ -628,7 +654,11 @@ export function openSkyProxy() {
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
// Compress before writing either slot, so a request arriving
// mid-refresh still reads a body with its own frame.
const frame = await gzipBody(body);
_openskyCacheBody = body;
_openskyCacheGzip = frame;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
@@ -681,9 +711,13 @@ export function openSkyProxy() {
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(
200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
@@ -691,8 +725,9 @@ export function openSkyProxy() {
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
}
@@ -734,16 +769,23 @@ export function openSkyProxy() {
}
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
// The frame belongs to this body only if this body was cached. An
// uncached error must not borrow the previous snapshot's frame.
const encoded = encodedBody(
req,
body,
_openskyCacheBody === body ? _openskyCacheGzip : null,
);
res.writeHead(upstream.status, {
...buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
}),
);
res.end(body);
...encoded.headers,
});
res.end(encoded.body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
@@ -754,17 +796,22 @@ export function openSkyProxy() {
usedMode: 'unknown',
reason: 'cached_stale',
};
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(
+187 -5
View File
@@ -1,4 +1,5 @@
import { createCctvCatalog } from './cctv/catalog.js';
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
import {
buildSyntheticCctvSvg,
@@ -18,6 +19,8 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
@@ -43,6 +46,120 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
const puller = createHlsPuller();
/**
* Bounded transient image reuse keeps cadence-limited providers (World
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
* cap is the real bound; the entry cap only limits map overhead.
*/
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
const worldFrameCache = new Map();
const worldFrameInflight = new Map();
const worldFrameNextAllowedAt = new Map();
let worldFrameCacheBytes = 0;
function getWorldFrameCache(cameraId) {
const entry = worldFrameCache.get(cameraId);
if (!entry) return null;
worldFrameCache.delete(cameraId);
worldFrameCache.set(cameraId, entry);
return entry;
}
function storeWorldFrame(cameraId, image) {
if (
!Buffer.isBuffer(image?.body) ||
image.body.length > WORLD_FRAME_CACHE_MAX_ENTRY_BYTES
)
return;
const previous = worldFrameCache.get(cameraId);
if (previous) worldFrameCacheBytes -= previous.body.length;
const entry = {
body: image.body,
contentType: image.contentType,
fetchedAt: Date.now(),
};
worldFrameCache.delete(cameraId);
worldFrameCache.set(cameraId, entry);
worldFrameCacheBytes += entry.body.length;
while (
worldFrameCache.size > WORLD_FRAME_CACHE_MAX_ENTRIES ||
worldFrameCacheBytes > WORLD_FRAME_CACHE_MAX_BYTES
) {
const oldestId = worldFrameCache.keys().next().value;
const oldest = worldFrameCache.get(oldestId);
worldFrameCache.delete(oldestId);
worldFrameCacheBytes -= oldest.body.length;
}
}
function setNextAllowedAt(cameraId, at) {
worldFrameNextAllowedAt.delete(cameraId);
worldFrameNextAllowedAt.set(cameraId, at);
while (worldFrameNextAllowedAt.size > 4096) {
worldFrameNextAllowedAt.delete(
worldFrameNextAllowedAt.keys().next().value,
);
}
}
/**
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
* floor the last frame is reused; with no frame to reuse the caller gets
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
* never falls through to the key-bearing Street View fallback. A failed
* fetch backs off for a minute (capped at the floor) instead of a full
* window.
*/
async function fetchThrottledFrame(source, cameraId, frameUrl) {
const cached = getWorldFrameCache(cameraId);
const rawRefreshMs = Number(source?.frameRefreshMs);
const refreshMs =
Number.isFinite(rawRefreshMs) && rawRefreshMs > 0
? rawRefreshMs
: 5 * 60 * 1000;
const now = Date.now();
if (cached && now - cached.fetchedAt < refreshMs) {
return { ok: true, ...cached, cached: true };
}
const inflight = worldFrameInflight.get(cameraId);
if (inflight) return inflight;
const stale = cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: null;
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
if (!frameUrl) return stale;
setNextAllowedAt(cameraId, now + refreshMs);
let request;
request = fetchCctvImageFromUpstream(frameUrl)
.then((image) => {
if (image?.ok) {
storeWorldFrame(cameraId, image);
return image;
}
setNextAllowedAt(
cameraId,
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
);
return stale || image;
})
.finally(() => {
if (worldFrameInflight.get(cameraId) === request)
worldFrameInflight.delete(cameraId);
});
worldFrameInflight.set(cameraId, request);
return request;
}
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
const setHealth = (cameraId, patch) => {
@@ -153,6 +270,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
);
if (url.pathname === '/sources') {
const quebecVideo = quebecBrowserVideoEnabled();
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
sources: sources.map((source) => ({
@@ -164,6 +282,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
provider: source.provider,
lat: source.lat,
lon: source.lon,
frameRefreshMs: source.frameRefreshMs,
headingDeg: source.headingDeg,
headingConfidence: source.headingConfidence || '',
pitchDeg: source.pitchDeg,
@@ -174,6 +293,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
feedType: normalizeFeedType(source.feedType),
sourceKind:
source.sourceKind || (source.url ? 'configured' : 'fallback'),
sourceUrl: source.sourceUrl || '',
browserMediaUrl:
quebecVideo && source.sourceKind === 'quebec-511-browser'
? quebec511VideoUrl(source.id)
: '',
accuracyM: source.accuracyM,
positionSource: source.positionSource || '',
poseSource: source.poseSource,
model: source.model || '',
manufacturer: source.manufacturer || '',
@@ -526,15 +652,71 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
: '');
const upstreamImage =
source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
source?.pack === 'world-cameras'
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
: source?.sourceKind === 'ottawa-traffic'
? await fetchThrottledFrame(
source,
cameraId,
ottawaAuthorizedFrameUrl(upstreamCandidate),
)
: source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate, {
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
});
if (
source?.sourceKind === 'mlit-river' &&
isMlitRiverAdjustmentImage(upstreamImage)
) {
const svg = buildSyntheticCctvSvg({
cameraId,
label,
city,
status: 'CAMERA UNDER ADJUSTMENT',
});
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'snapshot',
label: source.provider || 'MLIT river cameras',
message: 'Provider camera under adjustment',
});
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'X-CCTV-Source': 'provider-maintenance',
});
res.end(svg);
return;
}
if (upstreamImage?.throttled) {
// Inside the provider's refresh floor with no frame to reuse: a
// placeholder, not Street View, and no health change.
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'X-CCTV-Source': 'synthetic',
});
res.end(
buildSyntheticCctvSvg({
cameraId,
label,
city,
status: 'WAITING FOR NEXT FRAME',
}),
);
return;
}
if (upstreamImage?.ok) {
setHealth(cameraId, {
status: 'ok',
status: upstreamImage.stale ? 'degraded' : 'ok',
sourceKind: 'snapshot',
label: source?.provider || 'Configured source',
message: 'Upstream snapshot active',
message: upstreamImage.stale
? 'Serving last snapshot; refresh failed'
: upstreamImage.cached
? 'Cached snapshot active'
: 'Upstream snapshot active',
});
res.writeHead(200, {
'Content-Type': upstreamImage.contentType,
+49 -12
View File
@@ -1,5 +1,6 @@
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_CCTV_MAX_SOURCES,
} from './constants.js';
@@ -23,16 +24,27 @@ export function resolveCatalogCap(raw) {
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
* each pack per turn, in each pack's own order — so a lowered
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
* that many rows only from the capacity ABOVE the shared floor, so a
* reservation adds room and never shrinks the other packs' round-robin
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
* ids resolve last-pack-wins so
* file/env overrides keep replacing live records; the record is then counted
* against the pack that won it.
*
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
* packs in merge order (earlier packs lose duplicate ids to later ones).
* @param {number} maxCount - Catalog cap.
* @param {object} [options]
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
* for the round-robin before reservations apply.
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
*/
export function allocateSourceCap(packs, maxCount) {
export function allocateSourceCap(
packs,
maxCount,
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
) {
const owner = new Map();
for (const pack of packs) {
for (const source of pack.sources) {
@@ -40,30 +52,53 @@ export function allocateSourceCap(packs, maxCount) {
owner.set(source.id, { pack: pack.name, source });
}
}
const lanes = packs.map((pack) => ({
name: pack.name,
const lanes = packs.map((pack) => {
// Only the record that WON its id (the last occurrence anywhere, so also
// the last within this pack) takes a lane slot; earlier duplicates never
// count against the cap.
queue: pack.sources.filter(
const queue = pack.sources.filter(
(source) => source?.id && owner.get(source.id)?.source === source,
),
next: 0,
kept: 0,
}));
);
return {
name: pack.name,
queue,
reserved: Math.max(
0,
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
),
next: 0,
kept: 0,
};
});
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
const sources = [];
const reservedSources = [];
if (limit >= total) {
for (const lane of lanes) {
sources.push(...lane.queue);
lane.kept = lane.queue.length;
}
} else {
while (sources.length < limit) {
// Reserved packs draw only on the capacity above the shared floor, then
// every lane (reserved ones included) shares the rest round-robin.
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
while (reservedSources.length < reserveBudget) {
let tookReserved = false;
for (const lane of lanes) {
if (sources.length >= limit) break;
if (reservedSources.length >= reserveBudget) break;
if (lane.kept >= lane.reserved) continue;
reservedSources.push(lane.queue[lane.next]);
lane.next += 1;
lane.kept += 1;
tookReserved = true;
}
if (!tookReserved) break;
}
while (sources.length + reservedSources.length < limit) {
for (const lane of lanes) {
if (sources.length + reservedSources.length >= limit) break;
if (lane.next >= lane.queue.length) continue;
sources.push(lane.queue[lane.next]);
lane.next += 1;
@@ -72,7 +107,9 @@ export function allocateSourceCap(packs, maxCount) {
}
}
return {
sources,
// Keep the shared round-robin sequence first so the default active camera
// remains in its established region; guaranteed regional rows follow it.
sources: [...sources, ...reservedSources],
packs: lanes.map((lane) => ({
name: lane.name,
offered: lane.queue.length,
+34 -2
View File
@@ -1,11 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import {
DEFAULT_CCTV_SOURCE_FILE,
CCTV_SOURCE_CACHE_MS,
WORLD_CCTV_SOURCE_COUNT,
} from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import { loadLithuaniaSources } from './lithuania.js';
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
import { loadTaiwanSources } from './taiwan.js';
import { loadWorldCameraSources } from './worldCameras.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -20,6 +27,7 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadIcelandSourcesFromOpenData,
loadHkSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
@@ -75,6 +83,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
load: loadDriveBcSourcesFromOpenData,
},
{
name: 'ottawa',
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
load: loadOttawaSourcesFromOpenData,
},
{
name: 'txdot',
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
@@ -110,6 +123,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'iceland',
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
load: loadIcelandSourcesFromOpenData,
},
{
name: 'hk',
enabled: () => envEnabled('CCTV_HK_ENABLED'),
@@ -172,6 +190,18 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
load: loadHoustonSourcesFromOpenData,
},
{
name: 'quebec',
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
load: loadQuebecSources,
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
},
{
name: 'world-cameras',
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
load: loadWorldCameraSources,
reservedCount: WORLD_CCTV_SOURCE_COUNT,
},
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
@@ -316,7 +346,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
: [];
// Live packs first so file/env overrides win on duplicate IDs; each pack
// keeps its own priority order and the catalog cap is shared fairly.
const normalizePack = (name, items) => ({
const normalizePack = (name, items, reservedCount = 0) => ({
name,
sources: items
.filter((item) => item && typeof item === 'object')
@@ -326,6 +356,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
? items.available
: items.length,
region: items.region ?? null,
reservedCount,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
@@ -334,6 +365,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
liveResults[index]?.status === 'fulfilled'
? liveResults[index].value
: [],
pack.reservedCount,
),
),
normalizePack('file', fromFile),
+74 -7
View File
@@ -5,15 +5,18 @@ export const DEFAULT_AUSTIN_ROWS_URL =
/** Default cap on Austin cameras after distance-based prioritization. */
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
/**
* Catalog-wide safety ceiling on served cameras. Each pack already caps
* itself (nearest-to-anchor first); this bound only matters when the packs
* together exceed it, and it is then filled round-robin across packs (see
* cap.js) so no region is silently dropped. The default per-pack caps
* together can exceed it, so a default install may be thinned evenly here.
* Slots every pack shares round-robin before any reservation applies. A
* reserved pack (Québec, World) only takes capacity above this floor, so a
* cap at or below it thins every region evenly (see cap.js).
*/
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
/**
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
* 1,120 Québec and 2,230 World image locations.
*/
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
export const CCTV_MAX_SOURCES_CEILING = 5000;
export const CCTV_MAX_SOURCES_CEILING = 8000;
/** Reference point for Austin camera prioritization (Congress & 6th). */
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
@@ -197,6 +200,39 @@ export const DRIVEBC_ANCHORS = [
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
{ lat: 48.4284, lon: -123.3656 }, // Victoria
];
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
export const OTTAWA_CAMERAS_URL =
'https://traffic.ottawa.ca/map/service/camera';
/**
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
* proxy adds at request time so it never appears in a catalog response.
*/
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
/** Pinned frame directory, for redirect containment in the media proxy. */
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
/** The API lets one certificate and user id fetch a camera once a minute. */
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
/** Alphanumeric user id this server sends with its certificate. */
export const OTTAWA_API_USER_ID = 'gev';
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
/**
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
* so the pin is the host root: a redirect must stay on the same server.
*/
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
);
/** Direct still-image directories from the World cams add-on's eligible packs. */
export const TRIPCHECK_IMAGE_ORIGIN =
'https://www.tripcheck.com/roadcams/cams/';
export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/';
export const DEFAULT_WORLD_CCTV_SOURCE_FILE =
'config/cctv_sources.world-image-cameras.json';
export const WORLD_CCTV_SOURCE_COUNT = 2230;
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
@@ -309,6 +345,37 @@ export const CATALONIA_ANCHORS = [
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/**
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
* one keyless JSON list, one row per camera view (about 500 views at roughly
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
* rewritten by the publisher every few minutes. Camera images are published
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
*/
export const ICELAND_CAMERAS_URL =
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
/** The only directory Iceland camera frames may come from. */
export const ICELAND_IMAGE_ORIGIN =
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Ground-elevation prior in metres: the feed carries none, and the country
* runs from sea level to highland passes. The client's ground snap corrects
* this on 3D-tile stacks. */
export const ICELAND_GROUND_ELEVATION_M = 60;
/** The feed lists about 500 views; the default keeps half so the shared
* catalog cap still leaves room for denser regions. */
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
* every region's nearest cameras instead of only the capital's. */
export const ICELAND_ANCHORS = [
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
];
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
export const DEFAULT_HK_CAMERAS_URL =
+243 -6
View File
@@ -10,10 +10,15 @@ import {
CCTV_MEDIA_MAX_BODY_BYTES,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
MLIT_RIVER_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
OTTAWA_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
ICELAND_IMAGE_ORIGIN,
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
TFL_IMAGE_ORIGIN,
WARENDORF_IMAGE_ORIGINS,
WSDOT_IMAGE_ORIGIN,
@@ -523,13 +528,18 @@ for (const prefix of [
TFL_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
ICELAND_IMAGE_ORIGIN,
...WARENDORF_IMAGE_ORIGINS,
NSW_IMAGE_ORIGIN,
OTTAWA_IMAGE_ORIGIN,
CALGARY_IMAGE_ORIGIN,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
WSDOT_IMAGE_ORIGIN,
A22_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
MLIT_RIVER_IMAGE_ORIGIN,
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
]) {
const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
@@ -621,6 +631,216 @@ export function cctvUpstreamUserAgent(url) {
}
}
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
/**
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
*
* The first part may follow a CRLF preamble, and its delimiter may be written
* as `--<boundary>` or, when the declared boundary already starts with `--`,
* as the boundary itself. A part with Content-Length is read to that length;
* a part without one ends at the next delimiter, so a JPEG end marker inside
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
* into a preallocated buffer and every search resumes where it stopped, so a
* trickling upstream costs linear time.
*/
async function readFirstMjpegFrame(upstream, maxBytes) {
const mediaType = cctvLiveMediaType(
'mjpeg',
upstream.headers.get('content-type'),
);
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
if (
!boundary ||
!upstream.body ||
!Number.isSafeInteger(frameMax) ||
frameMax < 4
) {
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return null;
}
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
if (boundary.startsWith('--') && boundary.length > 2)
delimiters.push(Buffer.from(boundary, 'latin1'));
const lineEnd = Buffer.from('\r\n', 'ascii');
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
const jpegStart = Buffer.from([0xff, 0xd8]);
const jpegEnd = Buffer.from([0xff, 0xd9]);
const maxPreambleBytes = 64;
const maxHeaderBytes = 8 * 1024;
const maxBufferedBytes =
maxPreambleBytes +
delimiters[0].length +
lineEnd.length +
maxHeaderBytes +
headerTerminator.length +
frameMax +
lineEnd.length +
delimiters[0].length;
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
let length = 0;
let reader;
let headerStart = -1;
let delimiter = null;
let headerScanFrom = 0;
let bodyStart = -1;
let declaredLength = null;
let boundaryScanFrom = -1;
/** Match the opening delimiter after an optional CRLF preamble. */
function findOpening() {
let at = 0;
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
if (at > maxPreambleBytes) return { failed: true };
for (const candidate of delimiters) {
const line = Buffer.concat([candidate, lineEnd]);
const need = at + line.length;
if (length < need) {
const seen = buffer.subarray(at, length);
if (line.subarray(0, seen.length).equals(seen))
return { pending: true };
continue;
}
if (buffer.subarray(at, need).equals(line))
return { start: need, delimiter: candidate };
}
return { failed: true };
}
/** Parse the part headers; false rejects the stream. */
function parseHeaders(headerEnd) {
const headerText = buffer
.subarray(headerStart, headerEnd)
.toString('latin1');
const headers = new Map();
for (const line of headerText.split('\r\n')) {
const colon = line.indexOf(':');
if (colon <= 0) return false;
const name = line.slice(0, colon).trim().toLowerCase();
if (!name || headers.has(name)) return false;
headers.set(name, line.slice(colon + 1).trim());
}
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
return false;
const declared = headers.get('content-length');
if (declared !== undefined) {
if (!/^\d+$/.test(declared)) return false;
declaredLength = Number(declared);
if (
!Number.isSafeInteger(declaredLength) ||
declaredLength < jpegStart.length + jpegEnd.length ||
declaredLength > frameMax
)
return false;
}
return true;
}
/** A complete JPEG body, or null. */
function asJpeg(frame) {
return frame.length >= jpegStart.length + jpegEnd.length &&
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
frame.subarray(-jpegEnd.length).equals(jpegEnd)
? Buffer.from(frame)
: null;
}
try {
reader = upstream.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) return null;
const chunk = Buffer.from(value);
const room = maxBufferedBytes - length;
const included = Math.min(room, chunk.length);
chunk.copy(buffer, length, 0, included);
length += included;
const overflow = included < chunk.length;
if (headerStart < 0) {
const opening = findOpening();
if (opening.failed) return null;
if (opening.pending) {
if (overflow) return null;
continue;
}
headerStart = opening.start;
delimiter = opening.delimiter;
headerScanFrom = headerStart;
}
if (bodyStart < 0) {
const headerEnd = buffer
.subarray(0, length)
.indexOf(headerTerminator, headerScanFrom);
if (headerEnd < 0) {
if (length - headerStart > maxHeaderBytes || overflow) return null;
headerScanFrom = Math.max(
headerStart,
length - headerTerminator.length + 1,
);
continue;
}
if (headerEnd - headerStart > maxHeaderBytes) return null;
if (!parseHeaders(headerEnd)) return null;
bodyStart = headerEnd + headerTerminator.length;
boundaryScanFrom = bodyStart;
}
const available = length - bodyStart;
if (available >= jpegStart.length) {
if (
!buffer
.subarray(bodyStart, bodyStart + jpegStart.length)
.equals(jpegStart)
)
return null;
}
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
const nextPart = buffer
.subarray(0, length)
.indexOf(nextDelimiter, boundaryScanFrom);
if (declaredLength !== null) {
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
return null;
if (available >= declaredLength)
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
} else if (nextPart >= bodyStart) {
const frame = buffer.subarray(bodyStart, nextPart);
return frame.length > frameMax ? null : asJpeg(frame);
} else if (available > frameMax) {
return null;
}
if (nextPart < 0)
boundaryScanFrom = Math.max(
bodyStart,
length - nextDelimiter.length + 1,
);
if (overflow) return null;
}
} finally {
if (reader) {
try {
await reader.cancel();
} catch {
/* already closed or aborted */
}
try {
reader.releaseLock();
} catch {
/* already released */
}
}
}
}
/**
* Fetch one upstream CCTV image within the frame-refresh budget.
*
@@ -633,6 +853,10 @@ export function cctvUpstreamUserAgent(url) {
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
* answer and keep its first JPEG. Only sources registered as MJPEG stills
* (the Taiwan Highway Bureau feeds) set it; any other host answering
* multipart falls through to the next fallback.
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
*/
export async function fetchCctvImageFromUpstream(
@@ -641,6 +865,7 @@ export async function fetchCctvImageFromUpstream(
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
allowMjpeg = false,
} = {},
) {
if (!url || !/^https?:\/\//i.test(url)) return null;
@@ -660,10 +885,7 @@ export async function fetchCctvImageFromUpstream(
fetchImpl,
);
if (!upstream) return null;
const contentType = cctvRasterImageType(
upstream.headers.get('content-type'),
);
if (!upstream.ok || !contentType) {
if (!upstream.ok) {
try {
await upstream.body?.cancel();
} catch {
@@ -671,9 +893,24 @@ export async function fetchCctvImageFromUpstream(
}
return null;
}
const body = await readCappedResponseBytes(upstream, maxBytes);
const upstreamType = upstream.headers.get('content-type');
const contentType = cctvRasterImageType(upstreamType);
if (contentType) {
const body = await readCappedResponseBytes(upstream, maxBytes);
if (!body) return null;
return { ok: true, body, contentType };
}
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return null;
}
const body = await readFirstMjpegFrame(upstream, maxBytes);
if (!body) return null;
return { ok: true, body, contentType };
return { ok: true, body, contentType: 'image/jpeg' };
} catch {
return null;
} finally {
+15
View File
@@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
);
}
/** Iceland bounding box (mainland and nearby islands, with a margin). */
export function isLikelyIcelandCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 63.0 &&
lat <= 66.7 &&
lon >= -25.0 &&
lon <= -13.0
);
}
/**
* Decode numeric HTML character references, decimal ("&#237;") and hex
* ("&#xED;") alike, the entity form the Catalonia feed uses for accented
@@ -639,6 +650,10 @@ export function normalizeSourceItem(item) {
feedType: normalizeFeedType(item.feedType || item.type || ''),
url: typeof item.url === 'string' ? item.url : '',
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
accuracyM: toFiniteNumber(item.accuracyM),
positionSource: String(item.positionSource || ''),
license: String(item.license || item.licenseNote || ''),
// Per-camera attribution for feeds a partner supplies inside a pack
// (DriveBC: TransLink, city cameras). Shown beside the provider.
+185
View File
@@ -0,0 +1,185 @@
import {
CCTV_SOURCE_FETCH_TIMEOUT_MS,
DEFAULT_OTTAWA_MAX_SOURCES,
OTTAWA_ANCHORS,
OTTAWA_API_USER_ID,
OTTAWA_CAMERAS_URL,
OTTAWA_FRAME_REFRESH_MS,
OTTAWA_IMAGE_ORIGIN,
OTTAWA_IMAGE_URL,
} from './constants.js';
import {
fallbackHeadingFromId,
pinnedCameraUrl,
prioritizeSources,
toFiniteNumber,
} from './normalize.js';
let ottawaCertificateMissingLogged = false;
/** Test hook: let the next missing-certificate skip log again. */
export function _resetOttawaCertificateWarningForTest() {
ottawaCertificateMissingLogged = false;
}
/** The operator's Traffic Camera Images API certificate, trimmed. */
function ottawaCertificate(env = process.env) {
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
}
/**
* Add the operator's certificate to a registered keyless Ottawa frame URL.
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
* number is signed, so the certificate never goes to any other path or host.
*
* @param {string} url - Canonical frame URL from the catalog.
* @param {Record<string,string|undefined>} [env]
* @returns {string} The signed URL, or '' when there is no certificate or the
* URL is not a registered Ottawa frame.
*/
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
const certificate = ottawaCertificate(env);
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
if (!certificate || !pinned) return '';
const parsed = new URL(pinned);
const cameraNumber = parsed.searchParams.get('c') || '';
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
return '';
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
signed.searchParams.set('certificate', certificate);
signed.searchParams.set('id', OTTAWA_API_USER_ID);
return signed.href;
}
/**
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
* slack for the MTO cameras on the 416/417 that the city feed also carries.
*/
const OTTAWA_BOUNDS = Object.freeze({
minLat: 45.1,
maxLat: 45.65,
minLon: -76.2,
maxLon: -75.1,
});
/** Whether a point is inside the city feed's service area. */
function isOttawaCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= OTTAWA_BOUNDS.minLat &&
lat <= OTTAWA_BOUNDS.maxLat &&
lon >= OTTAWA_BOUNDS.minLon &&
lon <= OTTAWA_BOUNDS.maxLon
);
}
/**
* Load the City of Ottawa's traffic cameras.
*
* Camera images come from the city's Traffic Camera Images API, which needs a
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
* and allows one fetch per camera per minute for a certificate and user id.
* Without a certificate the pack is skipped, with no request and one log line.
* The camera list is the documented `map/service/camera` JSON: a camera
* number, English and French names, coordinates and the owner (CITY or MTO).
* The feed carries no bearing, no elevation and no status field, so headings
* use the same deterministic low-confidence fallback as the other
* heading-less packs and the client's ground snap refines the elevation
* prior.
*
* @param {object} [_options]
* @param {Record<string,string|undefined>} [env]
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
* a missing certificate or any upstream or payload failure.
*/
export async function loadOttawaSourcesFromOpenData(
_options = {},
env = process.env,
) {
if (!ottawaCertificate(env)) {
if (!ottawaCertificateMissingLogged) {
ottawaCertificateMissingLogged = true;
console.warn(
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
);
}
return [];
}
try {
const resp = await fetch(OTTAWA_CAMERAS_URL, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
return [];
}
const payload = await resp.json();
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
if (!rows) return [];
const cameras = [];
const seen = new Set();
for (const row of rows) {
const number = toFiniteNumber(row?.camera_number);
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
continue;
const lat = toFiniteNumber(row?.latitude);
const lon = toFiniteNumber(row?.longitude);
if (!isOttawaCoordinate(lat, lon)) continue;
seen.add(number);
const cameraId = `ottawa-${number}`;
const imageUrl = OTTAWA_IMAGE_URL(number);
// The docs name the field camera_owner; the live feed sends cameraOwner.
const owner = String(row?.camera_owner || row?.cameraOwner || '')
.trim()
.toUpperCase();
cameras.push({
id: cameraId,
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
city: 'Ottawa',
cityId: 'ottawa',
provider: 'City of Ottawa',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: 0,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
sourceKind: 'ottawa-traffic',
license: 'City of Ottawa Traffic Camera Images API (registered access)',
credit:
owner === 'MTO'
? 'Ministry of Transportation of Ontario (MTO) camera'
: '',
});
}
const maxRaw = Number(
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
: DEFAULT_OTTAWA_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
console.log(
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Ottawa camera download error:',
error?.message || error,
);
return [];
}
}
+138
View File
@@ -0,0 +1,138 @@
import fs from 'node:fs';
import path from 'node:path';
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
/**
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
* default: the camera locations are CC BY 4.0, but whether Québec 511's
* service terms allow another site to embed its clips is unconfirmed, so an
* unset flag lists the cameras with a link to the 511 viewer page only.
*/
export function quebecBrowserVideoEnabled(env = process.env) {
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
}
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
export function quebec511VideoUrl(cameraId) {
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
return match
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
: '';
}
function safeViewerUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: '';
} catch {
return '';
}
}
function sourceId(camera) {
const id = String(camera?.id || '').trim();
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
return `qc-mtl-${id.slice(4)}`;
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
return `qc-web-${id.slice(4)}`;
return '';
}
/**
* Read the checked-in, no-key Québec location catalog and adapt it to the
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
* their operator page because this dataset has no direct image URL for them.
*/
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
let cameras;
try {
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
} catch (error) {
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
return [];
}
const sources = [];
const seen = new Set();
for (const camera of cameras) {
const id = sourceId(camera);
const lat = Number(camera.lat);
const lon = Number(camera.lon);
if (
!id ||
seen.has(id) ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
lat < 44.9 ||
lat > 62.6 ||
lon < -79.9 ||
lon > -57.1
)
continue;
seen.add(id);
const name = String(camera.nameEn || camera.name || id).trim();
const isTraffic = camera.kind === 'traffic';
const isMontreal = camera.kind === 'montreal';
const sourceKind = isTraffic
? 'quebec-511-browser'
: isMontreal
? 'quebec-montreal-osm'
: 'quebec-webcam-link';
const provider = isTraffic
? 'Québec 511 / MTQ'
: isMontreal
? 'Ville de Montréal / OpenStreetMap'
: String(camera.operator || 'Québec webcam');
sources.push({
id,
name: name.slice(0, 180),
city: String(camera.region || 'Québec'),
cityId: 'quebec',
provider,
lat,
lon,
headingDeg: fallbackHeadingFromId(id),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 74,
rangeM: 400,
mountHeightM: 8,
groundElevationM: 0,
feedType: 'image',
// No frame URL is registered: the server never fetches 511 media. With
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
// overlay after a play click; otherwise the camera links to 511.
url: '',
snapshotUrl: '',
sourceKind,
sourceUrl: safeViewerUrl(camera.viewerUrl),
accuracyM: Number.isFinite(Number(camera.accuracyM))
? Number(camera.accuracyM)
: null,
positionSource: String(camera.positionSource || ''),
license: isTraffic
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
: isMontreal
? 'OpenStreetMap contributors, ODbL 1.0'
: 'Operator terms apply; location provenance shown per camera',
credit: isTraffic
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
: isMontreal
? 'OpenStreetMap contributors · ODbL'
: provider,
code: cameraDisplayCode(name.toUpperCase()),
});
}
return sources;
}
+175
View File
@@ -134,6 +134,12 @@ import {
HOUSTON_MAX_CATALOG_BYTES,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_ANCHORS,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
ICELAND_GROUND_ELEVATION_M,
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -152,6 +158,7 @@ import {
isLikelyCalgaryCoordinate,
isLikelyFloridaCoordinate,
isLikelyCataloniaCoordinate,
isLikelyIcelandCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHoustonCoordinate,
@@ -2029,6 +2036,174 @@ export async function loadCataloniaSourcesFromOpenData() {
}
}
/**
* Compass headings for Icelandic direction words, as they appear after "til"
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
* as in "séð austur").
*/
const ICELAND_DIRECTION_HEADINGS = {
norður: 0,
norðaustur: 45,
austur: 90,
suðaustur: 135,
suður: 180,
suðvestur: 225,
vestur: 270,
norðvestur: 315,
};
const ICELAND_DIRECTION_WORD =
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
const ICELAND_DIRECTION_RE = new RegExp(
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
'gu',
);
/**
* The compass heading an Icelandic view description states, or NaN. Only an
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
* a road direction ("Miklabraut austur") is not a facing. A description naming
* two different directions ("til norðurs og til suðurs", "til austurs og
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
* road) states none.
*
* @param {string} text - The feed's `Skyring` field.
* @returns {number} Heading in degrees [0..360), or NaN.
*/
export function icelandHeadingFromDescription(text) {
const headings = new Set();
for (const match of String(text || '')
.toLowerCase()
.matchAll(ICELAND_DIRECTION_RE)) {
for (const word of [match[1], match[2]]) {
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
}
}
return headings.size === 1 ? headings.values().next().value : NaN;
}
/**
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
* file name, which is unique per view in the live feed.
*
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
* @returns {?object}
*/
export function icelandCameraToSource(row) {
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
// Numbers only: Number(null) and Number('') are 0.
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
const stem = new URL(url).pathname
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
.replace(/\.jpe?g$/i, '')
.toLowerCase();
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
const cameraId = `is-vegagerdin-${stem}`;
const description = String(row.Skyring || '')
.replace(/\s+/g, ' ')
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
// Árnessýslu" while missing "séð séð".
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
.trim();
const station = String(row.Myndavel || '').trim();
const heading = icelandHeadingFromDescription(description);
const hasHeading = Number.isFinite(heading);
return {
id: cameraId,
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
city: 'Iceland',
cityId: 'iceland',
provider: 'Vegagerðin (IRCA)',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: ICELAND_GROUND_ELEVATION_M,
feedType: 'image',
url,
snapshotUrl: url,
sourceKind: 'iceland-vegagerdin',
license:
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
code: cameraDisplayCode(station.toUpperCase() || description),
};
}
/**
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
* Frames are stills on the IRCA host and the loader registers only links
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
* ids, coordinates, labels and which frame path is proxied, so it is fetched
* over https, redirects are refused and the body read is capped at
* ICELAND_MAX_CATALOG_BYTES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadIcelandSourcesFromOpenData() {
try {
const resp = await fetch(ICELAND_CAMERAS_URL, {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
// A response this loader will not read still owns its transport until the
// body is released, so every rejection path cancels before returning.
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Iceland catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Iceland camera download failed:', resp.status);
return discard();
}
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
const rows = Array.isArray(body) ? body : [];
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_ICELAND_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
console.log(
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Iceland camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Read one XML child tag's text from an `<image>` block.
*
+3 -1
View File
@@ -5,7 +5,9 @@ export const HLS_LIMITS = Object.freeze({
sessions: 2,
leasesPerSession: 8,
playlistBytes: 256 * 1024,
segmentBytes: 4 * 1024 * 1024,
// High-resolution cameras can exceed 4 MiB in a single ten-second TS segment.
// Retained data still shares the independent 24 MiB session budget below.
segmentBytes: 8 * 1024 * 1024,
sessionBytes: 24 * 1024 * 1024,
segments: 12,
pollMs: 2000,
+158 -11
View File
@@ -1,4 +1,7 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
CCTV_SOURCE_FETCH_TIMEOUT_MS,
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
} from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
@@ -10,6 +13,8 @@ import { readResponseTextCapped } from '../common/http.js';
* standard, published keyless and refreshed daily (~1,800 cameras). */
export const TAIWAN_FREEWAY_CCTV_URL =
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
export const TAIWAN_HIGHWAY_CCTV_URL =
'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
/** The whole network is ~1.2 MB. */
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
@@ -35,6 +40,9 @@ const STREAM_TO_FRAME = new Map([
]);
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set(
TAIWAN_HIGHWAY_IMAGE_ORIGINS.map((origin) => new URL(origin).hostname),
);
/** Prioritization anchors, north to south, so a cap keeps the whole island
* rather than only greater Taipei. */
@@ -211,6 +219,74 @@ export function taiwanCameraToSource(row) {
};
}
/**
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
* snapshot by the existing camera-frame proxy.
*
* @param {object} row - Output of parseTaiwanCctvXml.
* @returns {?object}
*/
export function taiwanHighwayCameraToSource(row) {
if (!row || typeof row !== 'object') return null;
const { lat, lon } = row;
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
let url;
try {
url = new URL(String(row.streamUrl ?? ''));
} catch {
return null;
}
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
url.search ||
url.hash ||
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
url.pathname.includes('..')
)
return null;
const rawId = String(row.cctvId ?? '').trim();
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
const cameraId = 'tw-thb-' + rawId.toLowerCase();
const road = String(row.roadName ?? '').trim();
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
const mile = String(row.mile ?? '').trim();
const section = [row.sectionStart, row.sectionEnd]
.map((part) => String(part ?? '').trim())
.filter(Boolean)
.join('→');
const name =
(road || mile || section
? [road, direction, mile, section].filter(Boolean).join(' ')
: '') || rawId;
return {
id: cameraId,
name: name.slice(0, 160),
city: 'Taiwan / 臺灣',
cityId: 'taiwan',
provider: 'Highway Bureau, MOTC',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'mjpeg',
url: url.href,
snapshotUrl: url.href,
sourceKind: 'taiwan-highway-motc',
license: 'Highway Bureau, MOTC open data',
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
};
}
let lastGood = null;
let lastGoodAt = 0;
let retryAfter = 0;
@@ -237,6 +313,49 @@ function taiwanSourceCap() {
: DEFAULT_TAIWAN_MAX_SOURCES;
}
/** Fetch the public provincial-road CCTV table as a no-key fallback. */
async function loadTaiwanHighwaySources() {
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
const response = await fetch(TAIWAN_HIGHWAY_CCTV_URL, {
headers: { Accept: 'application/xml,text/xml' },
redirect: 'manual',
signal,
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'Highway Bureau catalog redirected; redirects are not followed'
: 'Highway Bureau catalog HTTP ' + response.status,
);
}
const xml = await readResponseTextCapped(
response,
TAIWAN_MAX_CATALOG_BYTES,
signal,
);
const cameras = new Map();
for (const row of parseTaiwanCctvXml(xml)) {
const camera = taiwanHighwayCameraToSource(row);
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
}
if (!cameras.size)
throw new Error('no usable Highway Bureau cameras in the table');
const sources = prioritizeSources(
[...cameras.values()],
taiwanSourceCap(),
ANCHORS,
);
console.log(
'[CCTV] Loaded Taiwan Highway Bureau fallback: ' +
cameras.size +
' (using ' +
sources.length +
')',
);
return sources;
}
/**
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
* One bounded request per shared catalog refresh. A failed download serves the
@@ -262,7 +381,7 @@ export async function loadTaiwanSources() {
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `HTTP ${response.status}`,
: 'HTTP ' + response.status,
);
}
const xml = await readResponseTextCapped(
@@ -282,7 +401,11 @@ export async function loadTaiwanSources() {
ANCHORS,
);
console.log(
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
'[CCTV] Loaded Taiwan camera sources: ' +
cameras.size +
' (using ' +
sources.length +
')',
);
lastGood = sources;
lastGoodAt = Date.now();
@@ -291,13 +414,37 @@ export async function loadTaiwanSources() {
} catch (error) {
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
const fallback = fallbackSources();
console.warn(
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
(fallback.length
? `serving the last good ${fallback.length} cameras`
: 'no Taiwan cameras') +
', next attempt in 1 h',
);
return fallback;
if (fallback.length) {
console.warn(
'[CCTV] Taiwan Freeway camera download failed (' +
(error?.message || error) +
'); serving the last good ' +
fallback.length +
' cameras, next attempt in 1 h',
);
return fallback;
}
try {
const sources = await loadTaiwanHighwaySources();
lastGood = sources;
lastGoodAt = Date.now();
console.warn(
'[CCTV] Taiwan Freeway camera download failed (' +
(error?.message || error) +
'); using ' +
sources.length +
' Highway Bureau cameras as fallback, next attempt in 1 h',
);
return sources;
} catch (fallbackError) {
console.warn(
'[CCTV] Taiwan Freeway camera download failed (' +
(error?.message || error) +
'); Highway Bureau fallback failed (' +
(fallbackError?.message || fallbackError) +
'); no Taiwan cameras, next attempt in 1 h',
);
return [];
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
DEFAULT_WORLD_CCTV_SOURCE_FILE,
MLIT_RIVER_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
WORLD_CCTV_SOURCE_COUNT,
} from './constants.js';
import { pinnedCameraUrl } from './normalize.js';
const SOURCE_POLICIES = Object.freeze({
'tripcheck-odot': {
idPrefix: 'world-or-',
origin: TRIPCHECK_IMAGE_ORIGIN,
bounds: [41, 47, -125, -116],
filename: /^\/roadcams\/cams\/[^/]+\.jpe?g$/i,
frameRefreshMs: 5 * 60 * 1000,
},
'mlit-river': {
idPrefix: 'world-jp-river-',
origin: MLIT_RIVER_IMAGE_ORIGIN,
bounds: [24, 46, 122, 154],
filename: /^\/cam\/now\/\d+\.jpg$/i,
// The provider asks clients to avoid frequent repeated image collection.
frameRefreshMs: 10 * 60 * 1000,
},
});
// MLIT currently serves this fixed JPEG on cameras whose images are paused
// for maintenance (verified against two Yamagata cameras on 2026-11-02).
const MLIT_ADJUSTMENT_IMAGE_SHA256 =
'dbd2c3790eb75edf9ca44162f7979f2a3799090e23f2c5142f63211a035cb191';
export function isMlitRiverAdjustmentImage(image) {
return (
image?.ok === true &&
Buffer.isBuffer(image.body) &&
createHash('sha256').update(image.body).digest('hex') ===
MLIT_ADJUSTMENT_IMAGE_SHA256
);
}
function safeFrameUrl(value, policy) {
const normalized = pinnedCameraUrl(value, policy.origin);
if (!normalized) return '';
const url = new URL(normalized);
return !url.search && !url.hash && policy.filename.test(url.pathname)
? url.href
: '';
}
function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= minLat &&
lat <= maxLat &&
lon >= minLon &&
lon <= maxLon
);
}
/**
* Load only the new image-backed networks from the add-on export. Existing
* Canadian networks, Québec entries, JMA placeholder images, and NZTA's
* scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland)
* rows are excluded too: the Iceland pack loads the full live list of those
* cameras, so a World copy would draw each one twice.
*/
export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) {
const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE);
let rows;
try {
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
rows = Array.isArray(payload) ? payload : [];
} catch (error) {
console.warn('[CCTV] World camera catalog unavailable:', error?.message);
return [];
}
const seen = new Set();
const sources = [];
for (const row of rows) {
const sourceKind = String(row?.sourceKind || '');
const policy = SOURCE_POLICIES[sourceKind];
if (!policy) continue;
const id = String(row.id || '').trim();
const lat = Number(row.lat);
const lon = Number(row.lon);
const url = safeFrameUrl(row.url || row.snapshotUrl, policy);
const snapshotUrl = safeFrameUrl(row.snapshotUrl || row.url, policy);
if (
!id.startsWith(policy.idPrefix) ||
seen.has(id) ||
!inBounds(lat, lon, policy.bounds) ||
!url ||
!snapshotUrl ||
url !== snapshotUrl
)
continue;
seen.add(id);
sources.push({
id,
name: String(row.name || id)
.trim()
.slice(0, 180),
city: String(row.city || row.country || 'World'),
cityId: String(row.cityId || ''),
provider: String(row.provider || 'World traffic camera'),
lat,
lon,
headingDeg: Number.isFinite(Number(row.headingDeg))
? Number(row.headingDeg)
: undefined,
headingConfidence: String(row.headingConfidence || 'low'),
feedType: 'image',
sourceKind,
url,
snapshotUrl,
frameRefreshMs: policy.frameRefreshMs,
license: String(row.license || ''),
credit: String(row.credit || row.provider || ''),
});
}
console.log(
`[CCTV] Loaded World camera image sources: ${sources.length}/${WORLD_CCTV_SOURCE_COUNT}`,
);
return sources;
}
+81
View File
@@ -0,0 +1,81 @@
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
/**
* Opt-in gzip for text bodies a proxy already holds in memory.
*
* Not a middleware: `cctv/media.js` streams bodies and answers range requests,
* and several providers forward binary upstream types that gzip only grows.
* `encodedBody` returns the body with its headers so the two cannot disagree.
*/
/** Skip compression for bodies smaller than 1 KiB. */
const MIN_COMPRESSED_BYTES = 1024;
/**
* RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops`
* and `q=2` unreadable instead of rounding them into acceptance.
*/
const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
/**
* Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad
* q value, `x-gzip`) reads as no: every client can decode identity.
*
* @param {import('node:http').IncomingMessage} request
* @returns {boolean}
*/
export function acceptsGzip(request) {
const header = request?.headers?.['accept-encoding'];
if (typeof header !== 'string') return false;
return header.split(',').some((part) => {
const [token, ...parameters] = part.split(';');
if (token.trim().toLowerCase() !== 'gzip') return false;
const quality = parameters
.map((parameter) => parameter.trim().toLowerCase())
.find((parameter) => parameter.startsWith('q='));
if (quality === undefined) return true;
// `q=0` names gzip and refuses it.
const value = quality.slice(2);
return QVALUE.test(value) && Number(value) > 0;
});
}
/**
* Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky
* snapshot does not block other requests while it compresses.
*
* Returns null, meaning "serve identity", when the body is under 1 KiB or gzip
* does not shrink it.
*
* @param {string|Buffer} body - the exact bytes the identity response sends.
* @returns {Promise<Buffer|null>}
*/
export async function gzipBody(body) {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body);
if (raw.byteLength < MIN_COMPRESSED_BYTES) return null;
const compressed = await gzipAsync(raw);
return compressed.byteLength < raw.byteLength ? compressed : null;
}
/**
* Pick the body and the headers that describe it. `Vary` is on both branches
* so a shared cache never serves one encoding to a client that asked for the
* other.
*
* @param {import('node:http').IncomingMessage} request
* @param {string|Buffer} identity - the uncompressed body.
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
* @returns {{body: string|Buffer, headers: Record<string,string>}}
*/
export function encodedBody(request, identity, compressed) {
if (compressed && acceptsGzip(request)) {
return {
body: compressed,
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
};
}
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
}
+6 -9
View File
@@ -131,9 +131,9 @@ export async function fetchFirms(
* NASA FIRMS live active-fire proxy with a memory + disk cache.
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
*
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
* Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no
* cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, ~1 km, numeric
* 0-100 confidence), fetched sequentially (quota courtesy)
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
@@ -141,6 +141,8 @@ export async function fetchFirms(
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
*
* VIIRS_SNPP_NRT is left out: NASA stopped delivering Suomi NPP on 2026-11-02.
*
* Routes:
* GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires}
* GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions}
@@ -153,12 +155,7 @@ export async function fetchFirms(
export function firmsProxy() {
const TTL_MS = 30 * 60_000;
const STATUS_TTL_MS = 5 * 60_000;
const SOURCES = [
'VIIRS_NOAA20_NRT',
'VIIRS_NOAA21_NRT',
'VIIRS_SNPP_NRT',
'MODIS_NRT',
];
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT'];
/**
* Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about
* 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a
-363
View File
@@ -1,363 +0,0 @@
import { LIBRARY_ENTRIES } from '../../src/library/catalog.js';
import { loadLibraryDataset } from '../../src/library/formats.js';
import { buildPartitionIndex } from '../../src/library/parquet.js';
import {
libraryUpstreamUrl,
parseLibraryIdList,
libraryTileUrl,
} from '../../src/library/manifest.js';
import { KEY_SETUP_KEYS } from '../../src/keySetupCore.mjs';
import {
readResponseBytesCapped,
readResponseTextCapped,
coalesceProxyRequest,
} from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSite, admitSameSiteKeyless } from './common/same-site.js';
const DATASET_TIMEOUT_MS = 30_000;
const TILE_TIMEOUT_MS = 10_000;
/** Each listing page or footer read while building a partition index. */
const INDEX_TIMEOUT_MS = 20_000;
const TILE_MAX_BYTES = 2 * 1024 * 1024;
const TILE_CACHE_LIMIT = 512;
const IN_FLIGHT_LIMIT = 64;
/**
* Availability of every entry on this instance, decided by the operator's
* environment alone: `GEV_LIBRARY_DISABLE` removes entries, opt-in entries
* need `GEV_LIBRARY_ENABLE` (or, for a pack, its host's own switch), and a
* keyed entry needs its key. The answer names a reason, never a secret.
* @param {object[]} entries
* @param {Record<string, string|undefined>} env
* @returns {Map<string, { available: boolean, reason: string|null }>}
*/
export function libraryAvailability(entries, env) {
const enabled = parseLibraryIdList(env.GEV_LIBRARY_ENABLE);
const disabled = parseLibraryIdList(env.GEV_LIBRARY_DISABLE);
const result = new Map();
for (const entry of entries) {
let reason = null;
if (disabled.has(entry.id)) reason = 'operator-disabled';
else if (entry.kind === 'pack') {
if (String(env[entry.pack.env] || '').trim() !== '1')
reason = 'operator-opt-in';
} else if (entry.licence.operatorOptIn && !enabled.has(entry.id))
reason = 'operator-opt-in';
else if (entry.source.key && !libraryKeyValue(entry.source.key, env))
reason = 'key-missing';
result.set(entry.id, { available: reason === null, reason });
}
return result;
}
/** The configured value for a manifest key, through the key registry. */
export function libraryKeyValue(key, env) {
const registered = KEY_SETUP_KEYS.find(({ id }) => id === key?.id);
for (const name of registered?.envVars || []) {
const value = String(env[name] || '').trim();
if (value) return value;
}
return null;
}
function withKey(url, key, env, headers) {
if (!key) return url;
const value = libraryKeyValue(key, env);
if (!value) throw Object.assign(new Error('key_missing'), { status: 503 });
if (key.in === 'header') {
headers[key.name] = value;
return url;
}
const keyed = new URL(url);
keyed.searchParams.set(key.name, value);
return keyed.href;
}
/**
* Parse `/<id>/<z>/<x>/<y>` against a tiles entry's zoom range.
* @returns {{ z: number, x: number, y: number } | null}
*/
export function parseLibraryTilePath(parts, source) {
if (parts.length !== 3 || !parts.every((part) => /^\d{1,8}$/.test(part)))
return null;
const [z, x, y] = parts.map(Number);
const minZoom = source.minZoom ?? 0;
const maxZoom = source.maxZoom ?? 22;
if (z < minZoom || z > maxZoom) return null;
const span = 2 ** z;
if (x >= span || y >= span) return null;
return { z, x, y };
}
/**
* The Data Library's one server route. Proxied datasets and tiles reach their
* manifest's fixed upstream only: the browser names an entry id, never a URL,
* so the allowlist is exactly the catalog. Redirects are refused, bodies are
* capped while streaming, keys come from this server's environment and
* upstream error text is never returned.
*/
export function libraryProxy({
entries = LIBRARY_ENTRIES,
env = process.env,
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const datasets = new Map();
const tiles = new Map();
const indexes = new Map();
const inFlight = new Map();
const allowData = makeRateLimiter({
windowMs: 60_000,
max: 30,
globalMax: 600,
});
const allowTiles = makeRateLimiter({
windowMs: 60_000,
max: 600,
globalMax: 6000,
});
async function upstream(url, entry, headers, timeoutMs) {
// Defense in depth: every derived request stays on the manifest's host.
const target = libraryUpstreamUrl(url);
const declared = libraryUpstreamUrl(entry.source.url);
if (!target || !declared || target.host !== declared.host)
throw Object.assign(new Error('off_manifest_host'), { status: 502 });
const signal = AbortSignal.timeout(timeoutMs);
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Accept: '*/*', ...headers },
});
if (!response.ok) {
await response.body?.cancel?.().catch?.(() => {});
throw Object.assign(new Error('upstream_unavailable'), { status: 502 });
}
return { response, signal };
}
async function fetchDataset(entry) {
const headers = {};
const collection = await loadLibraryDataset(entry, async (url, budget) => {
const keyed = withKey(url, entry.source.key, env, headers);
const { response, signal } = await upstream(
keyed,
entry,
headers,
DATASET_TIMEOUT_MS,
);
const text = await readResponseTextCapped(
response,
budget.remainingBytes,
signal,
);
budget.remainingBytes -= new TextEncoder().encode(text).byteLength;
return text;
});
return { ...collection, fetchedAt: now() };
}
async function cached(store, key, ttlMs, load, limit = Infinity) {
const previous = store.get(key);
if (previous && now() - previous.savedAt < ttlMs)
return { value: previous.value, stale: false };
if (!inFlight.has(key) && inFlight.size >= IN_FLIGHT_LIMIT)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
store.delete(key);
store.set(key, { value, savedAt: now() });
while (store.size > limit) store.delete(store.keys().next().value);
return value;
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
throw error;
}
}
function json(res, status, value, extra = {}) {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...extra,
});
res.end(JSON.stringify(value));
}
function gate(req, res, entry) {
// A keyed entry spends the operator's credential: refuse forwarded
// requests like the other cost-bearing routes. Keyless entries keep
// working behind a reverse proxy, like terrain heights.
return entry?.source?.key
? admitSameSite(req, res)
: admitSameSiteKeyless(req, res);
}
async function handler(req, res) {
if (req.method !== 'GET')
return json(res, 405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
const parts = path.split('/').filter(Boolean);
const availability = libraryAvailability(entries, env);
if (parts.length === 1 && parts[0] === 'catalog') {
if (admitSameSiteKeyless(req, res)) return undefined;
return json(res, 200, {
entries: entries.map(({ id }) => ({ id, ...availability.get(id) })),
});
}
const [route, id, ...rest] = parts;
const entry = byId.get(id);
if (!entry || !['data', 'tile', 'index'].includes(route))
return json(res, 404, { error: 'unknown_entry' });
// A partitioned GeoParquet entry's index: which file covers where, read
// from each file's footer. Built once per cacheSeconds and shared, so
// visitors do not each list the bucket and read every footer.
if (route === 'index') {
if (rest.length || !entry.source?.partitions)
return json(res, 404, { error: 'not_partitioned' });
if (admitSameSiteKeyless(req, res)) return undefined;
const { available, reason } = availability.get(id);
if (!available) return json(res, 403, { error: reason });
if (!allowData(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const { value } = await cached(
indexes,
`index:${id}`,
entry.source.cacheSeconds * 1000,
async () => ({
partitions: await buildPartitionIndex(entry, {
fetchImpl: (url, options = {}) =>
fetchImpl(url, {
...options,
signal:
options.signal ?? AbortSignal.timeout(INDEX_TIMEOUT_MS),
}),
}),
builtAt: now(),
}),
);
return json(res, 200, value);
} catch (error) {
const status = error?.status === 429 ? 429 : 502;
return json(res, status, { error: 'library_upstream_unavailable' });
}
}
const isDataset = route === 'data' && rest.length === 0;
const isTile = route === 'tile';
if (
entry.source.fetch !== 'proxied' ||
(isDataset && entry.kind !== 'dataset') ||
(isTile && entry.kind !== 'tiles') ||
(!isDataset && !isTile)
)
return json(res, 404, { error: 'not_proxied' });
if (gate(req, res, entry)) return undefined;
const { available, reason } = availability.get(id);
if (!available) return json(res, 403, { error: reason });
const ttlMs = entry.source.cacheSeconds * 1000;
if (isDataset) {
if (!allowData(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const { value, stale } = await cached(
datasets,
`data:${id}`,
ttlMs,
() => fetchDataset(entry),
);
return json(
res,
200,
stale ? { ...value, stale: true } : value,
stale ? { 'X-Data-Stale': 'true' } : {},
);
} catch (error) {
const status = [429, 503].includes(error?.status) ? error.status : 502;
return json(res, status, {
error:
status === 503 ? 'key_missing' : 'library_upstream_unavailable',
});
}
}
const tile = parseLibraryTilePath(rest, entry.source);
if (!tile) return json(res, 400, { error: 'invalid_tile' });
if (!allowTiles(clientKey(req)))
return json(res, 429, { error: 'rate_limited' });
try {
const key = `tile:${id}:${tile.z}/${tile.x}/${tile.y}`;
const { value } = await cached(
tiles,
key,
ttlMs,
async () => {
const headers = {};
const url = withKey(
libraryTileUrl(entry.source.url, tile.z, tile.x, tile.y),
entry.source.key,
env,
headers,
);
const { response, signal } = await upstream(
url,
entry,
headers,
TILE_TIMEOUT_MS,
);
const type = String(response.headers.get('content-type') || '')
.split(';')[0]
.trim()
.toLowerCase();
if (!/^image\/(png|jpeg|webp)$/.test(type)) {
await response.body?.cancel?.().catch?.(() => {});
throw Object.assign(new Error('not_an_image'), { status: 502 });
}
const bytes = await readResponseBytesCapped(
response,
TILE_MAX_BYTES,
signal,
);
return { type, bytes };
},
TILE_CACHE_LIMIT,
);
if (res.destroyed) return undefined;
res.writeHead(200, {
'Content-Type': value.type,
'Cache-Control': `private, max-age=${entry.source.cacheSeconds}`,
'X-Content-Type-Options': 'nosniff',
});
res.end(Buffer.from(value.bytes));
return undefined;
} catch (error) {
const status = [429, 503].includes(error?.status) ? error.status : 502;
return json(res, status, {
error: status === 503 ? 'key_missing' : 'library_upstream_unavailable',
});
}
}
return {
name: 'data-library',
handler,
configureServer({ middlewares }) {
middlewares.use('/api/library', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/library', handler);
},
};
}
-332
View File
@@ -1,332 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
libraryAvailability,
libraryProxy,
parseLibraryTilePath,
} from './library.js';
import { validateLibraryCatalog } from '../../src/library/manifest.js';
const base = {
family: 'events',
provider: 'Fixture',
description: 'Fixture entry.',
maintainer: 'test',
coverage: { global: true },
licence: { name: 'Public domain', url: 'https://example.org/licence' },
attribution: { text: 'Fixture' },
status: 'community',
};
const { entries, errors } = validateLibraryCatalog([
{
...base,
id: 'proxied-points',
kind: 'dataset',
title: 'Proxied points',
source: {
format: 'geojson',
url: 'https://data.example.org/points.json',
fetch: 'proxied',
maxBytes: 4096,
},
display: { label: 'title' },
},
{
...base,
id: 'direct-points',
kind: 'dataset',
title: 'Direct points',
source: {
format: 'geojson',
url: 'https://data.example.org/direct.json',
fetch: 'browser-direct',
},
},
{
...base,
id: 'keyed-points',
kind: 'dataset',
title: 'Keyed points',
source: {
format: 'geojson',
url: 'https://keyed.example.org/points.json',
fetch: 'proxied',
key: { id: 'firms', in: 'query', name: 'MAP_KEY' },
},
},
{
...base,
id: 'opt-in-points',
kind: 'dataset',
title: 'Opt-in points',
source: {
format: 'geojson',
url: 'https://data.example.org/opt.json',
fetch: 'proxied',
},
licence: { ...base.licence, operatorOptIn: true },
},
{
...base,
id: 'proxied-tiles',
kind: 'tiles',
title: 'Proxied tiles',
source: {
format: 'xyz',
url: 'https://tiles.example.org/{z}/{x}/{y}.png',
fetch: 'proxied',
minZoom: 2,
maxZoom: 5,
},
},
{
...base,
id: 'fixture-pack',
kind: 'pack',
family: 'cameras',
title: 'Fixture cameras',
source: { format: 'host', url: 'https://cams.example.org/' },
pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' },
licence: { ...base.licence, operatorOptIn: true },
},
]);
assert.deepEqual(errors, []);
const collection = (features) =>
JSON.stringify({ type: 'FeatureCollection', features });
const point = (title) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [1, 2] },
properties: { title },
});
function install({ env = {}, fetchImpl, now } = {}) {
const plugin = libraryProxy({ entries, env, fetchImpl, now });
let handler;
plugin.configurePreviewServer({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/library');
handler = callback;
},
},
});
return async (url, headers = {}) => {
const res = {
headers: {},
writeHead(status, values) {
this.status = status;
this.headers = values;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.raw = body;
try {
this.body = JSON.parse(body);
} catch {
this.body = null;
}
},
set statusCode(value) {
this.status = value;
},
};
await handler(
{ url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } },
res,
);
return res;
};
}
test('availability is decided by the operator environment and names no secret', () => {
const none = libraryAvailability(entries, {});
assert.deepEqual(none.get('proxied-points'), {
available: true,
reason: null,
});
assert.equal(none.get('keyed-points').reason, 'key-missing');
assert.equal(none.get('opt-in-points').reason, 'operator-opt-in');
assert.equal(none.get('fixture-pack').reason, 'operator-opt-in');
const configured = libraryAvailability(entries, {
FIRMS_MAP_KEY: 'secret',
GEV_LIBRARY_ENABLE: 'opt-in-points',
GEV_LIBRARY_DISABLE: 'proxied-points',
CCTV_FIXTURE_ENABLED: '1',
});
assert.equal(configured.get('keyed-points').available, true);
assert.equal(configured.get('opt-in-points').available, true);
assert.equal(configured.get('proxied-points').reason, 'operator-disabled');
assert.equal(configured.get('fixture-pack').available, true);
assert.doesNotMatch(JSON.stringify([...configured]), /secret/);
});
test('the catalog route lists every entry with its availability', async () => {
const request = install();
const res = await request('/catalog');
assert.equal(res.status, 200);
assert.equal(res.body.entries.length, entries.length);
assert.deepEqual(
res.body.entries.find(({ id }) => id === 'opt-in-points'),
{ id: 'opt-in-points', available: false, reason: 'operator-opt-in' },
);
});
test('a proxied dataset reaches only its manifest URL, refuses redirects and is normalized', async () => {
const calls = [];
const request = install({
now: () => 42,
fetchImpl: async (url, options) => {
calls.push({ url, options });
return new Response(collection([point('Storm'), { geometry: null }]));
},
});
const res = await request('/data/proxied-points?url=https://evil.example');
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://data.example.org/points.json');
assert.equal(calls[0].options.redirect, 'error');
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(res.body.features.length, 1);
assert.equal(res.body.features[0].properties.name, 'Storm');
assert.equal(res.body.dropped, 1);
assert.equal(res.body.fetchedAt, 42);
// A fresh cache answers without another upstream request.
await request('/data/proxied-points');
assert.equal(calls.length, 1);
});
test('browser-direct, unknown, opt-in and wrong-kind entries are not proxied', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(collection([]));
},
});
assert.equal((await request('/data/direct-points')).status, 404);
assert.equal((await request('/data/nope-nope')).status, 404);
assert.equal((await request('/data/proxied-tiles')).status, 404);
assert.equal((await request('/tile/proxied-points/2/0/0')).status, 404);
const optIn = await request('/data/opt-in-points');
assert.equal(optIn.status, 403);
assert.equal(optIn.body.error, 'operator-opt-in');
assert.equal(calls, 0);
});
test('a keyed entry gets the operator key server-side and refuses forwarded requests', async () => {
const calls = [];
const request = install({
env: { FIRMS_MAP_KEY: 'k3y' },
fetchImpl: async (url) => {
calls.push(new URL(url));
return new Response(collection([point('Fire')]));
},
});
const forwarded = await request('/data/keyed-points', {
'x-forwarded-for': '203.0.113.9',
});
assert.equal(forwarded.status, 403);
assert.equal(calls.length, 0);
const res = await request('/data/keyed-points');
assert.equal(res.status, 200);
assert.equal(calls[0].searchParams.get('MAP_KEY'), 'k3y');
assert.doesNotMatch(res.raw, /k3y/);
});
test('an oversized or failing upstream answers 502 without its text, then serves stale', async () => {
let clock = 0;
let mode = 'ok';
const request = install({
now: () => clock,
fetchImpl: async () => {
if (mode === 'big') return new Response('x'.repeat(8192));
if (mode === 'fail')
return new Response('secret upstream text', { status: 500 });
return new Response(collection([point('A')]));
},
});
mode = 'big';
const big = await request('/data/proxied-points');
assert.equal(big.status, 502);
assert.doesNotMatch(big.raw, /x{20}/);
mode = 'ok';
assert.equal((await request('/data/proxied-points')).status, 200);
clock = 10 * 3600 * 1000;
mode = 'fail';
const stale = await request('/data/proxied-points');
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.doesNotMatch(stale.raw, /secret upstream text/);
});
test('cross-site browser requests are refused before any upstream request', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(collection([]));
},
});
const res = await request('/data/proxied-points', {
host: 'localhost:5173',
origin: 'https://evil.example',
'sec-fetch-site': 'cross-site',
});
assert.equal(res.status, 403);
assert.equal(calls, 0);
});
test('tiles: bounded z/x/y, image types only, cached', async () => {
const calls = [];
const request = install({
fetchImpl: async (url) => {
calls.push(url);
if (url.includes('/3/'))
return new Response('<html>', {
headers: { 'content-type': 'text/html' },
});
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'content-type': 'image/png' },
});
},
});
assert.equal((await request('/tile/proxied-tiles/1/0/0')).status, 400);
assert.equal((await request('/tile/proxied-tiles/2/4/0')).status, 400);
assert.equal((await request('/tile/proxied-tiles/2/a/0')).status, 400);
const ok = await request('/tile/proxied-tiles/2/1/3');
assert.equal(ok.status, 200);
assert.equal(ok.headers['Content-Type'], 'image/png');
assert.equal(calls[0], 'https://tiles.example.org/2/1/3.png');
await request('/tile/proxied-tiles/2/1/3');
assert.equal(calls.length, 1);
assert.equal((await request('/tile/proxied-tiles/3/1/1')).status, 502);
});
test('tile paths are parsed against the zoom range', () => {
const source = { minZoom: 0, maxZoom: 3 };
assert.deepEqual(parseLibraryTilePath(['3', '7', '7'], source), {
z: 3,
x: 7,
y: 7,
});
assert.equal(parseLibraryTilePath(['3', '8', '0'], source), null);
assert.equal(parseLibraryTilePath(['4', '0', '0'], source), null);
assert.equal(parseLibraryTilePath(['1', '0'], source), null);
});
test('only GET is served', async () => {
const plugin = libraryProxy({ entries, env: {} });
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end() {},
};
await plugin.handler({ url: '/catalog', method: 'POST', headers: {} }, res);
assert.equal(res.status, 405);
});
-2
View File
@@ -33,7 +33,6 @@ import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
import { libraryProxy } from './library.js';
/**
* Construct the local provider plugins in their established order.
@@ -82,7 +81,6 @@ function localProviderPlugins({
mapillaryProxy(),
firePerimetersProxy(),
gnssIntegrityProxy(),
libraryProxy(),
ghostMapsProxy(),
droneAirspaceProxy(),
inaturalistProxy(),
+30 -1
View File
@@ -64,6 +64,30 @@ function validMessages(messages) {
return !pending.length;
}
/**
* A tool schema without the `pattern`s Chat Completions refuses: it answers
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
* as `resolve_area`'s area ids do. Every argument is still checked against
* the action's own contract before the tool runs, so the model loses a hint,
* not a rule. Realtime voice refuses them too now (#1618) and uses this as well.
*/
export function chatSchema(schema) {
if (Array.isArray(schema)) return schema.map(chatSchema);
if (!object(schema)) return schema;
return Object.fromEntries(
Object.entries(schema)
.filter(
([key, value]) =>
!(
key === 'pattern' &&
typeof value === 'string' &&
/\\[pP]\{/.test(value)
),
)
.map(([key, value]) => [key, chatSchema(value)]),
);
}
export function createOpenAiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
@@ -126,7 +150,12 @@ export function createOpenAiChatHandler({
tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
type: 'function',
function: { name, description, parameters, strict: false },
function: {
name,
description,
parameters: chatSchema(parameters),
strict: false,
},
}),
),
},
+16 -1
View File
@@ -14,6 +14,7 @@ import {
} from './constants.js';
import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { chatSchema } from './chat.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import {
preferCodexOAuth,
@@ -128,6 +129,19 @@ function createRealtimeOAuthLoginHandler({
};
}
/**
* The tools one Realtime session is offered, as OpenAI accepts them: it now
* refuses a whole session whose tool schema uses `\p{…}` patterns (#1618), as
* Chat Completions did (#1616), so voice drops the same pattern hints chat does.
*/
function realtimeSessionTools(tools, { overpass = false } = {}) {
return offeredTools(tools, { overpass }).map((tool) =>
tool.parameters
? { ...tool, parameters: chatSchema(tool.parameters) }
: tool,
);
}
function createRealtimeTokenHandler({
annotationGuidance,
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
@@ -303,7 +317,7 @@ function createRealtimeTokenHandler({
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance, { overpass }),
tools: offeredTools(tools, { overpass }),
tools: realtimeSessionTools(tools, { overpass }),
tool_choice: 'auto',
},
};
@@ -370,4 +384,5 @@ export {
createRealtimeOAuthLoginHandler,
createRealtimeOAuthStatusHandler,
createRealtimeTokenHandler,
realtimeSessionTools,
};
+5
View File
@@ -227,6 +227,11 @@ export function installRouteMiddleware(
// only shape that asks the upstream for them. A response WITHOUT steps is
// byte for byte what this endpoint has always returned.
middlewares.use('/api/route', async (req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const fail = (msg) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: msg }));
+130 -74
View File
@@ -1,6 +1,9 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import {
celestrakSatcatUrl,
celestrakTleUrl,
} from '../../../src/data/spaceProviderRequests.js';
import { providerCooldownMs } from './cooldown.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { readResponseTextCapped } from '../common/http.js';
@@ -22,84 +25,131 @@ import { readResponseTextCapped } from '../common/http.js';
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
*
* The same plugin serves CelesTrak SATCAT records for the satellites layer's
* groups at `/api/celestrak-satcat/<group>` (object type, operational status
* and owner, which file each object under a class). The catalog changes
* slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not
* the endpoint, so a 403 or 429 holds both routes off; any other failure
* holds off only the route that failed, so a bad SATCAT answer never stops
* TLE refreshes.
*/
export function celestrakProxy() {
const TLE_TTL_MS = 6 * 3600_000;
const SATCAT_TTL_MS = 24 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const groups = new Set([
/** The satellites layer's core groups (src/layers/satellites/policy.js). */
const coreGroups = [
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]);
let cooldownUntil = 0;
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
return parsed;
} catch {
/* no disk cache yet */
}
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
'resource',
'weather',
'planet',
'sar',
'science',
];
const feeds = [
{
route: '/api/celestrak',
ttlMs: TLE_TTL_MS,
cacheHeader: 'x-tle-cache',
cachedAtHeader: 'X-TLE-Cached-At',
diskName: (group) => `celestrak-${group}.json`,
upstreamUrl: celestrakTleUrl,
groups: new Set([
...coreGroups,
'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]),
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
validate: (body) => /^1 /m.test(body),
invalid: 'no TLE lines in response',
},
{
route: '/api/celestrak-satcat',
ttlMs: SATCAT_TTL_MS,
cacheHeader: 'x-satcat-cache',
cachedAtHeader: 'X-SATCAT-Cached-At',
diskName: (group) => `celestrak-satcat-${group}.json`,
upstreamUrl: celestrakSatcatUrl,
groups: new Set(coreGroups),
// A real answer, even for an empty group, starts with the CSV header.
validate: (body) => /^OBJECT_NAME,/.test(body),
invalid: 'no SATCAT header in response',
},
];
// CelesTrak throttling (403/429) is per client, so it holds every feed
// off; any other failure holds off only the feed that failed, so a bad
// SATCAT answer never blocks the TLEs the layer cannot do without.
let hostCooldownUntil = 0;
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
async function fetchUpstream(group) {
const url = celestrakTleUrl(group);
const signal = AbortSignal.timeout(20000);
const res = await fetch(url.toString(), {
signal,
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
'User-Agent':
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) {
// Group refreshes run in parallel: a later failure without Retry-After
// must never shorten a longer deadline an earlier one already set.
cooldownUntil = Math.max(
cooldownUntil,
Date.now() + providerCooldownMs(res),
);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
signal,
);
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
return { at: Date.now(), body };
}
function createFeedHandler(feed) {
let feedCooldownUntil = 0;
const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil);
const mem = new Map(); // group -> { at: epochMs, body: string }
const inflight = new Map(); // group -> Promise<{at, body}|null>
const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group));
const installMiddleware = (server) => {
server.middlewares.use('/api/celestrak', async (req, res) => {
async function readDisk(group) {
try {
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
return parsed;
} catch {
/* no disk cache yet */
}
return null;
}
async function writeDisk(group, entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[celestrak-proxy] cache write failed');
}
}
async function fetchUpstream(group) {
const url = feed.upstreamUrl(group);
const signal = AbortSignal.timeout(20000);
const res = await fetch(url.toString(), {
signal,
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
'User-Agent':
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
},
});
if (!res.ok) {
// Group refreshes run in parallel: a later failure without Retry-After
// must never shorten a longer deadline an earlier one already set.
const until = Date.now() + providerCooldownMs(res);
if (res.status === 403 || res.status === 429)
hostCooldownUntil = Math.max(hostCooldownUntil, until);
else feedCooldownUntil = Math.max(feedCooldownUntil, until);
void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await readResponseTextCapped(
res,
CELESTRAK_MAX_RESPONSE_BYTES,
signal,
);
if (!feed.validate(body)) throw new Error(feed.invalid);
return { at: Date.now(), body };
}
return async (req, res) => {
const group = String(req.url || '')
.replace(/^\//, '')
.split('?')[0];
@@ -111,7 +161,7 @@ export function celestrakProxy() {
res.end();
return;
}
if (!groups.has(group)) {
if (!feed.groups.has(group)) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('invalid group');
return;
@@ -123,15 +173,15 @@ export function celestrakProxy() {
if (res.headersSent) return;
res.writeHead(status, {
'Content-Type': 'text/plain',
'x-tle-cache': cacheStatus,
[feed.cacheHeader]: cacheStatus,
'Cache-Control': 'no-store',
...(entry
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() }
: {}),
...(cooldownUntil > 0 && Date.now() < cooldownUntil
...(cooldownUntil() > 0 && Date.now() < cooldownUntil()
? {
'Retry-After': String(
Math.ceil((cooldownUntil - Date.now()) / 1000),
Math.ceil((cooldownUntil() - Date.now()) / 1000),
),
}
: {}),
@@ -145,11 +195,11 @@ export function celestrakProxy() {
entry = await readDisk(group);
if (entry) mem.set(group, entry);
}
if (entry && now - entry.at < TLE_TTL_MS) {
if (entry && now - entry.at < feed.ttlMs) {
send(200, entry.body, 'HIT', entry);
return;
}
if (Date.now() < cooldownUntil) {
if (Date.now() < cooldownUntil()) {
send(
entry ? 200 : 502,
entry?.body || 'celestrak upstream cooling down',
@@ -169,8 +219,8 @@ export function celestrakProxy() {
return fresh;
})
.catch((err) => {
if (Date.now() >= cooldownUntil)
cooldownUntil = Date.now() + 60000;
if (Date.now() >= feedCooldownUntil)
feedCooldownUntil = Date.now() + 60000;
console.warn(
'[celestrak-proxy] refresh failed — serving cache if any',
);
@@ -191,7 +241,13 @@ export function celestrakProxy() {
console.error('[celestrak-proxy] request failed');
send(500, 'celestrak proxy error', 'ERROR');
}
});
};
}
const handlers = feeds.map((feed) => [feed.route, createFeedHandler(feed)]);
const installMiddleware = (server) => {
for (const [route, handler] of handlers)
server.middlewares.use(route, handler);
};
return {
name: 'celestrak-proxy',
+144 -1
View File
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
: shape;
}
/** The kind of shape a row action is offered for: `area` or `line`. */
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
const lonLat = (point) =>
Array.isArray(point)
? [Number(point[0]), Number(point[1])]
: [Number(point?.lon), Number(point?.lat)];
const finitePairs = (points) =>
(Array.isArray(points) ? points : [])
.map(lonLat)
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
const wrapLon = (lon) =>
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
/** Whether consecutive positions step across the antimeridian once wrapped. */
const crossesAntimeridian = (points) =>
points.some(
(point, index) =>
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
);
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
const inRange = (points) => {
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
return crossesAntimeridian(wrapped) ? null : wrapped;
};
const closed = (ring) => {
if (ring.length < 3) return ring;
const [first, last] = [ring[0], ring[ring.length - 1]];
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
};
/**
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
* handle has several parts), a line a `LineString`. A pin, a shape too small
* to be one, or one that crosses the antimeridian has none, so it is offered
* no action rather than a shape that wraps the wrong way round the world.
*/
export function drawMarkGeometry(mark) {
if (mark?.type === 'route') {
const path = inRange(finitePairs(mark.path));
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
}
if (mark?.type !== 'area') return null;
const ringOf = (points) => {
const ring = inRange(closed(finitePairs(points)));
return ring?.length >= 4 ? ring : null;
};
if (Array.isArray(mark.polygons) && mark.polygons.length) {
const polygons = [];
for (const rings of mark.polygons) {
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
// A part that cannot be given whole is no part: never a hole dropped.
if (!parts.length || parts.some((ring) => !ring)) return null;
polygons.push(parts);
}
return polygons.length > 1
? { type: 'MultiPolygon', coordinates: polygons }
: { type: 'Polygon', coordinates: polygons[0] };
}
const ring = ringOf(mark.ring);
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
}
/**
* The actions an application adds to Draw's rows, checked once: each has an
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
* as the row shows it). Malformed or repeated ids are dropped.
*/
export function drawMarkActions(actions = []) {
const kept = new Map();
for (const action of Array.isArray(actions) ? actions : []) {
const wellFormed =
typeof action?.id === 'string' &&
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
typeof action.icon === 'string' &&
typeof action.label === 'function' &&
typeof action.run === 'function' &&
Array.isArray(action.shapes);
if (!wellFormed || kept.has(action.id)) continue;
kept.set(
action.id,
Object.freeze({
id: action.id,
icon: action.icon,
label: action.label,
shapes: Object.freeze(
action.shapes.filter((shape) =>
Object.values(ACTION_SHAPES).includes(shape),
),
),
accepts:
typeof action.accepts === 'function' ? action.accepts : () => true,
run: action.run,
}),
);
}
return Object.freeze([...kept.values()]);
}
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
export function drawMarkActionsFor(mark, actions = []) {
const shape = ACTION_SHAPES[mark?.type];
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
const geometry = drawMarkGeometry(mark);
if (!geometry) return { shape, geometry, actions: [] };
return {
shape,
geometry,
actions: actions.filter((action) => {
if (!action.shapes.includes(shape)) return false;
try {
return action.accepts(geometry, shape) === true;
} catch {
return false;
}
}),
};
}
/** An action's words for a row; an application's label that throws shows its id. */
function actionLabel(action, title) {
try {
const text = action.label(title);
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
} catch {
return action.id;
}
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
*/
export function presentDrawMarks(marks = []) {
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
const offered = drawMarkActionsFor(mark, actions).actions;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
busy: notes.get(mark.id)?.busy === true,
note: offered.length ? notes.get(mark.id)?.text || '' : '',
actions: offered.map((action) => ({
id: action.id,
icon: action.icon,
label: actionLabel(action, title),
})),
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
Loaded 100 of 604 files, more files were not shown because too many files have changed in this diff. Show more