Merge branch 'main' into fix/street-level-gate-pill-race

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-16 17:23:34 +01:00
commit e30f9a73f4
203 files changed
+20390 -1205

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Keep keys and local state out of the build context: compose bind-mounts the
# checkout at run time, so the image never needs them.
.git
node_modules
dist
.env
.env.*
!.env.example
**/ENVIRONMENT
pinokio/ENVIRONMENT
pinokio/.installed
.gev-cache
.gev-logs
qa-artifacts
qa-shots
screenshots
output
3d-models
.gstack
*.log
.DS_Store
# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise
# collect `._*.test.mjs` as test files.
._*
+18
View File
@@ -96,6 +96,13 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
# Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
# Optional: exact socket addresses (comma-separated, no wildcards or prefixes)
# that Provider Settings treats as this machine in addition to loopback. Leave
# unset. The Docker launcher sets it to the container's gateway at start,
# because that is where Docker delivers your own browser from; it is safe only
# while the port is published on 127.0.0.1. Read SECURITY.md before setting it
# by hand.
# GEV_KEY_SETUP_TRUSTED_PEERS=
# Optional: Mapillary imagery for the Street Level layer. Free client token from
# https://www.mapillary.com/dashboard/developers (register an app, copy its
@@ -212,6 +219,12 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
# 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_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first; min 8, max 1200
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
@@ -219,6 +232,11 @@ AISSTREAM_API_KEY=
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL
# CCTV_TORONTO_ENABLED=1 # set 0 to disable the City of Toronto RESCU cameras
# CCTV_TORONTO_MAX_SOURCES=336 # cap cameras loaded from Toronto RESCU (clamped to 8–500)
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+247
View File
@@ -1,5 +1,142 @@
# Changelog
- Add Bogotá (Tembici, 250 stations) and Santiago de Chile (Bike Itaú, 200
stations) to the GBFS bikeshare registry, the layer's first stations in
Colombia and Chile. Both are PBSC feeds like the Brazilian Bike Itaú cities,
so no proxy change is needed. Both feeds also list depot stations on other
continents (São Paulo, Madrid) as in service; stations outside a city's load
radius are now dropped instead of drawn as live docks.
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
MobilityData's systems catalogue; AMBici's is CC0. `gbfs.nextbike.net` joins
the proxy allowlist. These are the layer's first stations in Spain.
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
licence quote and attribution. A feed can now name its own modes, so Toronto's
trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids
are internal keys can translate them to the number a rider would say. TTC
vehicles take their mode from the fleet number first, so replacement buses on
streetcar routes read as buses and out-of-service streetcars stay streetcars.
- CCTV cameras with identified hardware show the model's datasheet specs. When a
source names a camera model found in the vendored CCTV Camera Database records
(CC0), the HUD meta line adds its resolution, night vision and PTZ capability.
A fixed single-lens model's horizontal FOV fills in when the source states no
FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal
range is what the lens can do, not its current zoom, so those cameras keep the
estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra,
#452).
- Ship a web app manifest and raster home-screen icons, so adding the app to a
phone home screen or installing it on desktop uses the God's Eye View mark
instead of a blank tile. The icons are generated from `public/logo.svg` by
`node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs
no image toolchain. The existing SVG favicon still serves the browser tab.
Manifest paths are relative to the manifest, so a build served under a path
prefix installs and launches inside that prefix.
- Cyclone advisories no longer go dark while NHC publishes an advisory ahead
of its nominal time: advisory and position times up to 90 minutes ahead of
the server clock are accepted (previously 5 minutes), so the whole feed is
not rejected for about half an hour each advisory cycle.
- Flights age out vanished aircraft again. OpenSky's routine position-less rows
no longer mark every snapshot partial, which had kept vanished and landed
aircraft at full opacity for up to five minutes; those rows retain only their
own aircraft, and a likely-landed one still leaves on the landed limit.
- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack:
WSDOT's official Travel Center camera layer, with frames registered only on
WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of
Seattle are skipped). Cameras with a single cardinal `CompassDirection` get
a high-confidence heading; `B` (both directions), `O`, and unset directions
use the shared id-hash fallback at low confidence. By default the 250
cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the
cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra,
#644).
- Refuse still-image cameras on the CCTV media relay. `/api/cctv/media` is the
live-feed route, but it also relayed any registered still camera through an
ordinary fetch that followed redirects to any host, passed through whatever
content type came back and had no cap on the body, bypassing every protection
`/api/cctv/frame` applies to the same URL. Only HLS, MP4, WebM and MJPEG
feeds are relayed now; a still camera, or an unknown id, gets a JSON 404
without an upstream request and is served only through `/api/cctv/frame`.
The client already loaded stills through `/frame`, so nothing it requests
changes.
- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. The pack is opt-in (`CCTV_LITHUANIA_ENABLED=1`) because its camera endpoints are the eismoinfo.lt website backend, not a service listed in Via Lietuva's open-data statement, so the licence for the photos is unconfirmed.
- Add King County road cameras (Washington State) as a keyless CCTV source
pack: one public King County DOT ArcGIS layer, fetched with an explicit field
list so its staff-name columns are never requested, and frames pinned to the
county's own image host and upgraded to HTTPS. WSDOT-owned rows are skipped,
and city-owned cameras the county hosts credit their owner. The layer
publishes no facing, so headings use the shared id-hash fallback at low
confidence. It does publish each camera's hardware, which is served on
`/api/cctv/sources` as `model` and `manufacturer`. `CCTV_KINGCOUNTY_MAX_SOURCES`
sets the cap and `CCTV_KINGCOUNTY_ENABLED=0` turns the pack off
(bassem chagra, #645).
- Add City of Toronto RESCU traffic cameras as a keyless CCTV source pack: the
~336 municipal cameras the Ontario 511 pack does not carry — downtown
arterials plus the Gardiner, DVP, Allen and Lake Shore. The list is the
City's own open data feed; frames are built from the documented
`loc####.jpg` convention and a digits-only camera number, so the frame proxy
is pinned to the portal origin by construction. The dataset publishes no
camera facing — its `D1`-`D4` fields describe static 2016 comparison images,
not the live pan-tilt-zoom frame — so headings use the shared id-hash
fallback at low confidence and are corrected with the calibration gizmo.
Carries the required Open Government Licence – Toronto attribution. Disable
with `CCTV_TORONTO_ENABLED=0`.
- Stop one retired bikeshare feed from reporting the whole layer as failed.
City feeds are fetched independently, but failure was recorded in a single
flag that only a successful *activation* cleared — and an already-loaded city
never activates again. A dead feed re-entering camera range therefore latched
LOAD FAILED permanently over a map still drawing every other city's stations.
Failure is now tracked per city: a city that fails beside others still
drawing marks the layer DEGRADED, polls that all fail over previously drawn
points read STALE without advancing freshness, and LOAD FAILED is reported
only when nothing is left to draw. A city that recovers clears its own
failure, a failure is dropped once its city leaves range or the camera rises
above the activation altitude, and late results from superseded requests are
ignored.
Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo)
to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC.
Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems
have wound down — CoGo's feed reports no stations and last moved a bike in
March 2025. The three repointed feeds were verified live.
- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry:
São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and
Curitiba (about 1,000 stations in total). These are the layer's first stations
in South America.
- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system
publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga
entries, so it needs no new plumbing; a registry test now holds every city's
feed URLs against the proxy host and path allowlists, so a city the proxy
would refuse fails the suite instead of rendering an empty layer.
- GBFS v3 station names use English translations when available, then the first
usable translation or short name. Empty or malformed names no longer appear as
`[object Object]` or interrupt station loading; legacy string names remain
supported in the globe and bike-share tools. Thanks to
[Kristóf Kékesi](https://github.com/KristofKekesi) (#61).
- Overpass and military-installation disk caches no longer serve answers from a
previously configured `OVERPASS_UPSTREAMS` instance after it is repointed.
- A Radio Browser mirror answering 204, 205 or 304, or a status outside
200–599, no longer crashes the local dev/preview server. Null-body statuses
get an empty body; anything else that cannot become a response fails that
mirror so the next one is tried.
- Snow flakes sway within a fixed range instead of drifting sideways faster the
longer the app runs; the Wind setting still scales the sway.
@@ -36,8 +173,17 @@
- Director timeline seeks cross the dateline along the short longitude arc.
- Keep unknown GBFS availability counts neutral instead of turning null or empty
values into zero bikes.
- Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records.
- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant.
- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo
(HIBike) to the GBFS bikeshare registry, the layer's first stations in
Michigan.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
@@ -133,6 +279,60 @@
## [Unreleased]
- Add a keyless Air quality layer to the Weather group, with Environment and
Climate Change Canada's AQHI as the first registered provider. Readings carry
`{ provider, scale, value, band }` so another network can be added without
inventing a shared numeric meaning across indices that do not share one; the
row and readout both state that coverage is Canada only. The WEATHER card
shows the band legend and the highest readings currently in view. AQHI is
reported as an integer from 1 with an open-ended "10+", so readings round to
that published form rather than the decimal precision the API returns.
Readings older than six hours are dropped, and the layer reports `stale`
through `getStats()` (also when every observation was dropped) so feed
provenance cannot describe an aged feed as live. Voice can switch it on and
query readings by value, band, station and observation time.
- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol
`/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with
streaming size caps that cancel the body at the cap or the route's deadline;
the adsb.lol military poll gains a deadline of its own. An oversized OpenSky
snapshot is not retried, and an OpenSky 429 starts its cooldown before the
body is read.
- Keep later satellites when a TLE catalog entry is missing an orbital line.
The parser skips the incomplete set and resumes at the next complete named
set, instead of losing the rest of the group (#906).
- Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous
cloud-filter slider: 0% is Full, 100% is the existing brightness key, and
intermediate values blend the keyed alpha. The slider commits on release to
avoid restaging imagery for every drag event. Filter percentages round-trip in
share links; legacy mode codes retain their old endpoint meanings. Background
panel refreshes preserve the pending drag value until release. Contributed by
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
line opens that sequence's picture nearest the click without moving the
camera, flat or 360°, with cones along the sequence around it and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
in the lines and in the lookup. The caption names each picture's author
and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the
Panoramax viewer. HD originals load once, only when the viewer is wider
than the 2,048 px version (a panorama: expanded), and every download stops
when the photo changes; an image whose header declares more than 64 MP is
refused before it decodes. Share links carry it as
`0.m.0_0.r.google-0*kartaview-0*panoramax-1` (`0.m.0_0.r.panoramax-1` works
too). KartaView's photo viewer
is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too,
Esc clears the selected drive before it collapses the panel. Lines are
simplified and drawn within a point budget (a tile's unsimplified lines
over Paris hung the tab), decoded in slices so the globe keeps moving,
and kept; the cones' tiles have their own connection, and the open
picture's tile draws its cones before its neighbours arrive.
- Flights and Military Flights highlight emergency transponder broadcasts in
fleet billboards, 3D models and Cockpit contacts. Colors update when a status
starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV
@@ -191,6 +391,21 @@
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
from #653.
- **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image,
pinned by digest, and serves the app on `http://localhost:4173` with no local
Node install. The repo is bind-mounted and the container runs as the host user
(never root), so `.env`,
`.gev-cache`, and `.gev-logs` persist on the host exactly as they do under
`npm run dev` — Provider Settings included. The launcher
(`scripts/docker-start.mjs`) trusts the container's gateway address for that
panel, since that is where Docker delivers the host's own browser from; the
opt-in behind it, `GEV_KEY_SETUP_TRUSTED_PEERS`, takes exact addresses only
and is unset (so loopback-only, unchanged) under every other launcher.
Thanks to [jklaze](https://github.com/jklaze) for finding and diagnosing the
container-to-host 403, and to [14traits](https://github.com/14traits),
[PoBruno](https://github.com/PoBruno) and [milu70hub](https://github.com/milu70hub)
for testing on Apple silicon, Docker Engine on Linux, and NAS hosts.
- Stdio servers from one install share a panel key so a page read from one
process can make requests through another. Concurrent malformed-key repairs
now select one winner; unavailable storage or a busy repair retains the
@@ -215,6 +430,17 @@
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel
Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless
GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join
the proxy allowlist; the last two are kept to their GBFS paths. The proxy
now sends `Client-Identifier: gods-eye-view` on every request and
`ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask,
and accepts Entur's extensionless feed paths. Attributed under NLOD and
Licence Ouverte. `get_bike_share` covers the six systems too.
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
@@ -226,6 +452,18 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
- Add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack: ~130
cameras from a WFS/GML feed that aggregates SCT's own highway cameras with
hotlinked Barcelona and Terrassa municipal cameras, each partner named as a
credit beside the shared provider. Ambient-card refresh paced to 3 minutes,
matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES`
sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. The catalog
is fetched over https with redirects refused and a 1 MiB body cap, and frame
links carrying userinfo or an explicit port, or outside the publisher's frame
directory, are rejected even on an allowlisted host. Camera ids come from
each frame's SCT camera file or image path rather than the feed's
restart-dependent feature id, and rows the feed repeats collapse to one.
- Import your own GeoJSON, KML, KMZ and GPX files from DISPLAY or by dropping
them onto the app. Show, hide, fly to or remove each file for the session.
GeoJSON/GPX draw on the ground; KML retains its styles and explicit heights.
@@ -252,6 +490,15 @@
before playback. The id now falls back to a v4 UUID built from
`crypto.getRandomValues`, which the server's lease check accepts.
- Move Google geocoding behind the local server. Forward and reverse lookups now
go through `/api/google/geocode` and `/api/google/reverse-geocode` with the
server key, joining Places and Street View. Google's Geocoding web service
refuses referrer-restricted keys, so the previous browser call left
`SECURITY.md`'s advice and working search mutually exclusive: the bundled
browser key had to be unrestricted for search to work at all. That key now
needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
fallback order are unchanged.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
+50 -2
View File
@@ -23,6 +23,7 @@ How to read this:
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -34,6 +35,7 @@ How to read this:
| **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" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
@@ -57,9 +59,20 @@ How to read this:
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
| **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 |
| **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) |
| **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" |
| **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) |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" |
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
@@ -67,6 +80,15 @@ How to read this:
| **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) |
| **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) |
| **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) |
| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto |
| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use |
| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence |
| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" |
| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence |
| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence |
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **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 |
| **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)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
@@ -77,6 +99,16 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
### Notes on the live sources
- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current.
Readings are stored as `{ provider, scale, value, band }` rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added later without inventing a shared numeric meaning that does not exist.
AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim.
**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.
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
@@ -128,10 +160,14 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
@@ -161,6 +197,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
### ⚠️ TeleGeography is bundled but NonCommercial
@@ -186,6 +223,17 @@ commercial use with attribution and applicable database share-alike/access
obligations; it does not license the application code. Trimmed test-only tile
fixtures retain their provenance in `src/data/fixtures/README.md`.
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
fetched at runtime. The records describe camera products (optics, resolution, night vision)
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
### Bundled OpenStreetMap data
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
#
# The dev server is the whole app: Vite serves the client AND runs the API
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
# is nothing else to containerise. See compose.yaml for the usual entry point.
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
# `npm test` builds fixtures with `git init`, so the suite cannot run in a
# container without it (71 failures on node:24-slim, all ENOENT on git).
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# The repo is bind-mounted, so inside the container it can show an owner other
# than the user the container runs as - always, on Docker Desktop. git then
# refuses it as "dubious ownership", which fails the tests that shell out to
# git. It is the user's own checkout, so trust that one path.
RUN git config --system --add safe.directory /app
# Never run as root. The image's own user is `node` (1000); compose overrides
# it with the host user so bind-mounted files stay yours.
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
# node_modules belongs to `node`, but compose may run as another UID. Vite only
# needs to write its dep-optimizer cache, so open up those two directories
# rather than chowning a few hundred MB to a UID we cannot know at build time.
RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite
COPY --chown=node:node . .
# Bind to all interfaces, otherwise the published port hits nothing. This does
# not widen the Host allowlist: IP addresses and localhost are accepted, other
# names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js).
ENV HOST=0.0.0.0
ENV PORT=4173
EXPOSE 4173
# The launcher is `npm run dev` plus one step: it trusts the container's
# gateway address for Provider Settings, because that is where Docker delivers
# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs.
CMD ["node", "scripts/docker-start.mjs"]
+74 -7
View File
@@ -153,6 +153,72 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
configured keys straight from the Keychain. It starts keyless too.
### Path 3 — Docker
No Node install needed; the container publishes the same dev server.
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
docker compose up
```
Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything:
- `.env` - your API keys, whether saved from Provider Settings or typed in by hand
- `.gev-cache/` - downloaded map and satellite data, so later starts are faster
- `.gev-logs/` - logs
Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one.
The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice.
Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail.
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
<details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
Run it as the user who should own the files it writes. `id` shows the numbers; for yourself on Linux:
```bash
env UID=$(id -u) GID=$(id -g) docker compose up
```
On a NAS, clone onto the share you want the data on and use the share owner's ids:
```bash
cd /volume1/docker
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
env UID=1026 GID=100 docker compose up -d
```
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
</details>
<details>
<summary>IPv6-only networks, rebuilding, and the test suites</summary>
The launcher reads the IPv4 default gateway, so an IPv6-only Docker network
finds none: it logs `No default gateway; Provider Settings stays loopback-only`
and the panel refuses every save. Put your keys in `.env` by hand there, or name
the gateway yourself with `GEV_KEY_SETUP_TRUSTED_PEERS` after reading
[SECURITY.md](SECURITY.md).
`node_modules` deliberately stays inside the image, so it survives in an
anonymous volume across restarts: after a `git pull` that changes dependencies,
recreate it with `docker compose down -v && docker compose up --build`. The
image also skips Puppeteer's Chromium download, which keeps it small but leaves
`npm run test:track` to the host — `npm run build` and `npm test` run fine in
the container.
</details>
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
@@ -309,12 +375,13 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🚢 **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 | 🟢 |
| 🌍 **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** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟢 KartaView · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. 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 | 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, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
@@ -350,13 +417,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
## 📷 Street Level
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key) and **KartaView** (no key).
> 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** needs nothing: its public API is 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 the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). 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. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works 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`.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
+14 -3
View File
@@ -21,19 +21,19 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Three deliberately client-side keys — restrict them
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
**Places, Geocoding and the Street View Static fallback never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles + Maps JavaScript API, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks.
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
@@ -87,6 +87,17 @@ The dev server is a **key broker**: every server-side key above is spendable by
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **Docker trusts one address, and only because the port is on loopback.**
Docker delivers the host's own browser to the container from the container
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
Settings refuses the machine running it. `scripts/docker-start.mjs` therefore
sets `GEV_KEY_SETUP_TRUSTED_PEERS` to exactly that gateway address. The list
takes exact addresses only (no wildcards, prefixes or hostnames), is unset
under every other launcher, and relaxes none of the sharing, Host, Origin,
Content-Type or proxy-header checks. It is safe precisely because
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
client arrives from the same gateway address, which hands them Provider
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
+21
View File
@@ -0,0 +1,21 @@
services:
gods-eye-view:
build: .
# Everything the app writes (.env with your keys, .gev-cache, .gev-logs)
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
user: "${UID:-1000}:${GID:-1000}"
ports:
# Publish to loopback only. Docker's default ("4173:4173") would bind
# every host interface, which quietly turns the LAN warning in the README
# into the default: this server brokers whatever provider keys you have
# configured to anyone who can reach it. Widen this deliberately, not by
# accident.
- "127.0.0.1:4173:4173"
volumes:
# Bind the source so edits hot-reload and so keys pasted into the setup
# UI land in the .env on your host instead of vanishing with the
# container. node_modules stays in the image: sharp and friends are
# compiled for Linux and must not be shadowed by a host install.
- .:/app
- /app/node_modules
+259 -18
View File
@@ -256,8 +256,15 @@ Clouds only applies a soft brightness ramp to decoded pixels once, using
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
preserved in Full image mode; both modes use the chosen layer opacity. This
is a display filter, not a cloud mask. Satellite share links retain the display
mode; observation history remains transient and links open latest.
is a display filter, not a cloud mask. The cloud-filter slider is a continuous
0–100% strength: 0% is Full, 100% is the existing Clouds only key, and
intermediate values blend their alpha. The value commits on slider release to
avoid restaging for every pointer event. Status refreshes retain the uncommitted
drag preview; changed model values or disabled controls supersede it.
Satellite share links carry the filter
strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and
Vivid remain layer-opacity presets (40%/70%); history remains transient and links
open latest.
Global infrared fetches one capped 4 MiB, 2048×1024 mosaic per frame and decodes
and processes it before staging. Globe hosts crop that canvas into 256 px tiles
to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent,
@@ -302,7 +309,8 @@ release commits immediately. Latest selects the newest frame per product, and
history labels the actual displayed frame as synced or nearest. Missing-frame
messages use each product's eligibility gap (30 minutes, or three hours for the
global mosaic). Wind shows forecast valid and issue times and does not follow
history. Satellite clouds uses Clouds only / Full image configuration labels.
history. Satellite clouds uses Clouds only / Full controls, a continuous cloud
filter slider and Soft/Vivid opacity presets in its right-rail settings.
Left weather rows retain only the toggle and one status line with a source
tooltip. Configuration and the cyclone Storms list live in the cards, as do the
@@ -331,7 +339,12 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound
same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service.
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
Current positions, advisory issue time and position time stay distinct. Tracks,
Current positions, advisory issue time and position time stay distinct. NHC may
release an advisory before its nominal time (routinely up to about 30 minutes,
rarely about an hour), so advisory issue and position times up to and including
90 minutes ahead of the server clock are accepted; 90 minutes is this app's
tolerance, not an NHC limit. Later, non-canonical, or more than 12-hour-old
times still reject the status snapshot. Tracks,
forecast lead-hour points and cones render only when all source parts match the
status advisory; a newer status shows its position with geometry pending rather
than relabeling older geometry. The cone represents forecast center uncertainty,
@@ -864,7 +877,13 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
longitude and latitude is a contact without a position, not a malformed row: it
leaves the snapshot complete and is listed in `retainIds`, so civil flights
retain that aircraft under the same five-minute bound, unless it is likely
landed, while other absent aircraft fade and leave on the missed-poll limits. A
nonempty snapshot with no positioned rows is still malformed. Military readsb
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -969,7 +988,41 @@ id-hash fallback at low confidence.
Once enabled, the 250 cameras nearest the five borough cores load.
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
round-robin, so adding this pack does not silently drop another region.
round-robin: no region is dropped outright, but when the packs together exceed
the ceiling, adding a pack trims cameras from the largest packs.
## City of Toronto RESCU CCTV source pack
The CCTV catalog adds the City of Toronto RESCU traffic cameras — downtown
arterials plus the Gardiner, DVP, Allen and Lake Shore, which the Ontario 511
pack does not cover. The server reads the keyless camera list the City
publishes on its open data portal (`tmcearthcameras.json`, served as JSONP).
The list has no URL field: frame URLs are built from each validated, digits-only
camera number on the fixed origin
`https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/`.
The feed has no compass data, so headings are low-confidence priors. Some
cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
By default all ~336 cameras load, nearest downtown first.
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
catalog-wide ceiling round-robin with the other packs.
## Lithuania CCTV source pack
The CCTV catalog can include Via Lietuva / Eismoinfo road cameras. The pack is
off unless `CCTV_LITHUANIA_ENABLED=1`, because its camera endpoints are the
eismoinfo.lt website backend rather than a service listed in Via Lietuva's
open-data statement, so the licence for the photos is unconfirmed.
Two keyless public endpoints provide current snapshot URLs and WGS84 camera
locations; records are joined by ID, with invalid coordinates and off-origin
image URLs rejected. Catalog requests refuse redirects, have a 15-second
deadline and a 4 MiB response cap, and use the existing shared 15-minute cache
and fair catalog allocation. An unavailable source contributes an empty pack.
Frames use the registered-image proxy and retain Via Lietuva attribution.
The images are periodically updated road-condition photographs, not continuous
video; camera poses use the existing low-confidence heading prior.
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
## DriveBC CCTV source pack
@@ -1094,6 +1147,9 @@ collapse can still be undone.
## Surface keyboard lifecycle
Surface Tab cycling excludes controls with a negative tabindex, native disabled
state or inert ancestry; ordinary visible enabled controls retain their order.
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
focus shared by the first-run launcher and Provider Settings. Each caller
activates it while open and deactivates it on dismissal; destruction releases
@@ -1151,6 +1207,9 @@ still waiting to drain reschedules that deadline, so a slow client does not read
as a stalled upstream. Error responses are cancelled. Buffered snapshots have a
16 MiB streaming cap; an oversized image remains an upstream miss and uses the
normal fallback chain. The existing declared media size ceiling remains 64 MiB.
`/api/cctv/media` relays live feeds (HLS, MP4, WebM, MJPEG) only; a still-image
camera is refused there with a JSON 404 and is served solely through
`/api/cctv/frame` and its same-origin, `image/*`-only, size-capped fetch.
## GBFS upstream bounds
@@ -1171,6 +1230,46 @@ The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
streaming. The 12-second deadline includes reading the body, and rejected or
stalled downloads are cancelled. Development and preview use the same handler.
Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban
Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds
(`gbfs.urbansharing.com`); other operators ignore it. Requests to Entur
(`api.entur.io`, Kolumbus and Farte Bysykkel) also carry
`ET-Client-Name: gods-eye-view-bikeshare`. Entur serves its feeds without a
`.json` suffix, so the endpoint check accepts `station_information` and
`station_status` with or without it. Hosts that front more than GBFS are kept to
their GBFS tree: `api.entur.io` to `/mobility/v2/gbfs/` and `api.cyclocity.fr`
(Bysykkel Lillestrøm) to `/contracts/`.
## Upstream body bounds
FIRMS CSV, CelesTrak TLE groups, adsb.lol military responses, TomTom flow
tiles, OpenSky states/all and adsbdb lookups now read their bodies through the
shared streaming readers in `src/sources/httpBody.js`, so a declared or observed body
over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than
buffered. TomTom flow tiles use `readResponseBytesCapped` alongside
GTFS-Realtime. Each read takes the route's abort signal, so a body still
streaming when the deadline fires is cancelled. Caps are bounds, not budgets, sized well
above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB
per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles
8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS
60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s, and each 10 s OpenSky
`states/all` attempt) it already bounded the body read, because aborting a
fetch signal also aborts its body; the capped reader takes the same signal.
The adsb.lol military poll, which had no deadline, now waits at most 12
seconds for headers and body together. An oversized OpenSky snapshot is not
retried, and an OpenSky 429 starts its cooldown from the response headers and
cancels the body unread. A rejected or timed-out body is an upstream miss and
follows each route's existing error and cached-data paths. FIRMS map-key
status, the OpenSky token exchange and CCTV media keep their current handling.
The route also bounds request frequency: 120 requests/minute per client
address, with a global backstop of three times that. A refused request answers
429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound
fetch. The bikeshare layer's own demand is about 15 requests a minute with
every catalogued system in range, so an ordinary session never meets the limit;
the method, host and path guards run unchanged.
Updated: September 21, 2026
## Remaining local service modules
@@ -1195,6 +1294,28 @@ services, local voice and standalone key setup. Importing them starts no network
acquisition. Browser layer lifecycle, rendering and voice execution stay in their
existing modules.
## Web app install icons
`public/manifest.webmanifest` declares the installable app: standalone display,
`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant.
`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG
favicon; the existing SVG favicon still serves the browser tab. Safari ignores an
SVG apple-touch-icon and substitutes a page screenshot, so the raster is what
makes an installed icon appear at all.
Manifest `start_url`, `scope` and icon paths are relative to the manifest, and
Vite rewrites the `index.html` links under `base`, so a build served from a path
prefix installs and launches inside that prefix.
`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and
writes `public/icons/`. Output is committed, so building needs no image
toolchain; re-run the script after changing the logo. Ordinary icons inset the
mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the
circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links,
the manifest fields and that each committed PNG is square at its declared size.
It also builds the install links under `base: '/example/'` and asserts every
emitted link and manifest URL stays inside that base.
## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data
@@ -1208,13 +1329,21 @@ a browser key. Preview is for local build verification, not a production server.
## CCTV and radio provider modules
CCTV direction text resolves travel tokens first, then intercardinals, then
bare cardinals when enabled. Only conflicting headings within the highest
matching tier remain unknown; street quadrants and lower-tier words do not
cancel an explicit travel heading.
CCTV catalog acquisition, source normalization and frame/media delivery now live
in separate modules. Each CCTV factory owns its catalog and health state; its
`sourceRoot` option resolves relative source files against the application root.
Radio Browser station normalization, restricted outbound transport and directory
caching are separate modules. Node-only package entries expose both provider
factories. Routes, payloads, fallback behavior and existing dev/preview hook
registration are preserved; browser rendering is unchanged.
caching are separate modules. The pinned transport gives a mirror's 204, 205 or
304 reply an empty body and fails any reply outside 200–599 without throwing
outside its promise, so an unusual mirror status cannot crash the server.
Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged.
## Terrain, traffic, fire and bike-share provider modules
@@ -1225,6 +1354,9 @@ and stale/error responses remain unchanged. Each has a Node-only package entry
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
math and GBFS source rules are available under `gods-eye-view/sources/`.
The browser layers and their rendering remain in their existing modules.
GBFS null or empty availability counts retain an unknown value through station
parsing and rendering; they use the neutral station color rather than reporting
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
## Terrain height cache bound
@@ -3138,13 +3270,14 @@ its criteria cannot be silently ignored.
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
@@ -3193,7 +3326,7 @@ provider draws in one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View and KartaView are registered. Without
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
@@ -3389,14 +3522,88 @@ photo that was opening is drawn on the new canvas instead. FIT / FILL hide
for panoramas (`supportsRenderModes`). The
caption has no place name (KartaView's is where the drive began) and links to
the full-size photo through the image proxy, since kartaview.org's photo
pages fail for signed-out visitors.
pages fail for signed-out visitors. The viewer itself is `photoViewer.js`,
shared with Panoramax: each provider hands it a `PhotoSources` table (first
and sharper flat and panorama URLs, and the caption fields).
Panoramax (`providers/panoramax/`) is keyless and browser-side: the
meta-catalogue `api.panoramax.xyz` (CORS open) federates every instance, and
each picture is served by the instance holding it (OpenStreetMap France,
IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip
(orange, `#ff6f00`, Panoramax's own) starts off. Coverage comes from its
vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence
lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11
to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
~2.1 MB and 8,600 sequences, 750,000 points). `decode.js` simplifies each
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
view draw their longest lines within 90,000 points (`maxPoints`,
`keepOrder`), shared out once loading settles so a sparse tile keeps all
it has and a dense one (a city centre) takes the rest; the selected
sequence is always drawn. Every point becomes ~8 ground-line vertices:
unsimplified, the lines hung the tab. Decoded tiles are kept while the
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
and above
60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds
~70,000 cells) and not counted as sequences: the panel says to zoom in.
A tile decodes in slices of about 8 ms, the page getting a turn between
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
tiles back for its Retry-After, which the browser cannot read here (the API
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
answers; an answer may take as long as it keeps arriving (silent for 15 s
is given up), so a slow link still gets its megabyte tiles. Both
filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a
line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
50 m. Each lookup is two searches side by side, ranked by distance in the
browser: `place_position` (nearest first, but even at
`place_fov_tolerance=180` it leaves out pictures looking away from the
point) and a bounding box (those too, roughly nearest first, so 20 are
asked for: ~60 kB, against ~600 kB for 100); the filters go to both (`filter=field_of_view=360`, `datetime=<since>/..`). A line is dated
by its sequence's first day, so a sequence still growing can hide under
SINCE; a line with no camera type or day stays drawn. Lines keep the
decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen
its sequence is highlighted and drawn as cones from the z15 tiles around it
(their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC
item), plus the neighbours within 150 m of an edge, on their own connection
(coverage tiles take three, searches two); each tile's list for the sequence
is kept. The open picture's own tile draws at once and the neighbours join
as they arrive; one that fails is not counted, so a picture opening in it
re-centres the cones there. The cones re-centre when a picture opens outside the tiles
they were drawn from, judged from the moment their load starts, and centre
only on a picture actually shown (never on a lookup that came back after
the user moved on); a lookup nobody waits for any more is stopped. ‹ › follow the picture's own `prev`/`next`
links, so stepping costs one small search per picture. The viewer shows the
2,048 px SD derivative (equirectangular for a 360° picture, decoded at
2,048 × 1,024) and swaps in the HD original only when the viewer is wider
than SD (flat) or, for a panorama, expanded; once, shrunk while it decodes.
When SD fails a flat picture falls back to the thumbnail, and to the
original only in the expanded viewer. Every download of a photo, first or
sharper, stops when another opens or the viewer closes. Originals wider than
8,192 px are never fetched; a file's header (JPEG, PNG, WebP, GIF) is read
before it decodes, and one declaring more than 64 MP or 16,384 px a side
is refused undecoded, as is an `<img>` that decodes larger and any file
over 48 MB. The caption names every
producer (an uploading app and its photographer; never an account named by
an email address), or else the instance holding the picture ("via
panoramax.ign.fr"), and the picture's licence (CC BY-SA 4.0, Licence
Ouverte 2.0…; StreetPose `license`), and links to the picture in the
Panoramax viewer. Esc anywhere in the docked viewer clears the selected
sequence before the panel can take it (KartaView too). After a GPU reset
the viewer draws the same picture again itself (`onReopen`), leaving the
core's lookups alone. The MCP globe panel allows the meta-catalogue and the
three biggest instances; a picture on another instance shows only in the
app.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), so it needs no network and covers the whole
photo flow. CI runs the fixture gate and `qa:panel-resize`
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
@@ -3486,8 +3693,11 @@ Transit is off by default, and share links use token `j`. The movement panel ord
places Transit directly after Street Traffic and before Bike Share. Cameras and
utilities retain their own groups. The layer polls
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes
identify buses, trams, subways, trains, ferries and unclassified vehicles.
Retained transit history uses the same outlier quarantine as live observations and
@@ -4020,6 +4230,14 @@ silently demoting every later lookup for the session.
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639).
- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that
resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera
Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens
hardware fills the FOV, and only when the source or seed states none and the pose is not
curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. PTZ, varifocal and
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
@@ -4121,6 +4339,10 @@ for, resolved lazily after env loading. It replaces the empty default list;
there is no built-in fallback. Local/private instances are allowed. The stable
`gods-eye-view/…` User-Agent is unchanged. URLs never appear in response headers
or upstream errors, including old cached endpoint metadata.
Disk cache entries for both routes carry a SHA-256 tag of the configured
instance set (order does not matter) and are served only while it matches, so
repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
answers; untagged entries stop serving once an instance is configured.
The shared transport guards `/api/overpass` and `/api/military-installations`.
With no configured upstream, each route serves existing cache data (including
@@ -4437,7 +4659,24 @@ Providers normalize coordinates, canonical name, label, place types and optional
bounds. Camera framing, nearby landmark recovery and footprint selection remain
in their consumers, including the Capitol identity/containment safeguards.
Radio keeps localized country names in labels rather than station filters.
Reverse geocoding and nearby/text-search endpoints retain their existing behavior.
Nearby/text-search endpoints retain their existing behavior.
**Google geocoding is a server route, not a browser call** (#363). Forward and
reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`,
which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`)
and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle.
Google's Geocoding **web service** refuses referrer-restricted keys, so the
previous browser call forced the bundled browser key — visible in devtools by
design — to be left unrestricted; it now needs only the Map Tiles API and the
Maps JavaScript API (Street View). Both routes answer in Google's own
`status`/`results` shape rather than the Places routes' `places: []` contract,
because the browser adapters normalize that shape directly. The browser key still gates the provider client-side, so a keyless
session spends no request to learn it has no Google: the route's own keyless
answer reads as an unanswered provider, which would stop the chain caching the
negative Photon or Nominatim then produces. Its query is capped at 200
characters, its reverse coordinates are validated against WGS84 bounds before
the request is paid for, and a malformed viewport bias is dropped rather than
refused, since a bias only ranks results.
Only valid answers and definitive misses enter bounded caches; malformed replies,
HTTP refusals and outages remain retryable. Searches share a 12-second total
@@ -4534,6 +4773,8 @@ are omitted rather than framing the wrong part of the globe.
### Recent Imagery (September 2026)
CMR pagination counts raw rows when checking page completion and the record budget. A catalog is truncated when upstream hits exceed the rows examined or the record cap drops valid granules from the last page; discarded malformed rows do not imply unseen imagery.
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
reject missing, blank or boolean edges instead of interpreting them as zero
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
+13
View File
@@ -4,7 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>God's Eye View</title>
<!-- Raster fallbacks: Safari ignores an SVG apple-touch-icon and falls back to a
screenshot of the page, so an installed app needs a real PNG. Regenerate
these from the logo with `node scripts/generate-icons.mjs`. -->
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
<!-- Listed after the PNG: browsers that use the last usable icon keep the SVG
for the tab. -->
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0a0a0f" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="God's Eye" />
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+2
View File
@@ -177,6 +177,7 @@
"./layers/directions": "./src/layers/directions/index.js",
"./layers/directions/source": "./src/layers/directions/source.js",
"./layers/cctv": "./src/layers/cctv/index.js",
"./layers/air-quality": "./src/layers/airQuality/index.js",
"./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js",
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
@@ -237,6 +238,7 @@
"./layers/alpr/source": "./src/layers/alpr/source.js",
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
"./layers/cctv/source": "./src/layers/cctv/source.js",
"./layers/air-quality/source": "./src/layers/airQuality/source.js",
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
"./layers/gnss/source": "./src/layers/gnss/source.js",
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"id": ".",
"name": "God's Eye View",
"short_name": "God's Eye",
"description": "Live spatial intelligence in the browser.",
"start_url": ".",
"scope": ".",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0f",
"theme_color": "#0a0a0f",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{
"src": "icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Docker launcher — the container's entry point (see Dockerfile).
*
* `compose.yaml` publishes the port on the host's loopback, but Docker still
* delivers the host's own browser to the container FROM the container
* network's gateway address, never from 127.0.0.1. Provider Settings admits
* loopback only, so without this the POWER UP panel refuses the very machine
* running the container. This launcher reads the gateway out of the kernel
* routing table and declares exactly that one address trusted, then starts
* Vite the way scripts/pinokio-start.mjs does.
*/
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDirectInvocation } from './pinokio-install.mjs';
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
const ROUTE_TABLE = '/proc/net/route';
/**
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
* addresses there are little-endian hex and only the default route (a
* destination of 00000000 on an interface flagged up) names a gateway.
* Returns null when there is none — the `--network none` case, which must
* never fabricate an address.
* @param {string} text Raw routing-table contents.
* @returns {string|null} Dotted-quad gateway, or null.
*/
export function defaultGatewayFromRouteTable(text) {
const lines = String(text || '').split(/\r?\n/).slice(1);
for (const line of lines) {
const fields = line.trim().split(/\s+/);
if (fields.length < 4) continue;
const [, destination, gateway, flags] = fields;
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
const octets = [6, 4, 2, 0].map((offset) =>
Number.parseInt(gateway.slice(offset, offset + 2), 16),
);
if (octets.every((octet) => octet === 0)) continue;
return octets.join('.');
}
return null;
}
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
* exactly as under `npm run dev`.
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
*/
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? '';
}
/**
* A checkout the container cannot write fails quietly: Provider Settings
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
* usual cause is a clone made as root on a NAS or Proxmox host while the
* container runs as 1000:1000. Say so at start, naming both sets of ids.
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
* @returns {string|null} A warning, or null when the checkout is writable.
*/
export function unwritableRootWarning({ root, writable, owner, runAs }) {
if (writable) return null;
return (
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
);
}
function readRouteTable() {
try {
return readFileSync(ROUTE_TABLE, 'utf8');
} catch {
return '';
}
}
async function start() {
let writable = true;
try {
accessSync(ROOT, constants.W_OK);
} catch {
writable = false;
}
if (!writable) {
const { uid, gid } = statSync(ROOT);
console.warn(
unwritableRootWarning({
root: ROOT,
writable,
owner: { uid, gid },
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
}),
);
}
const fromOperator = Boolean(
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
}
const port = Number.parseInt(process.env.PORT, 10) || 4173;
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
const server = await createServer({
root: ROOT,
server: {
// The container's own namespace. compose publishes this to the host's
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
host: '0.0.0.0',
port,
strictPort: true,
},
});
await server.listen();
server.printUrls();
console.log(`[Docker] Ready at http://localhost:${port}/`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
await server.close();
process.exit(0);
});
}
}
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
start().catch((error) => {
console.error(`[Docker] Start refused: ${error.message}`);
process.exitCode = 1;
});
}
+283
View File
@@ -0,0 +1,283 @@
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs';
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
/**
* Panoramax's meta-catalogue and one instance's pictures answered from
* fixtures, so the gate's Panoramax steps need no network. Two sequences
* drive east along the same Paris street (PNX_LINE), their pictures
* interleaved, so a click on one line must open that line's picture, not
* the other's that is as near. Every seventh picture of the first is 360°.
* The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the
* first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence
* Ouverte by an account named by an email address (never shown).
*/
export const PNX_SEQUENCES = Object.freeze([
'a1a1a1a1-0000-4000-8000-000000000001',
'b2b2b2b2-0000-4000-8000-000000000002',
]);
export const PNX_LINE = Object.freeze({
lat: 48.8874,
west: 2.3445,
east: 2.3545,
});
export const PNX_PARK = Object.freeze({
lon: 2.3495,
lat: 48.8874,
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
const HOST = 'https://panoramax.openstreetmap.fr';
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
/** Calls answered since the last reset, by kind. */
let asked = { search: 0, hd: 0 };
export function resetPanoramaxFixture() {
asked = { search: 0, hd: 0 };
}
/** How many HD originals were downloaded since the last reset. */
export function panoramaxHdDownloads() {
return asked.hd;
}
/**
* Both sequences' pictures, west to east. The second's sit 5 m along from
* the first's, on the same street.
*/
export function panoramaxPictures() {
const step = PNX_SPACING_M / METRES_PER_DEG_LON;
const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step);
return PNX_SEQUENCES.flatMap((sequenceId, s) =>
Array.from({ length: count }, (_, i) => ({
id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`,
sequenceId,
index: i,
count,
lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)),
lat: PNX_LINE.lat,
isPano: s === 0 && i % 7 === 3,
datetime:
s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00',
})),
);
}
function item(picture, pictures) {
const sibling = (delta) =>
pictures.find(
(p) =>
p.sequenceId === picture.sequenceId &&
p.index === picture.index + delta,
);
const link = (rel, other) =>
other
? [
{
rel,
href: `${API}/collections/${other.sequenceId}/items/${other.id}`,
},
]
: [];
const first = picture.sequenceId === PNX_SEQUENCES[0];
const files = `${HOST}/derivates/${picture.id}`;
return {
type: 'Feature',
id: picture.id,
collection: picture.sequenceId,
geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] },
providers: [
{
name: first ? PNX_CREATOR : '[email protected]',
roles: ['producer'],
},
],
properties: {
datetime: picture.datetime,
license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0',
'view:azimuth': 90,
'pers:interior_orientation': picture.isPano
? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] }
: { field_of_view: 70, sensor_array_dimensions: [4032, 3024] },
},
assets: {
hd: { href: `${files}/hd.jpg` },
sd: { href: `${files}/sd.jpg` },
thumb: { href: `${files}/thumb.jpg` },
},
links: [
...link('prev', sibling(-1)),
...link('next', sibling(1)),
{ rel: 'via', href: HOST },
],
};
}
/** The tile `z/x/y` in the meta-catalogue's shape. */
export function panoramaxTile(z, x, y) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0);
const from = Math.max(PNX_LINE.west, west);
const to = Math.min(PNX_LINE.east, east);
if (from >= to) return new Uint8Array(0);
if (z <= 5)
return encodePanoramaxTile(tile, {
grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }],
});
if (z < 7) return new Uint8Array(0);
const sequences = PNX_SEQUENCES.map((id, s) => ({
id,
type: s === 0 ? 'equirectangular' : 'flat',
date: s === 0 ? '2025-06-01' : '2023-03-01',
parts: [
[
[from, PNX_LINE.lat],
[to, PNX_LINE.lat],
],
],
}));
if (z < 15) return encodePanoramaxTile(tile, { sequences });
const pictures = panoramaxPictures()
.filter((p) => p.lon >= west && p.lon < east)
.map((p) => ({
id: p.id,
lon: p.lon,
lat: p.lat,
sequenceId: p.sequenceId,
heading: 90,
ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'),
type: p.isPano ? 'equirectangular' : 'flat',
}));
return encodePanoramaxTile(tile, { sequences, pictures });
}
/** A call named by host, path shape and parameter names. */
export function describePanoramaxCall(method, url) {
const keys = [...url.searchParams.keys()].sort();
const path = url.pathname
.replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt')
.replace(
/^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/,
'/derivates/{id}/$1.jpg',
);
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
}
const json = (body) => ({
status: 200,
contentType: 'application/geo+json',
headers: CORS,
body: JSON.stringify(body),
known: true,
});
/**
* Answer one Panoramax request; `known` is false for a call no fixture
* covers, so the gate fails on a new one instead of missing it.
*/
export function answerPanoramaxRequest({ method, url }) {
const address = new URL(url);
if (method === 'OPTIONS')
return { status: 204, headers: CORS, body: '', known: true };
const pictures = panoramaxPictures();
if (address.hostname === new URL(HOST).hostname) {
const file = address.pathname.match(
/^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/,
);
if (!file || !pictures.some((p) => p.id === file[1]))
return { status: 404, headers: CORS, body: '', known: false };
if (file[2] === 'hd') asked.hd++;
return {
status: 200,
contentType: 'image/jpeg',
headers: CORS,
body: photoJpeg(),
known: true,
};
}
if (address.hostname !== 'api.panoramax.xyz')
return { status: 404, headers: CORS, body: '', known: false };
const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
if (tile) {
const bytes = panoramaxTile(
Number(tile[1]),
Number(tile[2]),
Number(tile[3]),
);
return bytes.length
? {
status: 200,
contentType: 'application/vnd.mapbox-vector-tile',
headers: CORS,
body: Buffer.from(bytes),
known: true,
}
: { status: 204, headers: CORS, body: '', known: true };
}
if (address.pathname !== '/api/search')
return { status: 404, headers: CORS, body: '', known: false };
asked.search++;
const q = address.searchParams;
if (q.has('ids')) {
const wanted = q.get('ids').split(',');
return json({
type: 'FeatureCollection',
features: pictures
.filter((p) => wanted.includes(p.id))
.map((p) => item(p, pictures)),
});
}
const filter = q.get('filter') || '';
const since = q.get('datetime')?.split('/')[0];
const kept = pictures
.filter(
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
)
.filter((p) =>
filter === 'field_of_view=360'
? p.isPano
: filter === 'field_of_view<360'
? !p.isPano
: true,
)
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
// A box: every picture in it, nearest its centre first, as the live API
// returns them (roughly: the browser ranks them again).
if (q.has('bbox')) {
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
const centre = { lon: (west + east) / 2, lat: (south + north) / 2 };
return json({
type: 'FeatureCollection',
features: kept
.filter(
(p) =>
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
)
.map((p) => ({ p, metres: metresApart(centre, p) }))
.sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.id))
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures)),
});
}
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
// As the API does, even at the widest tolerance: a place behind a
// picture (every one looks east) is not in it.
const facing = (p) => lon >= p.lon - 1e-7;
const near = kept
.filter(facing)
.map((p) => ({ p, metres: metresApart({ lon, lat }, p) }))
.filter(({ metres }) => metres <= radius)
.sort((a, b) => a.metres - b.metres)
.slice(0, Number(q.get('limit')) || 10)
.map(({ p }) => item(p, pictures));
return json({ type: 'FeatureCollection', features: near });
}
+35 -3
View File
@@ -112,6 +112,8 @@
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.test.mjs",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -366,6 +368,12 @@
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
"src/data/earthquakes.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/model.test.mjs",
"src/layers/airQuality/ownership.test.mjs",
"src/layers/airQuality/source.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
@@ -540,6 +548,7 @@
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
@@ -734,6 +743,7 @@
"src/search/nominatim.js",
"src/search/nominatim.test.mjs",
"src/sources/httpBody.js",
"src/sources/httpBody.test.mjs",
"src/sources/overpass.js",
"src/sources/nominatim.js",
"server/providers/transit.js",
@@ -932,15 +942,33 @@
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
"src/layers/streetLevel/providers/kartaview/panorama.test.mjs",
"src/layers/streetLevel/panorama.test.mjs",
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/providers/panoramax/policy.test.mjs",
"src/layers/streetLevel/providers/panoramax/decode.test.mjs",
"src/layers/streetLevel/providers/panoramax/source.test.mjs",
"src/layers/streetLevel/providers/panoramax/viewer.test.mjs",
"src/layers/streetLevel/providers/panoramax/provider.test.mjs",
"src/testSupport/panoramaxFixtures.mjs",
"src/layers/streetLevel/photoViewer.test.mjs",
"src/layers/streetLevel/limiter.test.mjs",
"src/layers/streetLevel/imageSize.test.mjs",
"src/layers/streetLevel/lineCoverage.test.mjs",
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
@@ -957,6 +985,7 @@
"src/qaChrome.test.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
@@ -1021,6 +1050,9 @@
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs",
"scripts/generate-icons.mjs",
"src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs"
"src/ui/flyToDataSource.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs"
]
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
// Renders the home-screen and favicon rasters from `public/logo.svg`.
//
// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
// is committed, because the install icons have to exist in a plain `vite build`
// and contributors should not need an image toolchain to ship the app.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const REPO_ROOT = path.resolve(
fileURLToPath(new URL('.', import.meta.url)),
'..',
);
const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
const BACKGROUND = '#0a0a0f';
/**
* Fraction of the canvas the mark is allowed to occupy.
*
* A maskable icon may be cropped to a circle inscribed in the middle 80% of the
* square, so its mark stays well inside that circle; the other icons are shown
* as drawn and only need optical breathing room.
*/
const INSET = { normal: 0.78, maskable: 0.54 };
const TARGETS = [
{ file: 'icon-32.png', size: 32, inset: INSET.normal },
{ file: 'icon-180.png', size: 180, inset: INSET.normal },
{ file: 'icon-192.png', size: 192, inset: INSET.normal },
{ file: 'icon-512.png', size: 512, inset: INSET.normal },
{ file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
];
/** Fit the logo inside `size * inset` and centre it on an opaque square. */
async function render(svg, { size, inset }) {
const box = Math.round(size * inset);
// The logo's viewBox is wider than it is tall, so `contain` bounds it by
// width and leaves the vertical padding uneven — centring is done by the
// composite below rather than by the resize.
const mark = await sharp(svg, { density: 384 })
.resize(box, box, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: BACKGROUND,
},
})
.composite([{ input: mark, gravity: 'centre' }])
.png({ compressionLevel: 9 })
.toBuffer();
}
const svg = await readFile(LOGO);
await mkdir(OUT_DIR, { recursive: true });
for (const target of TARGETS) {
await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
console.log(`icons: wrote ${target.file} (${target.size}px)`);
}
+80 -8
View File
@@ -123,7 +123,12 @@
"traffic-provider": {
"runtime": "node",
"exports": ["./server/providers/traffic"],
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
"modules": [
"server/providers/traffic.js",
"server/providers/common/http.js",
"src/sources/httpBody.js",
"src/data/tomtomTiles.js"
],
"external": []
},
"traffic-source": {
@@ -139,7 +144,12 @@
"firms-provider": {
"runtime": "node",
"exports": ["./server/providers/firms"],
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
"modules": [
"server/providers/firms.js",
"server/providers/common/http.js",
"src/sources/httpBody.js",
"src/data/firmsCsv.js"
],
"external": []
},
"gbfs-provider": {
@@ -148,6 +158,8 @@
"modules": [
"server/providers/gbfs.js",
"server/providers/common/http.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js",
"src/data/gbfsSource.js",
"src/sources/httpBody.js"
],
@@ -166,6 +178,7 @@
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -1176,6 +1189,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -1237,7 +1251,16 @@
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
@@ -1682,6 +1705,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -2213,7 +2237,16 @@
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
@@ -2239,7 +2272,11 @@
"src/app/embed.js",
"src/data/cameraFraming.js",
"src/data/geoImportPins.js",
"src/data/geoImportImages.js"
"src/data/geoImportImages.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -2430,6 +2467,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
@@ -2760,7 +2798,16 @@
"src/layers/streetLevel/providers/kartaview/source.js",
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/providers/kartaview/panorama.js",
"src/layers/streetLevel/providers/panoramax/index.js",
"src/layers/streetLevel/providers/panoramax/policy.js",
"src/layers/streetLevel/providers/panoramax/source.js",
"src/layers/streetLevel/providers/panoramax/decode.js",
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
@@ -2780,7 +2827,11 @@
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js",
"src/data/cameraFraming.js"
"src/data/cameraFraming.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -3161,7 +3212,9 @@
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js",
"src/layers/gnss/source.js"
"src/layers/gnss/source.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js"
],
"external": []
},
@@ -3544,5 +3597,24 @@
"src/data/layerStateTokenReservations.json"
],
"external": []
},
"air-quality": {
"exports": ["./layers/air-quality"],
"modules": [
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/sources/httpBody.js"
],
"external": ["cesium"]
},
"air-quality-source": {
"exports": ["./layers/air-quality/source"],
"modules": [
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/sources/httpBody.js"
],
"external": []
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* qa-air-quality.mjs — headless proof for the Air quality layer.
*
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
* reach the app in the provider-shaped form other networks will share, and that
* the Weather readout descriptor carries a legend and an in-view list.
*
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
* with no reading surfacing as a confident 1 — is a real defect this catches.
*
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
*/
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
let exitCode = 0;
const consoleErrors = [];
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--no-sandbox',
'--disable-dev-shm-usage',
],
defaultViewport: { width: 1400, height: 900 },
});
try {
const page = await browser.newPage();
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 90000 },
);
await sleep(1500);
const stats = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('air-quality', true);
const mod = dm.layers.get('air-quality')?.module;
if (!mod) return { missing: true };
let s = null;
for (let i = 0; i < 45; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.count > 0 || s.error) break;
}
return s;
});
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
record('stations report a current index', loaded,
`count=${stats?.count} error=${stats?.error}`);
if (!loaded) exitCode = 1;
const records = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getAnalystRecords(500) || [],
);
// The provider-neutral contract other networks will share.
const shaped = records.every(
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
);
record('every reading carries provider, scale, value and band', shaped,
records.length
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
: 'no records');
if (!shaped) exitCode = 1;
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
record('every AQHI value is in the published integer form, floored at 1', published,
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
if (!published) exitCode = 1;
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
if (zones.length < 3) exitCode = 1;
// The card's whole point is "highest readings in view", so put the camera
// over Canada before asserting on it — the default view is over Austin,
// where an empty in-view list is correct and proves nothing.
await page.evaluate(() => {
const gev = window.__godsEyeView;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight in progress */
}
gev.viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: -100 * d2r,
latitude: 55 * d2r,
height: 4_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
});
await sleep(2500);
const controls = await page.evaluate(
() =>
window.__godsEyeView.dataManager
.layers.get('air-quality')
?.module.getRowControls?.() || null,
);
const readoutOk =
controls?.readout === true &&
Array.isArray(controls?.legend?.colors) &&
controls.legend.colors.length === controls.legend.labels.length &&
Array.isArray(controls?.list?.items) &&
controls.list.items.length > 0 &&
controls.list.items.every((item) => item.id && item.label && item.value) &&
/Canada only/.test(controls?.summary?.coverage || '');
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
controls
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
: 'no descriptor');
if (!readoutOk) exitCode = 1;
record('no console errors', consoleErrors.length === 0,
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
if (consoleErrors.length) exitCode = 1;
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});
+376 -9
View File
@@ -3,8 +3,9 @@
* Browser QA for the Street Level layer against a running server. Run with
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView
* request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token
* `--fixtures` (the CI mode) answers every *.mapillary.com, KartaView and
* Panoramax request from fixtures, so nothing reaches those providers. The
* server still needs a token
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
* a rejected key and a keyless second server.
*
@@ -46,6 +47,17 @@ import {
kartaViewPhotos,
resetKartaViewFixture,
} from './fixtures/street-level/kartaView.mjs';
import {
answerPanoramaxRequest,
describePanoramaxCall,
PNX_CREATOR,
PNX_LINE,
PNX_PARK,
PNX_SEQUENCES,
panoramaxHdDownloads,
panoramaxPictures,
resetPanoramaxFixture,
} from './fixtures/street-level/panoramax.mjs';
const PANEL_ID = 'street-level-panel';
@@ -62,6 +74,7 @@ export const EXPECTED_PROVIDERS = Object.freeze([
'mapillary',
'google',
'kartaview',
'panoramax',
]);
/**
@@ -245,6 +258,8 @@ export async function serveFixtures(page, fixture) {
{ urlPattern: '*://api.kartaview.org/*' },
{ urlPattern: '*://cdn.kartaview.org/*' },
{ urlPattern: '*://*.openstreetcam.org/*' },
{ urlPattern: '*://api.panoramax.xyz/*' },
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
],
});
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
@@ -280,6 +295,14 @@ export async function serveFixtures(page, fixture) {
answer = answerKartaViewRequest({ method, url: request.url });
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
} else if (
address.hostname === 'api.panoramax.xyz' ||
address.hostname === 'panoramax.openstreetmap.fr'
) {
const call = describePanoramaxCall(method, address);
answer = answerPanoramaxRequest({ method, url: request.url });
fixture.calls.add(call);
if (!answer.known) fixture.unknown.push(call);
} else if (
address.pathname === '/api/mapillary/status' &&
fixture.configured !== undefined
@@ -1054,7 +1077,7 @@ async function main() {
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
assert.ok(
await settle(page, () => {
const img = document.querySelector('.sl-kv-photo');
const img = document.querySelector('.sl-photo-flat');
return img && !img.hidden && img.naturalWidth > 0;
}),
'the photo is on screen',
@@ -1098,7 +1121,7 @@ async function main() {
);
}
const before = street.imageId;
await clickPanelControl(page, '.sl-kv-step-next');
await clickPanelControl(page, '.sl-photo-step-next');
assert.ok(
await uiUntil(
(u, was) =>
@@ -1150,10 +1173,10 @@ async function main() {
assert.equal(u.street.error, null);
assert.equal(u.street.renderModes, false, 'no frame to fit');
assert.equal(
await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden),
await page.$eval('.sl-photo-pano', (canvas) => canvas.hidden),
false,
);
const box = await page.$eval('.sl-kv-pano', (canvas) => {
const box = await page.$eval('.sl-photo-pano', (canvas) => {
const r = canvas.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
@@ -1170,7 +1193,7 @@ async function main() {
// A GPU reset: the panorama comes back on a new canvas, and drags.
assert.ok(
await page.evaluate(() => {
const canvas = document.querySelector('.sl-kv-pano');
const canvas = document.querySelector('.sl-photo-pano');
window.__kvLostCanvas = canvas;
const lose = canvas
.getContext('webgl')
@@ -1184,7 +1207,7 @@ async function main() {
await settle(
page,
() => {
const canvas = document.querySelector('.sl-kv-pano');
const canvas = document.querySelector('.sl-photo-pano');
return (
canvas &&
canvas !== window.__kvLostCanvas &&
@@ -1231,6 +1254,349 @@ async function main() {
await setStack(original);
}
/**
* Panoramax: keyless orange coverage with both filters, a line click
* opening that sequence's picture nearest the click (author, licence,
* cones, ›), a 360° picture whose original is fetched once on EXPAND.
* Live runs park over Paris, where Panoramax has pictures. Runs on a
* draped map; leaves Mapillary lit.
*/
async function panoramaxSteps() {
resetPanoramaxFixture();
const ui = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState(),
);
const uiUntil = (test, arg = null, options) =>
settle(
page,
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
undefined,
options,
);
const lit = (id) =>
uiUntil(
(u, want) =>
u.providers.find((p) => p.id === want)?.on === true &&
u.providers.filter((p) => p.on).length === 1,
id,
);
const module = (call, ...args) =>
page.evaluate(
(name, list) =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module[name](...list),
call,
args,
);
const stack = () =>
page.evaluate(() =>
window.__godsEyeView.mapStackController.getActiveId(),
);
const setStack = async (id) => {
await page.evaluate(
(stackId) =>
window.__godsEyeView.mapStackController.setStack(stackId),
id,
);
await settle(
page,
(stackId) =>
window.__godsEyeView.mapStackController.getActiveId() === stackId,
id,
);
};
const camera = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionCartographic;
return {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
height: c.height,
};
});
const pnxChip = '#sl-provider-chips [data-chip-id="panoramax"]';
const original = await stack();
await module('closeViewer');
await setStack('esri-imagery');
for (let attempt = 0; attempt < 4; attempt++) {
await page.evaluate((at) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
const C = v.camera.positionCartographic.constructor;
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(at.lon, at.lat, at.height),
),
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
});
}, PNX_PARK);
await sleep(1000);
}
try {
await step(
'Panoramax: its chip draws keyless orange coverage, keeps both filters, credits Panoramax and lights in the share link',
async () => {
await clickPanelControl(page, pnxChip);
assert.ok(await lit('panoramax'), 'Panoramax alone is lit');
await waitForCoverage(page);
const u = await ui();
assert.equal(u.keyRequired, false);
assert.deepEqual(u.filterControls, { pano: true, since: true });
assert.equal(
await page.$eval(pnxChip, (chip) =>
chip.style.getPropertyValue('--chip-color'),
),
'#ff6f00',
);
assert.match(
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
/sequences in view · click a line for its nearest photo/,
);
await page.waitForFunction(
() =>
document.body.innerHTML.includes('Panoramax</a> contributors'),
{ timeout: 15_000 },
);
assert.ok(
await settle(page, () =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.some(
(entry) =>
entry.startsWith('0.') && entry.includes('panoramax-1'),
),
),
`the share link lights Panoramax: ${await page.evaluate(() => location.hash)}`,
);
},
);
await step(
'Panoramax: a click on a line opens that sequence’s picture nearest the click, camera still, with author and licence, cones and ›',
async ({ skip }) => {
// Points on the street: the fixture's, or live pictures there.
let points = null;
if (fixtures)
points = Array.from({ length: 9 }, (_, i) => [
PNX_LINE.west +
((i + 2) / 13) * (PNX_LINE.east - PNX_LINE.west),
PNX_LINE.lat,
]);
else {
const near = await fetch(
`https://api.panoramax.xyz/api/search?place_position=${PNX_PARK.lon},${PNX_PARK.lat}&place_distance=0-150&place_fov_tolerance=180&limit=12`,
)
.then((r) => r.json())
.catch(() => null);
points = near?.features?.map((f) => f.geometry.coordinates);
if (!points?.length)
return skip('no Panoramax picture near the park');
}
const handle = await page
.waitForFunction(
findPickPoint,
{ timeout: 60_000, polling: 500 },
{
prefix: 'pnx:seq:',
id: null,
points,
billboardIds: null,
},
)
.catch(() => null);
if (!handle && !fixtures) return skip('no Panoramax line in view');
assert.ok(handle, 'a Panoramax line is pickable');
let at = await handle.jsonValue();
if (fixtures) {
// Click the top line where the other sequence's pictures are:
// the nearest picture is then the other's, and only the line
// click's own-sequence lookup opens the clicked line's.
const others = panoramaxPictures()
.filter((p) => p.sequenceId !== at.id)
.slice(40, 100)
.map((p) => [p.lon, p.lat]);
const aimed = await page
.waitForFunction(
findPickPoint,
{ timeout: 60_000, polling: 500 },
{
prefix: 'pnx:seq:',
id: at.id,
points: others,
billboardIds: null,
},
)
.catch(() => null);
assert.ok(
aimed,
'the line is pickable over the other’s pictures',
);
at = await aimed.jsonValue();
}
const parked = await camera();
await page.mouse.click(at.x, at.y);
await page.waitForFunction(
() => {
const s = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().street;
return (s.imageId && !s.loading) || s.error;
},
{ timeout: 60_000 },
);
const street = (await ui()).street;
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'panoramax');
assert.equal(
street.sequenceId,
at.id,
'the picture is from the line clicked',
);
assert.ok(street.license, 'the caption names the licence');
assert.match(
street.externalUrl,
/^https:\/\/api\.panoramax\.xyz\/#pic=/,
);
await sleep(1500);
const now = await camera();
assert.ok(
Math.abs(now.lon - parked.lon) < 1e-6 &&
Math.abs(now.lat - parked.lat) < 1e-6 &&
Math.abs(now.height - parked.height) < 0.5,
`the camera did not move: ${JSON.stringify({ parked, now })}`,
);
if (fixtures) {
const caption = await page.$eval(
'#sl-image-by',
(node) => node.textContent,
);
if (street.sequenceId === PNX_SEQUENCES[0])
assert.equal(caption, `Image by ${PNX_CREATOR} · CC BY-SA 4.0`);
else
assert.equal(
caption,
'via panoramax.openstreetmap.fr · Licence Ouverte 2.0',
'an account named by an email address is not shown; the instance is',
);
const pictures = panoramaxPictures().filter(
(p) => p.sequenceId === street.sequenceId,
);
assert.ok(
await uiUntil(
(u, want) =>
u.sequence.selectedId === want &&
!u.sequence.loading &&
u.sequence.images > 0,
street.sequenceId,
{ timeout: 30_000 },
),
`cones along the sequence: ${JSON.stringify((await ui()).sequence)}`,
);
assert.ok((await ui()).sequence.images <= pictures.length);
}
const before = street.imageId;
await clickPanelControl(page, '.sl-photo-step-next');
assert.ok(
await uiUntil(
(u, was) =>
u.street.imageId &&
u.street.imageId !== was &&
!u.street.loading &&
!u.street.error,
before,
{ timeout: 30_000 },
),
'› opened the next picture',
);
const after = (await ui()).street;
assert.equal(after.sequenceId, street.sequenceId);
if (fixtures) {
const all = panoramaxPictures();
const from = all.find((p) => p.id === before);
assert.equal(
after.imageId,
all.find(
(p) =>
p.sequenceId === from.sequenceId &&
p.index === from.index + 1,
).id,
'› followed the picture’s next link',
);
}
},
);
if (fixtures)
await step(
'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once',
async () => {
const pano = panoramaxPictures().find((p) => p.isPano);
await module('openImage', 'panoramax', pano.id);
assert.ok(
await uiUntil(
(u, want) =>
u.street.imageId === want &&
!u.street.loading &&
u.street.isPano,
pano.id,
{ timeout: 60_000 },
),
`the panorama opened: ${JSON.stringify((await ui()).street)}`,
);
const u = await ui();
assert.equal(u.street.renderModes, false, 'no frame to fit');
const box = await page.$eval('.sl-photo-pano', (canvas) => {
const r = canvas.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
const bearing = u.street.bearing;
await page.mouse.move(box.x, box.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++)
await page.mouse.move(box.x - i * 15, box.y);
await page.mouse.up();
assert.ok(
await uiUntil((v, was) => v.street.bearing !== was, bearing),
'dragging the panorama turned its bearing',
);
assert.equal(panoramaxHdDownloads(), 0, 'docked: SD only');
await clickPanelControl(page, '#sl-viewer-expand');
assert.ok(
await settle(page, () =>
Boolean(document.querySelector('.sl-viewer-wrap-expanded')),
),
'the viewer expanded',
);
const deadline = Date.now() + 30_000;
while (panoramaxHdDownloads() === 0 && Date.now() < deadline)
await sleep(250);
await sleep(2000);
assert.equal(
panoramaxHdDownloads(),
1,
'the original, once, for the expanded viewer',
);
await page.keyboard.press('Escape');
},
);
else
await skipStep(
'fixtures only',
'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once',
);
} finally {
await module('closeViewer');
await clickPanelControl(
page,
'#sl-provider-chips [data-chip-id="mapillary"]',
);
assert.ok(await lit('mapillary'), 'Mapillary is lit again');
await setStack(original);
}
}
async function keyedSteps() {
// A late startup flight only shows itself by moving the camera, so park
// and check it stays put rather than reading a state.
@@ -2556,6 +2922,7 @@ async function main() {
},
);
await kartaViewSteps();
await panoramaxSteps();
await step(
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
async () => {
@@ -2639,7 +3006,7 @@ async function main() {
);
if (fixtures)
await step(
'every Mapillary and KartaView request was answered from a fixture',
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
() => {
console.log(
` (answered: ${[...fixture.calls].sort().join(' | ')})`,
+4 -1
View File
@@ -443,7 +443,10 @@ async function main() {
// for nearby names. Answer both with empty results: the entity-context
// assertions are about the SELECTED contact, and a live lookup would
// make the run non-hermetic (and bill the owner's Google quota).
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
if (
isAppRequest &&
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
) {
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
}
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
+18 -2
View File
@@ -1,3 +1,5 @@
import { readResponseTextCapped } from '../common/http.js';
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
*
@@ -25,6 +27,10 @@ export function adsbLolProxy() {
let _cooldownStatus = 0;
/** Response cache TTL (ms). */
const CACHE_MS = 12000;
/** Upstream deadline (ms); the fetch previously had none and could hang a poll. */
const UPSTREAM_TIMEOUT_MS = 12000;
/** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */
const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
const RATE_LIMIT_COOLDOWN_MS = 30000;
/** Cooldown after a 5xx (ms). */
@@ -98,11 +104,17 @@ export function adsbLolProxy() {
);
return;
}
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
signal,
});
if (upstream.ok) {
const body = await upstream.text();
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
_cache = body;
_cacheAt = Date.now();
_cooldownUntil = 0;
@@ -129,7 +141,11 @@ export function adsbLolProxy() {
return;
}
}
const body = await upstream.text();
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
serve(
res,
upstream.status,
+12 -4
View File
@@ -1,5 +1,9 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { readResponseJsonCapped } from '../common/http.js';
/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */
const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024;
/**
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
* airports) and hex → aircraft type/registration. Free community API — cached
@@ -80,12 +84,16 @@ export function adsbdbProxy() {
kind === 'route'
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
const signal = AbortSignal.timeout(8000);
const res = await fetch(url, { signal });
if (res.ok) {
const payload = await readResponseJsonCapped(
res,
ADSBDB_MAX_RESPONSE_BYTES,
signal,
);
const data =
kind === 'route'
? parseRoute(await res.json())
: parseAircraft(await res.json());
kind === 'route' ? parseRoute(payload) : parseAircraft(payload);
store[key] = { at: Date.now(), data }; // data may be null — negative cache
dirty = true;
return data;
+48 -20
View File
@@ -2,6 +2,7 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback
import {
coalesceProxyRequest,
readResponseJsonCapped,
readResponseTextCapped,
} from '../common/http.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
// ---------------------------------------------------------------------------
@@ -29,11 +30,34 @@ const OPENSKY_CACHE_MS = 9000;
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
const OPENSKY_STATES_URL =
'https://opensky-network.org/api/states/all?extended=1';
/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */
const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
/**
* Credit-governor cooldown for a 429: OpenSky's retry-after bounded to
* 30 s … 30 min, or 2 min when the header is absent.
* @param {Response} upstream Rate-limited response.
* @returns {number} Cooldown (ms).
*/
function openSkyRateLimitCooldownMs(upstream) {
// headers.get() gives null when absent, and Number(null) is 0, not NaN.
const header = upstream.headers.get('x-rate-limit-retry-after-seconds');
const retryAfterSec =
header == null || header.trim() === '' ? NaN : Number(header);
return Math.min(
Math.max(
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
30_000,
),
30 * 60_000,
);
}
/**
* Fetch and read the global snapshot, giving each attempt
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
* the connection fails. A second failure throws, which the proxy answers
* the connection fails. An oversized body is not retried: it would fail again
* and spend more credits. A second failure throws, which the proxy answers
* from its stale cache or the regional fallback.
* @param {Record<string, string>} headers Request headers.
* @returns {Promise<{upstream: Response, body: string}>}
@@ -41,13 +65,27 @@ const OPENSKY_STATES_URL =
async function fetchOpenSkyStates(headers) {
for (let attempt = 1; ; attempt++) {
try {
const upstream = await fetch(OPENSKY_STATES_URL, {
headers,
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
});
return { upstream, body: await upstream.text() };
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
if (upstream.status === 429) {
// Start the cooldown from the headers and drop the rejected body
// unread, so a stalled or oversized 429 body cannot skip it.
_openskyCooldownUntil =
Date.now() + openSkyRateLimitCooldownMs(upstream);
void upstream.body?.cancel().catch(() => {});
return {
upstream,
body: JSON.stringify({ error: 'OpenSky rate limited.' }),
};
}
const body = await readResponseTextCapped(
upstream,
OPENSKY_STATES_MAX_RESPONSE_BYTES,
signal,
);
return { upstream, body };
} catch (error) {
if (attempt >= 2) throw error;
if (attempt >= 2 || error?.code === 'RESPONSE_TOO_LARGE') throw error;
console.warn(
'[OpenSky] global snapshot attempt failed, retrying:',
error?.message || error,
@@ -544,19 +582,9 @@ export function openSkyProxy() {
}
if (upstream.status === 429) {
reason = 'rate_limited';
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
// 2 min when the header is absent) — no upstream attempts until then.
const retryAfterSec = Number(
upstream.headers.get('x-rate-limit-retry-after-seconds'),
);
const cooldownMs = Math.min(
Math.max(
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
30_000,
),
30 * 60_000,
);
_openskyCooldownUntil = now + cooldownMs;
// Credit governor: fetchOpenSkyStates already started the cooldown
// from OpenSky's retry-after — no upstream attempts until then.
const cooldownMs = openSkyRateLimitCooldownMs(upstream);
// 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) {
+18 -1
View File
@@ -28,7 +28,7 @@ export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
* GET /api/cctv/sources — list all registered camera sources
* GET /api/cctv/health — per-camera health/status report
* GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera
* GET /api/cctv/media/:id — proxy live video/image media from upstream
* GET /api/cctv/media/:id — proxy live video media from upstream
* GET /api/cctv/frame/:id — single frame with fallback chain
*
* @returns {import('vite').Plugin}
@@ -164,6 +164,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
sourceKind:
source.sourceKind || (source.url ? 'configured' : 'fallback'),
poseSource: source.poseSource,
model: source.model || '',
manufacturer: source.manufacturer || '',
license: source.license,
credit: source.credit || '',
code: source.code || '',
@@ -214,6 +216,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const source = sourceById.get(cameraId);
const mediaUrl = source?.url || '';
const feedType = normalizeFeedType(source?.feedType || 'image');
// Live feeds only. A still camera is served by /frame, whose fetch
// keeps redirects on the registered origin, accepts only image/* and
// caps the body; relaying it here would bypass all three.
if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') {
res.writeHead(404, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'No live media for this camera; use the frame endpoint',
}),
);
return;
}
const leaseId = url.searchParams.get('lease');
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
res.writeHead(400);
+39 -4
View File
@@ -4,6 +4,7 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } 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 {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -17,23 +18,30 @@ import {
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
loadWsdotSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/** Env opt-in: only "1" enables. For packs whose feed access needs an
* operator agreement, so an unset flag never reaches the upstream. */
* operator agreement or whose terms are unconfirmed, so an unset flag never
* reaches the upstream. */
const envOptIn = (name) => String(process.env[name] || '').trim() === '1';
/**
* Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries
* round-robin (cap.js), so a new pack never silently evicts an older one.
* plus its loader (in sources.js or its own module); the catalog cap is
* shared across entries round-robin (cap.js), so a new pack never silently
* evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env
* kill switch, or by an opt-in flag where access needs an agreement.
* kill switch, or by an opt-in flag where access needs an agreement or the
* terms are unconfirmed.
*/
const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
@@ -92,11 +100,26 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
load: loadCalgarySourcesFromOpenData,
},
{
name: 'catalonia',
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
load: loadDelDOTSourcesFromOpenData,
},
{
name: 'kingcounty',
enabled: () => envEnabled('CCTV_KINGCOUNTY_ENABLED'),
load: loadKingCountySourcesFromOpenData,
},
{
name: 'wsdot',
enabled: () => envEnabled('CCTV_WSDOT_ENABLED'),
load: loadWsdotSourcesFromOpenData,
},
{
name: 'vegvesen',
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
@@ -109,6 +132,18 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_NYC_ENABLED'),
load: loadNycSourcesFromOpenData,
},
{
name: 'toronto',
enabled: () => envEnabled('CCTV_TORONTO_ENABLED'),
load: loadTorontoSourcesFromOpenData,
},
{
// Opt-in until the Via Lietuva open-data terms are confirmed to cover the
// camera photos (see DATA_SOURCES.md).
name: 'lithuania',
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
load: loadLithuaniaSources,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+132 -5
View File
@@ -8,8 +8,8 @@ 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. Sized above the sum of the
* default per-pack caps so a default install never trims.
* 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.
*/
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
@@ -236,6 +236,56 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
* body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/**
* Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML
* XML list (verified 2026-09-15, hourly catalog refresh), fetched over https
* (verified 2026-09-27: same 200 body, no redirect). The feed carries a
* `font` field naming the actual publisher per camera — SCT's own highway
* cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are
* kept, each confirmed to return a real frame. Frame URLs are checked
* against a host allowlist rather than one pinned origin, since each
* publisher serves from its own host.
*/
export const CATALONIA_CAMERAS_URL =
'https://www.gencat.cat/transit/opendata/cameres.xml';
/** Byte ceiling for the Catalonia catalog read (~90 KB observed 2026-09-27),
* so a runaway body cannot be buffered without limit. */
export const CATALONIA_MAX_CATALOG_BYTES = 1024 * 1024;
/** Allowed image hosts, one per kept `font` publisher in the feed, each
* mapped to the one directory its frame links live in (every link in the
* 2026-10 feed shares it); a link must name a single file directly inside. */
export const CATALONIA_IMAGE_HOSTS = Object.freeze(
new Map([
['mct.gencat.cat', '/mct2bo/'], // SCT
['www.bcn.cat', '/transit/imatges/'], // IMI (Ajuntament de Barcelona)
['emap.terrassa.cat', '/it_terrassa/'], // Ajuntament de Terrassa
]),
);
/** SCT `sctidcam` values: a bare image filename, which also keys the camera
* id (the feed's `fid` embeds an upstream server-start stamp). */
export const CATALONIA_SCT_CAMERA_FILE_RE = /^[a-z0-9]+\.(gif|jpe?g)$/i;
/** Per-`font` partner credit, shown beside the provider in the CCTV panel
* (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's
* own publisher and carries no separate credit. */
export const CATALONIA_FONT_CREDIT = Object.freeze({
IMI: 'Ajuntament de Barcelona',
Terrassa: 'Ajuntament de Terrassa',
});
/** Ground-elevation priors in metres, by `font`: the feed carries no
* elevation, and this pack spans sea level (Barcelona) to Terrassa's plateau
* (~270 m). The client's ground snap corrects these on 3D-tile stacks. */
export const CATALONIA_FONT_ELEVATION_M = Object.freeze({
SCT: 80,
IMI: 20,
Terrassa: 270,
});
export const DEFAULT_CATALONIA_MAX_SOURCES = 150;
/** Prioritization anchors: Barcelona and Terrassa. */
export const CATALONIA_ANCHORS = [
{ lat: 41.3874, lon: 2.1686 }, // Barcelona
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
* country (~900 cameras). The raw DATEX node needs an account; this view does
@@ -249,9 +299,9 @@ export const VEGVESEN_IMAGE_ORIGIN =
* per-camera manifest path is registered. */
export const VEGVESEN_VIDEO_URL = (cameraId) =>
`https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`;
/** Above the ~850 working cameras so a default install keeps them all, while
* staying close to them: the per-pack defaults are meant to sum to no more
* than DEFAULT_CCTV_MAX_SOURCES. */
/** Above the ~850 working cameras so the pack keeps them all on its own. The
* catalog-wide DEFAULT_CCTV_MAX_SOURCES cap still applies across packs and is
* filled round-robin, so a full default install may thin this pack. */
export const DEFAULT_VEGVESEN_MAX_SOURCES = 900;
/** Hard ceiling on the catalog body. The whole country is ~800 KB. */
export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024;
@@ -305,6 +355,83 @@ export const DELDOT_ANCHORS = [
{ lat: 39.1582, lon: -75.5244 }, // Dover (Kent)
{ lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex)
];
/** King County, WA road cameras: keyless public ArcGIS FeatureServer layer
* ("King County and WSDOT Traffic Cameras", King County DOT Road Services).
* GeoJSON with WGS84 point geometry. `outFields` is an explicit list on
* purpose: the layer also carries asset-administration columns (including
* staff names in `ModifiedBy`) that must never be fetched. Uniquely among the
* packs, the county publishes each camera's hardware `Manufacturer`/`Model`
* (Cohu 3950/3965, Bosch AUTODOME, Axis Q6135-LE, …), carried through as the
* source's `model`. */
export const KING_COUNTY_CAMERAS_URL =
'https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson';
/** Frames come only from the county's own image handler host. */
export const KING_COUNTY_IMAGE_ORIGIN = 'https://info.kingcounty.gov/';
/** The whole catalog is ~125 rows (~59 live county cameras); no thinning by
* default. */
export const DEFAULT_KING_COUNTY_MAX_SOURCES = 125;
/** Prioritization anchor: Renton, roughly central to the county road network. */
export const KING_COUNTY_ANCHORS = [{ lat: 47.4829, lon: -122.2171 }];
/** Hard ceiling on the King County catalog body. The whole layer is ~125 rows
* and well under 100 KB; this only exists so an upstream that streams an
* unbounded body cannot be buffered without limit. */
export const KING_COUNTY_MAX_CATALOG_BYTES = 1024 * 1024;
/**
* City of Toronto RESCU traffic cameras: the municipal network the province's
* 511 pack does not cover — downtown arterials plus the Gardiner, DVP, Allen
* and Lake Shore. One keyless list for the whole city (~336 cameras), from the
* City's open data portal. It is fetched on every catalog refresh, but the
* upstream file is not updated often and may lag the live camera network.
*
* The list is served as JSONP (`jsonTMCEarthCamerasCallback({...})`); the
* portal publishes the same data as .xml and .csv beside it.
*/
export const TORONTO_RESCU_LIST_URL =
'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json';
/**
* The only origin Toronto camera frames may come from. The list carries no URL
* field: frame URLs are BUILT from this origin and a strictly-digits camera
* number (`loc####.jpg`, the convention the dataset documents), so no upstream
* field can steer the frame proxy off-host.
*/
export const TORONTO_RESCU_IMAGE_ORIGIN =
'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/';
/** The whole city fits under this; the cap exists to stay tunable, not to trim. */
export const DEFAULT_TORONTO_MAX_SOURCES = 336;
/** Yonge / Queen: the prioritization anchor. */
export const TORONTO_DOWNTOWN = { lat: 43.6532, lon: -79.3832 };
/** The city climbs from 76 m at the lake to 209 m at Steeles, so no single
* number fits: this is the mean across the 336 camera sites. The ground-height
* sidecar has no Toronto entries yet, so this prior seeds placement; a camera
* that gains a sidecar entry uses it instead, and the client's ground snap
* corrects the prior wherever 3D tiles are loaded. */
export const TORONTO_GROUND_ELEVATION_M = 116;
/** Hard ceiling on the RESCU list body. The whole city is ~336 rows and under
* 60 KB; this only exists so an upstream that streams an unbounded body cannot
* be buffered without limit. */
export const TORONTO_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the
* official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator),
* ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use;
* the catalog is fetched once per CCTV_SOURCE_CACHE_MS refresh. */
export const WSDOT_CAMERAS_URL =
'https://data.wsdot.wa.gov/travelcenter/Cameras.json';
/** Only frames on WSDOT's own image host are registered; the catalog also
* lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports)
* whose own terms apply. */
export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/';
export const DEFAULT_WSDOT_MAX_SOURCES = 250;
/** Hard ceiling on the WSDOT catalog body. The live layer is ~400 KB; this
* only exists so an upstream that streams an unbounded body cannot be
* buffered without limit. */
export const WSDOT_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Prioritization anchors: downtown Seattle and Spokane. */
export const WSDOT_ANCHORS = [
{ lat: 47.6062, lon: -122.3321 }, // Seattle
{ lat: 47.6588, lon: -117.426 }, // Spokane
];
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
+154
View File
@@ -0,0 +1,154 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
prioritizeSources,
} from './normalize.js';
import { readResponseJsonCapped } from '../common/http.js';
const BASE = 'https://eismoinfo.lt/eismoinfo-backend';
export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`;
// Without lks=true this endpoint returns [latitude, longitude]. The camera
// table's x/y fields use LKS-94 metres and must never be treated as degrees.
export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`;
export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last';
const ANCHORS = [
{ lat: 54.6872, lon: 25.2797 }, // Vilnius
{ lat: 54.8985, lon: 23.9036 }, // Kaunas
{ lat: 55.7033, lon: 21.1443 }, // Klaipeda
{ lat: 55.9349, lon: 23.3137 }, // Siauliai
{ lat: 55.7348, lon: 24.3575 }, // Panevezys
];
/** Join public catalogs by ID; register only the official still endpoint. */
export function lithuaniaCameraToSource(row, feature) {
const id = String(row?.id ?? '');
if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null;
const point = feature?.points?.[0]?.point;
if (!Array.isArray(point) || point.length !== 2) return null;
const [lat, lon] = point;
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
lat < 53.8 ||
lat > 56.5 ||
lon < 20.8 ||
lon > 26.9
)
return null;
try {
const url = new URL(row.image);
if (
url.origin !== 'https://eismoinfo.lt' ||
url.username ||
url.password ||
url.pathname !== FRAME_PATH ||
url.searchParams.get('id') !== id
)
return null;
} catch {
return null;
}
// Reconstruct so extra query parameters cannot alter the request.
const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`;
const cameraId = `lithuania-${id}`;
const name = String(row.name || feature.name || `Lietuva ${id}`)
.trim()
.slice(0, 160);
return {
id: cameraId,
name,
city: 'Lithuania / Lietuva',
cityId: 'lithuania',
provider: 'Via Lietuva / Eismoinfo',
lat,
lon,
// No calibrated pose is published; use the app's low-confidence prior.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'eismoinfo-public',
credit:
'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills',
code: cameraDisplayCode(name.toUpperCase()),
};
}
async function readCatalog(url, sibling) {
const signal = AbortSignal.any([
AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
sibling.signal,
]);
try {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal,
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `Catalog HTTP ${response.status}`,
);
}
return await readResponseJsonCapped(
response,
LITHUANIA_MAX_CATALOG_BYTES,
signal,
);
} catch (error) {
// The join needs both catalogs; release the other request at once
// rather than leaving its body open until the timeout.
sibling.abort();
throw error;
}
}
/** Two bounded requests per shared catalog refresh, no API key required. */
export async function loadLithuaniaSources() {
try {
const sibling = new AbortController();
const [rows, layers] = await Promise.all([
readCatalog(LITHUANIA_CAMERAS_URL, sibling),
readCatalog(LITHUANIA_LOCATIONS_URL, sibling),
]);
if (!Array.isArray(rows) || !Array.isArray(layers)) return [];
const features = layers.find((layer) => layer?.layer === 'VKR')?.features;
if (!Array.isArray(features)) return [];
const locations = new Map(
features.filter(Boolean).map((feature) => [String(feature.id), feature]),
);
const cameras = new Map();
for (const row of rows) {
const camera = lithuaniaCameraToSource(
row,
locations.get(String(row?.id)),
);
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
}
const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400);
const cap = Number.isFinite(rawCap)
? Math.max(1, Math.min(1000, Math.floor(rawCap)))
: 400;
const sources = prioritizeSources([...cameras.values()], cap, ANCHORS);
console.log(
`[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`,
);
return sources;
} catch (error) {
console.warn(
'[CCTV] Lithuania camera download error:',
error?.message || error,
);
return [];
}
}
+63
View File
@@ -394,6 +394,44 @@ export function isLikelyFinlandCoordinate(lat, lon) {
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
}
/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */
export function isLikelyCataloniaCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 40.3 &&
lat <= 42.9 &&
lon >= 0.0 &&
lon <= 3.4
);
}
/**
* Decode numeric HTML character references, decimal ("&#237;") and hex
* ("&#xED;") alike, the entity form the Catalonia feed uses for accented
* place names, plus the handful of named entities XML/HTML text commonly
* carries. A reference that names no valid character (NUL, a surrogate, or
* anything past U+10FFFF) is left as written rather than thrown on, so one
* bad field cannot fail a whole catalog.
*
* @param {string} text
* @returns {string}
*/
export function decodeNumericEntities(text) {
return String(text || '')
.replace(/&#(?:x([0-9a-f]+)|(\d+));/gi, (match, hex, dec) => {
const code = hex ? Number.parseInt(hex, 16) : Number(dec);
const valid =
code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff);
return valid ? String.fromCodePoint(code) : match;
})
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}
/**
* Human label for one Fintraffic preset (camera view).
*
@@ -562,5 +600,30 @@ export function normalizeSourceItem(item) {
// badge can distinguish them from raw automated priors (e.g. Austin Open
// Data, which never sets this field). Passed through as-is to the client.
poseSource: item.poseSource === 'curated' ? 'curated' : undefined,
// Optional hardware-model passthrough (additive, like poseSource): packs
// whose catalog publishes the camera's hardware model — King County's
// layer carries Manufacturer/Model per camera — pass it through so
// identified-hardware consumers can use it. Absent everywhere else.
model:
typeof item.model === 'string' && item.model.trim()
? item.model.trim()
: undefined,
// Bare model strings collide across vendors (e.g. "3950"), so the
// publishing pack's manufacturer rides along for disambiguation.
manufacturer:
typeof item.manufacturer === 'string' && item.manufacturer.trim()
? item.manufacturer.trim()
: undefined,
};
}
/** City of Toronto bounding box (Steeles to the lake, Etobicoke to Scarborough). */
export function isLikelyTorontoCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 43.55 &&
lat <= 43.9 &&
lon >= -79.7 &&
lon <= -79.1
);
}
+769 -1
View File
@@ -55,10 +55,34 @@ import {
DEFAULT_CALGARY_MAX_SOURCES,
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
CATALONIA_IMAGE_HOSTS,
CATALONIA_SCT_CAMERA_FILE_RE,
CATALONIA_FONT_CREDIT,
CATALONIA_FONT_ELEVATION_M,
DEFAULT_CATALONIA_MAX_SOURCES,
CATALONIA_ANCHORS,
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
DEFAULT_TORONTO_MAX_SOURCES,
TORONTO_DOWNTOWN,
TORONTO_GROUND_ELEVATION_M,
TORONTO_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS,
KING_COUNTY_CAMERAS_URL,
KING_COUNTY_IMAGE_ORIGIN,
DEFAULT_KING_COUNTY_MAX_SOURCES,
KING_COUNTY_ANCHORS,
KING_COUNTY_MAX_CATALOG_BYTES,
WSDOT_CAMERAS_URL,
WSDOT_IMAGE_ORIGIN,
DEFAULT_WSDOT_MAX_SOURCES,
WSDOT_ANCHORS,
WSDOT_MAX_CATALOG_BYTES,
DEFAULT_VEGVESEN_CCTV_URL,
VEGVESEN_IMAGE_ORIGIN,
VEGVESEN_VIDEO_URL,
@@ -89,14 +113,21 @@ import {
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
} from './normalize.js';
import { directionToHeading } from '../../../src/data/directionText.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
readResponseBytesCapped,
readResponseJsonCapped,
readResponseTextCapped,
} from '../common/http.js';
/**
* Fetch and parse Austin traffic camera records from the city Open Data portal.
*
@@ -1613,6 +1644,261 @@ export async function loadCalgarySourcesFromOpenData() {
}
}
/**
* Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records.
* Regex-based rather than a full XML parser — the feed is small (~160
* features) and this avoids a new dependency for one field set per feature.
*
* @param {string} xml
* @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>}
*/
export function parseCataloniaXml(xml) {
const out = [];
const blockRe = /<gml:featureMember>([\s\S]*?)<\/gml:featureMember>/g;
const tag = (block, name) => {
const match = new RegExp(`<cite:${name}>([^<]*)<\\/cite:${name}>`).exec(
block,
);
return match ? decodeNumericEntities(match[1]) : '';
};
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const block = match[1];
const fidMatch = /<cite:cameres\s+fid="([^"]+)"/.exec(block);
const coordsMatch = /<gml:coordinates[^>]*>([^<]+)<\/gml:coordinates>/.exec(
block,
);
if (!fidMatch || !coordsMatch) continue;
const [lonRaw, latRaw] = coordsMatch[1].split(',');
out.push({
fid: fidMatch[1],
lon: toFiniteNumber(lonRaw),
lat: toFiniteNumber(latRaw),
carretera: tag(block, 'carretera'),
municipi: tag(block, 'municipi'),
pk: tag(block, 'pk'),
link: tag(block, 'link'),
font: tag(block, 'font'),
});
}
return out;
}
/**
* Validate and normalize one Catalonia feed `link` against the publisher
* host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects
* anything else, including a host that merely resembles an allowed one, any
* URL carrying userinfo (`https://user:[email protected]/...`) or an explicit
* port — the hostname check alone would pass either, letting catalog data
* inject credentials or steer the server-side frame request to another
* service on that host — and any path outside the host's frame directory.
*
* SCT's own `RenderService` frame link is a special case, for two stacked
* reasons (both verified 2026-09-15):
* 1. It always 302s to `TransitCamera`, on a Location the upstream
* hardcodes as plain http even when RenderService itself was requested
* over https. fetchWithinHost (media.js) only follows same-origin
* redirects — same scheme included, by design, to stop an upstream
* steering a host-pinned request into a plaintext downgrade — so that
* hop is refused and the camera would render as a placeholder.
* 2. mct.gencat.cat's TLS setup itself does not complete a handshake with
* Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it
* is reachable over https from other clients (e.g. curl) — an upstream
* server misconfiguration this proxy cannot route around.
* TransitCamera answers the frame directly over **http**, no redirect, and
* that is the only transport this host actually serves to this runtime — so
* this builds that URL itself from RenderService's `sctidcam` id instead of
* registering the (https, then-redirecting) RenderService link. The
* `sctidcam` id must be a bare image filename (CATALONIA_SCT_CAMERA_FILE_RE).
*
* @param {string} rawUrl
* @returns {string} Normalized URL, or '' if not accepted.
*/
export function normalizeCataloniaImageUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || '').trim());
} catch {
return '';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (parsed.username || parsed.password || parsed.port) return '';
const dir = CATALONIA_IMAGE_HOSTS.get(parsed.hostname);
if (!dir || !parsed.pathname.startsWith(dir)) return '';
const file = parsed.pathname.slice(dir.length);
if (!file || file.includes('/')) return '';
if (parsed.hostname === 'mct.gencat.cat') {
const camId = parsed.searchParams.get('sctidcam');
if (file !== 'RenderService') return '';
if (!camId || !CATALONIA_SCT_CAMERA_FILE_RE.test(camId)) return '';
const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera');
direct.searchParams.set('nom', camId);
direct.searchParams.set('visualitzacio', 'imatge');
return direct.toString();
}
parsed.protocol = 'https:';
return parsed.toString();
}
/**
* Stable camera id for one normalized Catalonia frame URL (the output of
* normalizeCataloniaImageUrl). The feed's `fid` is not used: it embeds an
* upstream server-start stamp and changes on every upstream restart. SCT
* cameras key on the `sctidcam` filename stem (`cat-sct-nc87`), partner
* cameras on the frame host and path (`cat-www-bcn-cat-transit-imatges-…`).
*
* @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted.
* @returns {string}
*/
export function cataloniaCameraId(imageUrl) {
const parsed = new URL(imageUrl);
const slug = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (parsed.hostname === 'mct.gencat.cat') {
const file = parsed.searchParams.get('nom') || '';
return `cat-sct-${slug(file.replace(/\.[^.]*$/, ''))}`;
}
return `cat-${slug(parsed.hostname)}-${slug(parsed.pathname)}`;
}
/**
* Human label for one Catalonia camera: "<road/place> (<municipality>)",
* skipping the parenthetical when the municipality is already named in the
* road/place text or when one half is missing.
*
* @param {string} carretera
* @param {string} municipi
* @returns {string}
*/
export function cataloniaCameraName(carretera, municipi) {
const road = String(carretera || '').trim();
const city = String(municipi || '').trim();
if (road && city) {
return road.toLowerCase().includes(city.toLowerCase())
? road
: `${road} (${city})`;
}
return road || city || 'Càmera de trànsit';
}
/**
* Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one
* WFS/GML XML list. The feed carries a `font` field naming the real
* publisher per camera; only SCT's own highway cameras and the hotlinked
* Barcelona (IMI) and Terrassa municipal cameras are kept — the publishers
* whose frame links were confirmed to serve images. Frame URLs are checked
* against a host allowlist (CATALONIA_IMAGE_HOSTS), so a row from any other
* publisher is dropped. The feed carries no camera status, so a camera its
* publisher has taken offline still registers and serves that publisher's
* own "image unavailable" still.
* Each non-SCT camera carries its publisher as a `credit` alongside the
* shared `provider`. The feed carries no heading, so every camera takes the
* id-hash fallback, low-confidence pose (same as TfL and Fintraffic).
*
* Ids come from the validated frame URL (cataloniaCameraId), not the feed's
* restart-dependent `fid`, and rows sharing one are collapsed before the cap
* (the live feed repeats a few SCT cameras).
*
* The list decides ids, coordinates, labels and which allowlisted frame path
* is proxied, so it is fetched over https, redirects are refused (the list
* host cannot be steered or downgraded) and the body read is capped at
* CATALONIA_MAX_CATALOG_BYTES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCataloniaSourcesFromOpenData() {
try {
const resp = await fetch(CATALONIA_CAMERAS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
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] Catalonia catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Catalonia camera download failed:', resp.status);
return discard();
}
const xml = await readResponseTextCapped(resp, CATALONIA_MAX_CATALOG_BYTES);
const rows = parseCataloniaXml(xml);
if (!rows.length) return [];
const cameras = [];
for (const row of rows) {
if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue;
const imageUrl = normalizeCataloniaImageUrl(row.link);
if (!imageUrl) continue;
const font = row.font.trim();
const cameraId = cataloniaCameraId(imageUrl);
cameras.push({
id: cameraId,
name: cataloniaCameraName(row.carretera, row.municipi),
city: row.municipi.trim() || 'Catalunya',
cityId: 'catalunya',
provider: 'Servei Català de Trànsit',
lat: row.lat,
lon: row.lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'catalonia-open-data',
license:
"Generalitat de Catalunya — Llicència oberta d'ús d'informació",
credit: CATALONIA_FONT_CREDIT[font] || '',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_CATALONIA_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS);
console.log(
`[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Catalonia camera download error:',
error?.message || error,
);
return [];
}
}
export async function loadDelDOTSourcesFromOpenData() {
try {
const resp = await fetch(DELDOT_CCTV_URL, {
@@ -1717,6 +2003,319 @@ export async function loadDelDOTSourcesFromOpenData() {
}
}
/** Rough King County, WA bounding box. */
function isLikelyKingCountyCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 47.0 &&
lat <= 48.0 &&
lon >= -123.1 &&
lon <= -121.0
);
}
/**
* A King County `Model` value, whitespace-collapsed, or '' when it is a
* vendor-generic string ("PTZ", "PTZ IP", "BOSCH/PTZ") that names no model.
*/
function kingCountyModel(raw) {
const model = String(raw || '')
.replace(/\s+/g, ' ')
.trim();
return /^(?:[a-z]+\/)?ptz(?: ip)?$/i.test(model) ? '' : model;
}
/**
* Fetch King County road cameras (Washington State). Keyless: one public
* ArcGIS FeatureServer GeoJSON query (King County DOT Road Services), with an
* explicit `outFields` list so the layer's asset-administration columns are
* never fetched. Only cameras that are live and active (`Live`,
* `CurrentStatus`) with a positive integer id, finite in-county coordinates,
* and a frame on the county's own image handler are kept. WSDOT-owned rows in
* the layer are skipped because their frames are WSDOT-hosted, outside the
* pinned image origin, while partner-city cameras hosted on the county image
* handler (e.g. Newcastle) name their owner as the per-camera credit,
* DriveBC-style. The layer publishes no facing, so every heading is the id
* hash at low confidence.
*
* Uniquely among the packs, the county publishes each camera's hardware
* `Manufacturer`/`Model`; the model string (whitespace-collapsed — the feed
* carries stray newlines) rides along as the source's `model` for
* identified-hardware consumers. Vendor-generic strings ("PTZ", "PTZ IP",
* "BOSCH/PTZ") name no model and are treated as absent.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadKingCountySourcesFromOpenData() {
try {
const resp = await fetch(KING_COUNTY_CAMERAS_URL, {
headers: { Accept: 'application/json' },
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] King County catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] King County camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
KING_COUNTY_MAX_CATALOG_BYTES,
);
const rows = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
const seen = new Set();
for (const row of rows) {
const attrs = row?.properties;
const rawId = attrs?.AssetID;
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
if (attrs.Live !== 1) continue;
if (String(attrs.CurrentStatus || '').trim() !== 'Active') continue;
const owner = String(attrs.Owner || '').trim();
// WSDOT-owned rows serve WSDOT-hosted frames, outside the pinned
// county image origin, so they are skipped.
if (owner === 'WSDOT') continue;
// The handler is served over HTTPS; the feed still writes http://.
const imageUrl = String(attrs.ImageURL || '')
.trim()
.replace(/^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN);
if (!imageUrl.startsWith(KING_COUNTY_IMAGE_ORIGIN)) continue;
const [lon, lat] = Array.isArray(row?.geometry?.coordinates)
? row.geometry.coordinates
: [];
if (!isLikelyKingCountyCoordinate(lat, lon)) continue;
const cameraId = `kingcounty-${rawId}`;
if (seen.has(cameraId)) continue;
seen.add(cameraId);
const model = kingCountyModel(attrs.Model);
const manufacturer = String(attrs.Manufacturer || '')
.replace(/\s+/g, ' ')
.trim();
const camera = {
id: cameraId,
name:
String(attrs.Location || '').trim() || `King County camera ${rawId}`,
city: 'King County',
cityId: 'king-county',
provider: 'King County Road Services',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
// The low-confidence pose personality shared by the other packs.
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'kingcounty-open-data',
license: 'King County GIS open data (KCGIS Center terms of use)',
credit: owner && owner !== 'King County' ? owner : '',
};
if (model) camera.model = model;
if (model && manufacturer) camera.manufacturer = manufacturer;
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_KINGCOUNTY_MAX_SOURCES ||
DEFAULT_KING_COUNTY_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_KING_COUNTY_MAX_SOURCES;
const prioritized = prioritizeSources(
cameras,
maxCount,
KING_COUNTY_ANCHORS,
);
console.log(
`[CCTV] Loaded King County camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] King County camera download error:',
error?.message || error,
);
return [];
}
}
/** WSDOT `CompassDirection` codes as headings in degrees. The live feed sends
* single cardinals (N/E/S/W), `B` (both directions — most rows), `O`, and
* null; the diagonals are mapped in case they appear. `B`, `O`, null, and any
* other code carry no single facing and fall through to the id hash. */
const WSDOT_COMPASS_HEADINGS = Object.freeze({
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
});
/**
* Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees.
* The WSDOT camera layer publishes point geometry in wkid 102100.
* @param {number} x - Web Mercator metres east.
* @param {number} y - Web Mercator metres north.
* @returns {{lat: number, lon: number}} WGS84 degrees.
*/
export function webMercatorToWgs84(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return { lat, lon };
}
/** Rough Washington State bounding box (with the Columbia River border cams). */
function isLikelyWashingtonCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 45.3 &&
lat <= 49.3 &&
lon >= -125 &&
lon <= -116.5
);
}
/**
* Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON
* layer on WSDOT's official open-data host (the same layer the WSDOT Travel
* Center map consumes; a keyed Traveler Information API serves the identical
* population for higher-volume use). Only cameras with a positive integer id,
* finite in-state coordinates, and a frame on WSDOT's own image host are
* kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle,
* lodges, airports) are skipped rather than proxied under someone else's
* terms. A single-facing `CompassDirection` (a cardinal) gives a
* high-confidence heading; `B` (both directions), `O`, and null fall back to
* the id hash.
* The feed carries Windows-1252 bytes (en-dashes in titles) with no charset
* declaration, so the body is decoded explicitly before parsing.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadWsdotSourcesFromOpenData() {
try {
const resp = await fetch(WSDOT_CAMERAS_URL, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] WSDOT catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] WSDOT camera download failed:', resp.status);
return discard();
}
const payload = JSON.parse(
new TextDecoder('windows-1252').decode(
await readResponseBytesCapped(resp, WSDOT_MAX_CATALOG_BYTES),
),
);
const rows = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
for (const row of rows) {
const attrs = row?.attributes;
const rawId = attrs?.CameraID;
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
const imageUrl = String(attrs.ImageURL || '').trim();
if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue;
const x = row?.geometry?.x;
const y = row?.geometry?.y;
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const { lat, lon } = webMercatorToWgs84(x, y);
if (!isLikelyWashingtonCoordinate(lat, lon)) continue;
const cameraId = `wsdot-${rawId}`;
const heading =
WSDOT_COMPASS_HEADINGS[
String(attrs.CompassDirection || '')
.trim()
.toUpperCase()
];
const hasHeading = Number.isFinite(heading);
cameras.push({
id: cameraId,
name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`,
city: 'Washington',
cityId: 'washington-state',
provider: 'WSDOT',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Same two pose personalities as the other packs: raw priors that the
// client's ground snap and manual calibration refine.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'wsdot-open-data',
license: 'WSDOT Travel Center camera data (public low-volume feed)',
});
}
const maxRaw = Number(
process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_WSDOT_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS);
console.log(
`[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] WSDOT camera download error:',
error?.message || error,
);
return [];
}
}
/**
* One Statens vegvesen `CctvSimple` feature -> one catalog source, or null.
*
@@ -2016,3 +2615,172 @@ export async function loadNycSourcesFromOpenData() {
return [];
}
}
/**
* The JSONP envelope the City of Toronto wraps its camera list in. Anchored at
* both ends, so only that exact callback is unwrapped — a payload that merely
* contains the name somewhere is not treated as the list.
*/
const RESCU_JSONP = /^jsonTMCEarthCamerasCallback\(([\s\S]*)\)\s*;?$/;
/**
* Unwrap the RESCU camera list. The portal serves the list as JSONP; should it
* ever drop the wrapper, the bare JSON document still parses. Anything else —
* a foreign callback, a truncated body, an error page — yields no cameras
* rather than a thrown loader.
*
* @param {string} text - Raw response body.
* @returns {Array<object>} The `Data` rows, or [] when the body is unusable.
*/
export function parseRescuCameraList(text) {
const body = String(text ?? '').trim();
if (!body) return [];
const unwrapped = RESCU_JSONP.exec(body)?.[1] ?? body;
try {
const parsed = JSON.parse(unwrapped);
const rows = parsed?.Data ?? parsed?.data;
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
/**
* Convert one RESCU list row into a camera source.
*
* The row carries no URL: the frame URL is built from the documented
* `loc####.jpg` convention and a camera number validated as plain digits, so
* the frame proxy is pinned to the city origin by construction and no upstream
* field — present or planted — can steer it.
*
* No compass heading exists in this dataset. `D1`-`D4` are the directions of
* the *comparison* images (static reference stills captured in 2016, served
* from a different path); the live frame is one pan-tilt-zoom camera pointing
* wherever the operator left it. Treating those letters as a facing would put
* a confident, wrong bearing on every camera in the city, so every row takes
* the id-hash fallback and the low-confidence pose, as headingless Austin,
* TfL, Fintraffic and Calgary cameras do.
*
* @param {object} row - One entry from the RESCU `Data` array.
* @returns {object|null} Normalized camera source, or null when unusable.
*/
export function torontoCameraToSource(row) {
if (!row || typeof row !== 'object') return null;
const number = String(row.Number ?? row.number ?? '').trim();
// Plain digits only: a number is about to become a URL path segment.
if (!/^\d{1,6}$/.test(number)) return null;
const lat = toFiniteNumber(row.Latitude ?? row.latitude);
const lon = toFiniteNumber(row.Longitude ?? row.longitude);
if (!isLikelyTorontoCoordinate(lat, lon)) return null;
const cameraId = `toronto-${number}`;
// The feed's own label, verbatim. It is upper-case ("MCCOWAN RD", "ST
// HELEN'S AVE"); title-casing it would mangle more names than it fixed.
const name =
String(row.Name ?? row.name ?? '')
.replace(/\s+/g, ' ')
.trim() || `Toronto Camera ${number}`;
return {
id: cameraId,
name,
city: 'Toronto',
cityId: 'toronto',
provider: 'City of Toronto',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: TORONTO_GROUND_ELEVATION_M,
feedType: 'image',
url: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`,
snapshotUrl: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`,
sourceKind: 'toronto-rescu-open-data',
license:
'Contains information licensed under the Open Government Licence – Toronto',
// Unselected-label code: the intersection, so a camera at rest reads as a
// place rather than as its id.
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch City of Toronto RESCU traffic cameras from the City's open data
* portal, keyless. One JSONP list for the whole city; frames are stills on the
* same portal origin.
*
* The list fetch refuses redirects (`redirect: 'manual'`) so the list host
* cannot be steered, and caps the body it will buffer.
*
* Attribution: Open Government Licence – Toronto, registered in
* src/data/dataCredits.js.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTorontoSourcesFromOpenData() {
try {
const endpoint =
process.env.CCTV_TORONTO_LIST_URL || TORONTO_RESCU_LIST_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/json, text/javascript' },
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] Toronto camera list redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Toronto camera download failed:', resp.status);
return discard();
}
const text = await readResponseTextCapped(resp, TORONTO_MAX_CATALOG_BYTES);
const rows = parseRescuCameraList(text);
if (!rows.length) return [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = torontoCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_TORONTO_MAX_SOURCES || DEFAULT_TORONTO_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
: DEFAULT_TORONTO_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, [
TORONTO_DOWNTOWN,
]);
console.log(
`[CCTV] Loaded Toronto camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Toronto camera download error:',
error?.message || error,
);
return [];
}
}
+4 -1
View File
@@ -41,6 +41,9 @@ function number(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
// up to about half an hour before; allow that, but not a clock far ahead.
const EARLY_ADVISORY_MS = 90 * 60_000;
function iso(value, now) {
if (
typeof value !== 'string' ||
@@ -50,7 +53,7 @@ function iso(value, now) {
const n = Date.parse(value);
if (
!Number.isFinite(n) ||
n > now + 5 * 60_000 ||
n > now + EARLY_ADVISORY_MS ||
now - n > 12 * HOUR ||
new Date(n).toISOString().replace('.000Z', 'Z') !==
value.replace('.000Z', 'Z')
+12 -2
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
import { readResponseTextCapped } from './common/http.js';
/**
* NASA FIRMS live active-fire proxy with a memory + disk cache.
@@ -35,6 +36,12 @@ export function firmsProxy() {
'VIIRS_SNPP_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
* bound, not a budget.
*/
const FIRMS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
@@ -83,9 +90,12 @@ export function firmsProxy() {
*/
async function fetchSource(key, source) {
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
const signal = AbortSignal.timeout(60_000);
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const records = parseFirmsCsv(await res.text());
const records = parseFirmsCsv(
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
);
if (records === null) throw new Error('non-CSV upstream response');
return records;
}
+42 -2
View File
@@ -1,8 +1,10 @@
import { readResponseTextCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
isAllowedGbfsHost,
isAllowedGbfsPath,
gbfsCacheControl,
gbfsUpstreamHeaders,
} from '../../src/data/gbfsSource.js';
// ---------------------------------------------------------------------------
@@ -11,8 +13,24 @@ import {
/** Upstream fetch timeout for GBFS requests (ms). */
const GBFS_PROXY_TIMEOUT_MS = 12000;
/** Value of the Client-Identifier header sent with every upstream request. */
export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view';
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
/**
* Requests/minute/IP admitted by the proxy.
*
* The bikeshare layer's own demand is small and known: `src/layers/bikeshare/
* registry.js` catalogues 15 systems, station information is cached per city
* after its first fetch, and station status refreshes once per city every
* `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every
* catalogued system in camera range at once that is about 15 requests a minute,
* so 120 is eight times a maximal real session while still capping a caller
* using this proxy as an open relay against a third-party GBFS host.
*/
export const GBFS_RATE_PER_MIN = 120;
function gbfsRedirectHost(location, requestUrl) {
if (!location) return '';
try {
@@ -60,6 +78,10 @@ export async function fetchGbfsUpstream(
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
// Urban Sharing (the Norwegian city-bike feeds) asks every client to
// name itself in this header; other operators ignore it.
'Client-Identifier': GBFS_CLIENT_IDENTIFIER,
...gbfsUpstreamHeaders(new URL(url).hostname),
},
redirect: 'manual',
signal: controller.signal,
@@ -113,7 +135,12 @@ export async function fetchGbfsUpstream(
*
* @returns {import('vite').Plugin}
*/
export function gbfsProxy() {
export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) {
const allow = makeRateLimiter({
windowMs: 60_000,
max: ratePerMinute,
globalMax: ratePerMinute * 3,
});
const installMiddleware = (server) => {
server.middlewares.use('/api/gbfs', async (req, res) => {
try {
@@ -126,6 +153,19 @@ export function gbfsProxy() {
return;
}
// Frequency, checked before the target is even parsed: the allowlist
// already says WHERE this proxy may go, nothing said HOW OFTEN. A
// refused request performs no outbound fetch and starts no timer.
if (!allow(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
const url = new URL(req.url || '/', 'http://localhost');
const encodedTarget = url.pathname.replace(/^\/+/, '');
if (!encodedTarget) {
@@ -181,7 +221,7 @@ export function gbfsProxy() {
return;
}
if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
+3
View File
@@ -83,6 +83,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
export { googlePlacesContextProxy } from './places.js';
export { googleServerApiKey } from './places.js';
export { keylessGooglePlacesResponse } from './places.js';
export { keylessGoogleGeocodeResponse } from './places.js';
export { validateGeocodeAddress } from './places.js';
export { geocodeBounds } from './places.js';
export { adsbLolFallbackAnchor } from './aircraft/opensky.js';
export { readResponseTextCapped } from './common/http.js';
export { readResponseJsonCapped } from './common/http.js';
+11 -2
View File
@@ -9,7 +9,10 @@ import {
fetchOverpassPayload,
overpassNotConfigured,
} from './overpass/transport.js';
import { resolveOverpassUpstreams } from './overpass/constants.js';
import {
resolveOverpassUpstreams,
resolveOverpassUpstreamTag,
} from './overpass/constants.js';
import {
_militaryInstallationCache,
trimMilitaryInstallationCache,
@@ -74,7 +77,13 @@ function militaryInstallationsProxy() {
retrievedAt: new Date().toISOString(),
status: 'ready',
};
const entry = { payload, cachedAt: Date.now() };
// Tagged with the configured upstream set so the disk layer does not serve
// this answer back after the operator repoints (see overpass/cache.js).
const entry = {
payload,
cachedAt: Date.now(),
upstreams: resolveOverpassUpstreamTag(),
};
_militaryInstallationCache.set(key, entry);
trimMilitaryInstallationCache();
writeMilitaryInstallationDisk(key, entry);
@@ -5,6 +5,7 @@ import {
MILITARY_INSTALLATION_DISK_DIR,
MILITARY_INSTALLATION_MAX_CACHE,
} from './constants.js';
import { cachedByConfiguredUpstream } from '../overpass/constants.js';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
@@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk(
const entry = JSON.parse(
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
);
// Same reason as the Overpass disk cache: the key is the bbox alone, so an
// entry written against a different OVERPASS_UPSTREAMS is otherwise a hit.
if (!cachedByConfiguredUpstream(entry)) return null;
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
return migrateMilitaryInstallationEntry(entry);
} catch {
+6
View File
@@ -5,6 +5,7 @@ import {
OVERPASS_DISK_DIR,
OVERPASS_CACHE_MS,
OVERPASS_CACHE_MAX_ENTRIES,
cachedByConfiguredUpstream,
} from './constants.js';
import path from 'node:path';
import { createHash } from 'node:crypto';
@@ -47,6 +48,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
// them on both fresh and stale reads so an upgrade can recover immediately.
if (!overpassPayloadIsData(payload)) return null;
// The key is the query alone, so a file written by a different upstream —
// including the public instances that were the default before #742 — is a
// hit for the same query. Refuse it: the operator's configured instance
// must be what answers, on the stale path too.
if (!cachedByConfiguredUpstream(payload)) return null;
if (Date.now() - payload.cachedAt > maxAgeMs) return null;
return payload;
} catch {
+59 -4
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import { createHash } from 'node:crypto';
// ---------------------------------------------------------------------------
// Overpass API proxy constants and cache state
@@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) {
return endpoints.slice(0, 8);
}
let upstreamMemo = { raw: null, endpoints: [] };
let upstreamMemo = { raw: null, endpoints: [], tag: 'none' };
/**
* Short, stable identifier for the configured upstream set. Cached responses
* carry it so an answer produced by a different instance is not served after
* the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the
* configuration change by 7 days, a month for boundary queries, and forever on
* the serve-stale path. It is a hash, never the URLs, and it is only ever
* compared on read: it is not sent to clients and does not appear in errors.
*/
function upstreamTag(endpoints) {
if (!endpoints.length) return 'none';
// Sorted, so reordering the same instances for failover keeps the cache.
return createHash('sha256')
.update([...endpoints].sort().join('\n'))
.digest('hex');
}
/** Resolve after environment loading. Public Overpass instances are not used by default. */
function resolveOverpassUpstreams() {
const raw = process.env.OVERPASS_UPSTREAMS || '';
if (upstreamMemo.raw !== raw)
upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) };
readUpstreamMemo();
return [...upstreamMemo.endpoints];
}
/** The configured-upstream tag stored alongside, and checked against, cache entries. */
function resolveOverpassUpstreamTag() {
return readUpstreamMemo().tag;
}
let reportedForeignCache = false;
/**
* Was this cached entry produced by the upstream set configured now?
*
* With nothing configured there is no instance to contradict, and serving
* last-good data at any age is the point of the cache (the proxy's
* not-configured path depends on it), so any entry is accepted. Once an
* operator does configure an instance, only its own answers are served: an
* entry with no tag, or one from a different instance, is refused rather than
* matched on the query alone. Shared by both caches fed by these upstreams.
*/
function cachedByConfiguredUpstream(entry) {
const tag = resolveOverpassUpstreamTag();
if (tag === 'none' || entry?.upstreams === tag) return true;
if (!reportedForeignCache) {
reportedForeignCache = true;
console.warn(
'[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.',
);
}
return false;
}
function readUpstreamMemo() {
const raw = process.env.OVERPASS_UPSTREAMS || '';
if (upstreamMemo.raw !== raw) {
const endpoints = parseOverpassUpstreams(raw);
upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) };
}
return upstreamMemo;
}
/**
* TTL for FRESH cached Overpass responses (ms). Road geometry is static for
* months — the original 45 s TTL forced a public-mirror round-trip on nearly
@@ -153,6 +206,8 @@ export {
OVERPASS_MAX_RESPONSE_BYTES,
parseOverpassUpstreams,
resolveOverpassUpstreams,
resolveOverpassUpstreamTag,
cachedByConfiguredUpstream,
OVERPASS_USER_AGENT,
OVERPASS_TIMEOUT_MS,
};
+4
View File
@@ -1,6 +1,7 @@
import {
OVERPASS_MAX_RESPONSE_BYTES,
resolveOverpassUpstreams,
resolveOverpassUpstreamTag,
OVERPASS_USER_AGENT,
OVERPASS_TIMEOUT_MS,
} from './constants.js';
@@ -187,6 +188,9 @@ async function fetchOverpassPayload(
contentType: 'application/json',
// Never retain a secret-bearing endpoint in cache or response metadata.
endpoint: 'configured',
// Which configured instance produced this, as a hash rather than a URL,
// so a cached answer is not reused after the operator repoints.
upstreams: resolveOverpassUpstreamTag(),
rateLimited: false,
};
} catch {
+6 -1
View File
@@ -1,8 +1,13 @@
/** Place-search and route middleware for Node development servers. */
export { googlePlacesContextProxy } from './places/google.js';
export {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from './places/google.js';
export {
googleServerApiKey,
keylessGooglePlacesResponse,
keylessGoogleGeocodeResponse,
} from './places/google-key.js';
export { installRouteMiddleware } from './places/routes.js';
export {
+28 -6
View File
@@ -14,12 +14,34 @@ export function keylessGooglePlacesResponse(apiKey) {
}
/**
* Google API key for the SERVER-SIDE calls (Places nearby/text search, the
* CCTV Street View fallback). These never reach the browser, so this key can
* be restricted by server IP and scoped to Places API + Street View Static
* API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles +
* Geocoding for the browser (#33). Splitting them is opt-in: unset, this
* falls back to the shared browser key and nothing changes.
* Geocoding speaks Google's own response shape rather than the `places: []`
* contract its two siblings use, because the browser adapters read `status`
* and `results` directly. The keyless capability reads the same way: a status
* that is not `OK` and no results, which the adapters already treat as "this
* provider did not answer" and pass to the next geocoder in the chain.
*/
export function keylessGoogleGeocodeResponse(apiKey) {
if (String(apiKey ?? '').trim()) return null;
return {
statusCode: 200,
payload: {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
},
};
}
/**
* Google API key for the SERVER-SIDE calls (Places nearby/text search,
* geocoding, the CCTV Street View fallback). These never reach the browser, so
* this key can be restricted by server IP and scoped to Places API + Geocoding
* API + Street View Static API — while GOOGLE_MAPS_API_KEY stays
* referrer-restricted to Map Tiles + Maps JavaScript API for the browser
* (#33, #363). Splitting them is opt-in: unset, this falls back to the shared
* browser key, which then has to carry every API both sides use and cannot
* take a referrer restriction.
*/
export function googleServerApiKey() {
return resolveGoogleServerKey(process.env);
+191 -1
View File
@@ -1,9 +1,11 @@
import {
googleServerApiKey,
keylessGooglePlacesResponse,
keylessGoogleGeocodeResponse,
} from './google-key.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -25,7 +27,7 @@ let _googleRateLimiter;
*/
export const GOOGLE_DEFAULT_PER_MIN = 120;
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */
function googleRateLimiter() {
if (_googleRateLimiter === undefined)
_googleRateLimiter = makeCostRateLimiter(
@@ -56,6 +58,51 @@ export function validatePlacesCoordinates(searchParams) {
return { ok: true, latitude, longitude };
}
/** Longest accepted geocoder query. Real place names are far shorter. */
const GEOCODE_MAX_QUERY = 200;
/**
* Upstream deadline and body ceiling for the geocode routes. The deadline stays
* armed through body consumption, so a stalled upstream cannot hold a handler
* open, and the cap bounds a chunked or length-omitted reply. A geocode answer
* is a handful of results; 256 KiB is far above any honest one.
*/
const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000;
const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024;
/** Validate a free-text geocoder query before consuming request quota. */
export function validateGeocodeAddress(searchParams) {
const address = String(searchParams.get('address') || '').trim();
if (!address) return { ok: false, error: 'address is required' };
if (address.length > GEOCODE_MAX_QUERY)
return { ok: false, error: 'address is too long' };
return { ok: true, address };
}
/**
* Validate the optional viewport bias, which Google reads as
* `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than
* refused: the bias only ranks results, so a malformed one costs relevance,
* not an answer.
*/
export function geocodeBounds(searchParams) {
const raw = String(searchParams.get('bounds') || '').trim();
if (!raw) return null;
const corners = raw.split('|');
if (corners.length !== 2) return null;
const parts = corners.flatMap((corner) => corner.split(','));
// A blank component must be refused before Number() turns it into 0 and a
// half-written corner like `30.4,` reads as the equator.
if (parts.length !== 4 || parts.some((part) => !part.trim())) return null;
const numbers = parts.map(Number);
if (!numbers.every(Number.isFinite)) return null;
const [swLat, swLon, neLat, neLon] = numbers;
if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null;
if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null;
return raw;
}
/** Nearby place labels and view-biased text search, with request-time key resolution. */
export function googlePlacesContextProxy({
resolveApiKey = googleServerApiKey,
@@ -293,6 +340,149 @@ export function googlePlacesContextProxy({
);
}
});
/**
* Geocoding belongs on the server, not in the page. Google's Geocoding web
* service refuses referrer-restricted keys, so calling it from the browser
* forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be
* left unrestricted (#363). Here the request carries the server key, and
* the browser key no longer needs the Geocoding API at all.
*
* Unlike the two Places routes above, these answer in Google's own shape
* (`status` + `results`) rather than the `places: []` contract: the browser
* normalizers read that shape directly, and keeping it means the proxy adds
* a hop without adding a translation nobody asked for.
*/
function installGeocodeRoute(path, readQuery) {
middlewares.use(path, async (req, res) => {
const refuse = (statusCode, error) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }),
);
};
// Gate first, like the Places routes above: a cross-site caller learns
// nothing about this endpoint's method surface.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
refuse(405, 'Method not allowed');
return;
}
// A keyless geocode has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the
// Places routes above).
const apiKey = resolveApiKey();
const keyless = keylessGoogleGeocodeResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const query = readQuery(requestUrl.searchParams);
if (!query.ok) {
refuse(400, query.error);
return;
}
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with the
// Places routes because both spend the same Google budget.
const _grl = googleRateLimiter();
if (_grl && !_grl(clientKey(req))) {
res.statusCode = 429;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Retry-After', '5');
res.end(
JSON.stringify({
status: 'OVER_QUERY_LIMIT',
results: [],
error: 'Rate limit exceeded',
}),
);
return;
}
try {
const upstream = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
);
for (const [name, value] of Object.entries(query.params))
upstream.searchParams.set(name, value);
upstream.searchParams.set('key', apiKey);
const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS);
const response = await fetchImpl(upstream.toString(), {
redirect: 'error',
signal: deadline,
});
// An unreadable, oversized or timed-out body is not an answer: it
// falls through to the same shaped failure as a refusal below.
const data = await readResponseJsonCapped(
response,
GEOCODE_MAX_RESPONSE_BYTES,
deadline,
).catch(() => null);
// A body we could not read is an upstream failure, not an answer with
// a status of its own, so it does not borrow the upstream's code.
if (!data) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
}),
);
return;
}
res.statusCode = response.ok ? 200 : response.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'private, max-age=300');
res.end(JSON.stringify(data));
} catch {
// Fixed public text: a transport, URL or runtime message would
// describe the server to the browser (SECURITY.md, sanitized errors).
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
}),
);
}
});
}
installGeocodeRoute('/api/google/geocode', (searchParams) => {
const address = validateGeocodeAddress(searchParams);
if (!address.ok) return address;
const bounds = geocodeBounds(searchParams);
return {
ok: true,
params: {
address: address.address,
...(bounds ? { bounds } : {}),
},
};
});
installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => {
const coordinates = validatePlacesCoordinates(searchParams);
if (!coordinates.ok) return coordinates;
const { latitude, longitude } = coordinates;
return { ok: true, params: { latlng: `${latitude},${longitude}` } };
});
}
return {
+29 -12
View File
@@ -1,6 +1,9 @@
import https from 'node:https';
import { Readable } from 'node:stream';
import { isNonGlobalIpv4 } from './stations.js';
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export function radioMirrorOrigin(value) {
const hostname = String(value ?? '')
.toLowerCase()
@@ -124,19 +127,33 @@ export function fetchPinnedRadioResponse(url, options, addresses) {
},
},
(response) => {
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
// Nothing thrown here may escape: this callback runs outside the
// promise, so an exception would be uncaught and crash the server.
try {
const status = response.statusCode || 500;
// A `Response` only represents statuses up to 599; Node never hands
// this callback a 1xx.
if (status > 599)
throw new Error(
`Radio Browser returned unsupported status ${status}`,
);
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
}
const nullBody = NULL_BODY_STATUSES.has(status);
const upstream = new Response(
nullBody ? null : Readable.toWeb(response),
{ status, statusText: response.statusMessage || '', headers },
);
if (nullBody) response.resume();
resolve(upstream);
} catch (error) {
response.destroy();
reject(error);
}
resolve(
new Response(Readable.toWeb(response), {
status: response.statusCode || 500,
statusText: response.statusMessage || '',
headers,
}),
);
},
);
request.on('error', reject);
+11 -2
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import { readResponseTextCapped } from '../common/http.js';
/**
* Vite plugin: CelesTrak TLE proxy.
@@ -48,10 +49,14 @@ export function celestrakProxy() {
}
}
/** 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: AbortSignal.timeout(20000),
signal,
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
// descriptive User-Agent with a contact point.
headers: {
@@ -60,7 +65,11 @@ export function celestrakProxy() {
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.text();
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 };
+11 -4
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { readResponseBytesCapped } from './common/http.js';
import {
isValidTileCoord as isValidTomTomTile,
utcDayKey as tomtomUtcDayKey,
@@ -48,6 +49,8 @@ export function tomtomProxy() {
const DEFAULT_DAILY_BUDGET = 6000;
const MEM_MAX_ENTRIES = 256;
const UPSTREAM_TIMEOUT_MS = 15000;
/** Flow tiles are well under 1 MB; this bounds a runaway body, not a budget. */
const TOMTOM_TILE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
/** @type {Map<string, {at:number, buf:Buffer}>} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */
const mem = new Map();
@@ -143,11 +146,15 @@ export function tomtomProxy() {
'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' +
`${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`;
recordUpstreamFetch(); // attempts count — upstream bills the request either way
const res = await fetch(url, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const bytes = await readResponseBytesCapped(
res,
TOMTOM_TILE_MAX_RESPONSE_BYTES,
signal,
);
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (buf.length === 0) throw new Error('empty tile body');
return buf;
}
+23
View File
@@ -179,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses
assert.equal(recreated.fixes.length, 1);
assert.equal(recreated.truncated, true);
});
test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => {
const h = history(t);
const ttc = {
id: 'ttc',
historyRetention: true,
defaultMode: 'bus',
routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'),
vehicleMode: (id) =>
/^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown',
};
h.ingest(
ttc,
[
row(100000, '8515', { routeId: '506' }),
row(100000, '4507', { routeId: null }),
row(100000, 'x1', { routeId: '501' }),
],
100000,
);
assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus');
assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram');
assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram');
});
+1 -1
View File
@@ -404,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+2
View File
@@ -24,6 +24,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
@@ -118,6 +119,7 @@ export function createApplicationCatalog({
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
+2 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 31);
assert.equal(first.layers.length, 32);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -154,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 31);
assert.equal(catalog.layers.length, 32);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+6
View File
@@ -0,0 +1,6 @@
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire air-quality readings to the application overlay host. */
export function createApplicationAirQuality(options) {
return createAirQualityLayer({ overlayHost, ...options });
}
+4 -2
View File
@@ -2,6 +2,7 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
@@ -12,8 +13,8 @@ import * as render from '../../renderGovernor.js';
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/**
* Mapillary, Google Street View and KartaView, once the Mapillary source is
* supplied; else none.
* Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary
* source is supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
@@ -21,6 +22,7 @@ function defaultProviders(sources) {
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
createPanoramaxProvider(),
];
}
+21 -3
View File
@@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it
);
});
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
@@ -37,7 +37,12 @@ test('the defaults are Mapillary (given its source), Street View and KartaView,
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
assert.deepEqual(layer.providerIds, [
'mapillary',
'google',
'kartaview',
'panoramax',
]);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
@@ -45,12 +50,25 @@ test('the defaults are Mapillary (given its source), Street View and KartaView,
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
assert.equal(
layer.getParams().providerSwitches,
'google-1*kartaview-0*panoramax-0',
);
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// As is Panoramax: lighting it puts KartaView out.
layer.setParams({ providerSwitches: 'panoramax-1' });
assert.equal(layer.getParams().panoramax, true);
assert.equal(layer.getParams().kartaview, false);
assert.equal(layer.getParams().mapillary, false);
// The form the app writes (the README and CHANGELOG quote it).
assert.equal(
layer.getParams().providerSwitches,
'google-0*kartaview-0*panoramax-1',
);
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+1
View File
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 31);
assert.equal(actual.layers.length, 32);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { directionToHeading } from './directionText.js';
test('conflicting facing directions cannot manufacture one heading', () => {
for (const bare of [false, true]) {
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
assert.equal(directionToHeading('NB northbound', bare), 0);
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
}
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
assert.equal(directionToHeading('West / SR-24', true), 270);
});
test('travel tokens retain precedence over quadrant streets and bare words', () => {
for (const [text, heading] of [
['I-5 NB at NE 45th St', 0],
['SR 520 EB at NE 148th', 90],
['US 13 SB @ SW Front St', 180],
]) {
for (const bare of [false, true])
assert.equal(directionToHeading(text, bare), heading);
}
assert.equal(
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
270,
);
assert.equal(directionToHeading('NE near West St', true), 45);
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
});
+683
View File
@@ -0,0 +1,683 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
import {
parseCataloniaXml,
normalizeCataloniaImageUrl,
cataloniaCameraName,
loadCataloniaSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<?xml'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One `<gml:featureMember>` block matching the real feed's shape. */
function cataloniaFeature({
fid,
lon,
lat,
carretera = '',
municipi = '',
pk = '',
link = '',
font = '',
}) {
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
return (
`<gml:featureMember><cite:cameres fid="${fid}">` +
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
`</gml:Point></cite:geom>` +
`<cite:carretera>${carretera}</cite:carretera>` +
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
`<cite:link>${link}</cite:link>` +
`<cite:font>${font}</cite:font>` +
`</cite:cameres></gml:featureMember>`
);
}
function cataloniaFeed(features) {
return (
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
features.join('') +
'</wfs:FeatureCollection>'
);
}
test('parseCataloniaXml extracts every field from a feature block', () => {
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
const rows = parseCataloniaXml(xml);
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
lon: 2.1849528,
lat: 41.45989301,
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
});
});
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
// RenderService always 302s to a hardcoded http:// Location (which
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
// setup fails the handshake with Node's fetch regardless — TransitCamera
// over plain http is the only transport that actually works here, so we
// build that URL from RenderService's sctidcam id instead of registering
// the redirector.
assert.equal(
normalizeCataloniaImageUrl(
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
);
assert.equal(
normalizeCataloniaImageUrl(
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
'an already-https RenderService link is rewritten the same way',
);
assert.equal(
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
'',
'a RenderService link with no sctidcam id is rejected',
);
});
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
assert.equal(
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
'https://www.bcn.cat/transit/imatges/a.gif',
);
assert.equal(
normalizeCataloniaImageUrl(
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
),
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
);
assert.equal(
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
'',
'a look-alike host is rejected',
);
assert.equal(
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
'',
'any host outside the allowlist is rejected',
);
assert.equal(normalizeCataloniaImageUrl(''), '');
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
});
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
// The hostname matches the allowlist, so only an explicit userinfo check
// stops catalog data injecting credentials into the frame request.
for (const link of [
'https://user:[email protected]/transit/imatges/a.gif',
'http://[email protected]/transit/imatges/a.gif',
'https://:[email protected]/it_terrassa/cam02.jpeg',
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
]) {
assert.equal(normalizeCataloniaImageUrl(link), '', link);
}
});
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
// An allowlisted hostname alone would let catalog data aim the frame
// request at another service or path on that host.
for (const link of [
'http://www.bcn.cat:8080/transit/imatges/a.gif',
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
'http://www.bcn.cat/admin/a.gif',
'http://www.bcn.cat/transit/imatges/',
'http://www.bcn.cat/transit/imatges/sub/a.gif',
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
'https://emap.terrassa.cat/other/cam02.jpeg',
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
]) {
assert.equal(normalizeCataloniaImageUrl(link), '', link);
}
});
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
for (const camId of [
'../../etc/passwd',
'..%2F..%2Fetc%2Fpasswd',
'nc87.png',
'nc87',
'nc87.gif/../x.gif',
'nc87.gif&nom=other.gif',
'nc-87.gif',
]) {
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
}
assert.equal(
normalizeCataloniaImageUrl(
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
),
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
);
});
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
assert.equal(decodeNumericEntities('Llu&#237;s'), 'Lluís');
assert.equal(decodeNumericEntities('Llu&#xED;s &#x1F6A6;'), 'Lluís 🚦');
assert.equal(decodeNumericEntities('Llu&#XeD;s'), 'Lluís');
for (const raw of [
'a&#99999999999999999999999;b',
'a&#1114112;b',
'a&#x110000;b',
'a&#xFFFFFFFFFFFFFFFFFFFF;b',
'a&#55296;b',
'a&#xDFFF;b',
'a&#0;b',
]) {
assert.equal(decodeNumericEntities(raw), raw, raw);
}
});
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
assert.equal(
cataloniaCameraName('C-58', 'Nus Trinitat'),
'C-58 (Nus Trinitat)',
);
assert.equal(
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
'Plaça Urquinaona (Barcelona)',
);
assert.equal(
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
'Barcelona Ronda Litoral',
);
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
});
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
municipi: 'Nus Trinitat',
pk: '0.50',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-2',
lon: '2.17228212',
lat: '41.38890733',
carretera: 'Plaça Urquinaona',
municipi: 'Barcelona',
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: 'cameres.fid-3',
lon: '2.0111',
lat: '41.5636',
carretera: 'Cam02',
municipi: 'Terrassa',
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
font: 'Terrassa',
}),
// Rejected: link host not in the allowlist.
cataloniaFeature({
fid: 'cameres.fid-4',
lon: '2.0',
lat: '41.5',
link: 'https://example.com/fake.gif',
font: 'SCT',
}),
// Rejected: outside the Catalonia bounding box.
cataloniaFeature({
fid: 'cameres.fid-5',
lon: '-3.7',
lat: '40.4',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
font: 'SCT',
}),
]);
return new Response(xml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
const cameras = await loadCataloniaSourcesFromOpenData();
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
assert.ok(
CATALONIA_CAMERAS_URL.startsWith('https://'),
'the catalog is fetched over https',
);
assert.deepEqual(
cameras.map((camera) => camera.id).sort(),
[
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
'cat-sct-nc87',
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
],
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
);
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
assert.equal(sct.provider, 'Servei Català de Trànsit');
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
assert.equal(
sct.url,
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
);
assert.equal(sct.groundElevationM, 80);
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
const bcn = cameras.find(
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
);
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
assert.equal(bcn.city, 'Barcelona');
assert.equal(bcn.groundElevationM, 20);
const terrassa = cameras.find(
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
);
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
assert.equal(terrassa.groundElevationM, 270);
});
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
}
});
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered or
// downgraded. Its body is a live stream, so the test also proves the
// refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// An HTTP error body is released too.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const validFeed = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
// A body that declares a size over the cap is refused rather than buffered,
// even though its actual content would parse.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(validFeed, {
headers: {
'Content-Type': 'application/xml',
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
// So is a body with no declared size that only turns out too long while
// streaming — a valid feed padded past the cap.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized =
validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/xml' } },
);
});
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
});
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_CATALONIA_ENABLED: undefined,
CCTV_CATALONIA_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== CATALONIA_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
const xml = cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-1',
lon: '2.1849528',
lat: '41.45989301',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]);
return new Response(xml, { status: 200 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['cat-sct-nc87'],
);
process.env.CCTV_CATALONIA_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
* under one upstream run's `fid` prefix. */
function snapshotFeed(fidPrefix) {
return cataloniaFeed([
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_386f`,
lon: '2.1849528',
lat: '41.45989301',
carretera: 'C-58',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_b48`,
lon: '2.18',
lat: '41.46',
carretera: 'C-31',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_1c2`,
lon: '2.17228212',
lat: '41.38890733',
carretera: 'Plaça Urquinaona',
municipi: 'Barcelona',
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: `cameres.fid-${fidPrefix}_1c3`,
lon: '2.0111',
lat: '41.5636',
municipi: 'Terrassa',
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 &#13;',
font: 'Terrassa',
}),
]);
}
async function loadFromFeed(t, xml) {
t.mock.method(globalThis, 'fetch', async () => {
return new Response(xml, {
status: 200,
headers: { 'Content-Type': 'application/xml' },
});
});
try {
return await loadCataloniaSourcesFromOpenData();
} finally {
t.mock.restoreAll();
}
}
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
// The fid embeds the upstream server-start stamp: the PR fixture's
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
assert.deepEqual(ids(before), [
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
'cat-sct-nc85',
'cat-sct-nc87',
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
]);
assert.deepEqual(ids(after), ids(before));
});
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
// different fids.
const row = (fid, camId) =>
cataloniaFeature({
fid,
lon: '2.18',
lat: '41.46',
carretera: 'C-31',
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
font: 'SCT',
});
const cameras = await loadFromFeed(
t,
cataloniaFeed([
row('cameres.fid-x_1', 'nc85.gif'),
row('cameres.fid-x_2', 'cb5.gif'),
row('cameres.fid-x_3', 'nc85.gif'),
row('cameres.fid-x_4', 'cb5.gif'),
row('cameres.fid-x_5', 'cb10.gif'),
]),
);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'cat-sct-cb10',
'cat-sct-cb5',
'cat-sct-nc85',
]);
});
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const cameras = await loadFromFeed(
t,
cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-x_1',
lon: '2.17',
lat: '41.39',
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
font: 'IMI',
}),
cataloniaFeature({
fid: 'cameres.fid-x_2',
lon: '2.0111',
lat: '41.5636',
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
font: 'Terrassa',
}),
cataloniaFeature({
fid: 'cameres.fid-x_3',
lon: '2.18',
lat: '41.46',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-x_4',
lon: '2.18',
lat: '41.46',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]),
);
assert.deepEqual(
cameras.map((camera) => [camera.id, camera.url]),
[
[
'cat-sct-nc87',
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
],
],
);
});
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
quiet(t);
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
const cameras = await loadFromFeed(
t,
cataloniaFeed([
cataloniaFeature({
fid: 'cameres.fid-x_1',
lon: '2.18',
lat: '41.46',
carretera: 'C-31 &#99999999999999999999;',
municipi: 'Bad&#xD800;',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
font: 'SCT',
}),
cataloniaFeature({
fid: 'cameres.fid-x_2',
lon: '2.18',
lat: '41.46',
carretera: 'Pla&#xE7;a Catalunya',
municipi: 'Llinars del Vall&#232;s',
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
font: 'SCT',
}),
]),
);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
assert.equal(cameras.length, 2);
assert.equal(
byId.get('cat-sct-nc85').name,
'C-31 &#99999999999999999999; (Bad&#xD800;)',
'an invalid reference is kept as written',
);
assert.equal(
byId.get('cat-sct-nc87').name,
'Plaça Catalunya (Llinars del Vallès)',
);
});
+3 -1
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
DEFAULT_CATALONIA_MAX_SOURCES,
DRIVEBC_WEBCAMS_URL,
} from '../../server/providers/cctv/constants.js';
import {
@@ -221,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
);
});
test('default per-pack camera caps fit inside the default catalog cap', () => {
test('default caps of the original packs fit inside the default catalog cap', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
@@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => {
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES +
1;
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
+367
View File
@@ -0,0 +1,367 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
DEFAULT_KING_COUNTY_MAX_SOURCES,
KING_COUNTY_CAMERAS_URL,
KING_COUNTY_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js';
import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** A FeatureServer GeoJSON feature carrying the fields the loader reads. */
function kingCountyFeature(id, lat, lon, overrides = {}) {
const { properties = {}, geometry } = overrides;
return {
type: 'Feature',
geometry:
geometry === undefined
? { type: 'Point', coordinates: [lon, lat] }
: geometry,
properties: {
AssetID: id,
Location: `Road ${id} at Test Ave`,
ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`,
Live: 1,
Model: '3950',
Manufacturer: 'Cohu',
Owner: 'King County',
CurrentStatus: 'Active',
...properties,
},
};
}
test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => {
quiet(t);
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
type: 'FeatureCollection',
features: [
kingCountyFeature(56, 47.70059, -122.02443, {
properties: {
Location: 'NE Novelty Hill Road at Trilogy Parkway NE',
// The live layer carries stray whitespace in model strings.
Model: 'Q6135-LE\n',
Manufacturer: 'Axis',
},
}),
// City-owned camera the county hosts: kept, owner becomes the credit.
kingCountyFeature(90, 47.674, -122.119, {
properties: { Owner: 'City of Newcastle', Model: null },
}),
// WSDOT-owned row: its frames are WSDOT-hosted, so it is skipped.
kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }),
// Off-host frame, not live, retired, bad id, bad geometry, out of county.
kingCountyFeature(71, 47.6, -122.3, {
properties: { ImageURL: 'https://example.com/cam71.jpg' },
}),
kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }),
kingCountyFeature(73, 47.6, -122.3, {
properties: { CurrentStatus: 'Retired' },
}),
kingCountyFeature('74', 47.6, -122.3),
kingCountyFeature(75, 47.6, -122.3, { geometry: null }),
kingCountyFeature(76, 34.05, -118.24),
],
});
});
const cameras = await loadKingCountySourcesFromOpenData();
assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'kingcounty-56',
'kingcounty-90',
]);
const novelty = cameras.find((camera) => camera.id === 'kingcounty-56');
assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE');
assert.equal(novelty.provider, 'King County Road Services');
assert.equal(
novelty.url,
'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg',
);
assert.equal(novelty.snapshotUrl, novelty.url);
assert.equal(novelty.feedType, 'image');
// Published hardware model rides along, whitespace-collapsed.
assert.equal(novelty.model, 'Q6135-LE');
assert.equal(novelty.manufacturer, 'Axis');
assert.equal(novelty.credit, '');
// No facing in the layer: id-hash fallback at low confidence.
assert.equal(novelty.headingConfidence, 'low');
assert.equal(novelty.fovDeg, 44);
assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360);
const newcastle = cameras.find((camera) => camera.id === 'kingcounty-90');
assert.equal(newcastle.credit, 'City of Newcastle');
assert.equal(newcastle.model, undefined);
// A manufacturer without a model identifies nothing, so it is dropped too.
assert.equal(newcastle.manufacturer, undefined);
});
test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => {
const withModel = normalizeSourceItem({
id: 'kingcounty-56',
lat: 47.7,
lon: -122.0,
model: ' Q6135-LE ',
manufacturer: ' Axis ',
});
assert.equal(withModel.model, 'Q6135-LE');
assert.equal(withModel.manufacturer, 'Axis');
const withoutModel = normalizeSourceItem({
id: 'austin-1',
lat: 30.3,
lon: -97.7,
});
assert.equal(withoutModel.model, undefined);
assert.equal(withoutModel.manufacturer, undefined);
assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined);
});
/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
/** The browser catalog with the pose math stubbed out: only field
* passthrough is under test here. */
function browserCatalog() {
const model = {
safeNumber: (value, fallback = NaN) =>
Number.isFinite(Number(value)) ? Number(value) : fallback,
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
headingFromId: () => 0,
normalizeFeedType: (type) => type,
ensureCameraPose: () => {},
};
return createCatalog({
state: {},
services: { locations: { CITY_POIS: {} } },
parts: { model },
source: {},
});
}
test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
// No file/env catalog, so the live packs load; every pack but King County
// sees an upstream failure and contributes nothing.
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_KINGCOUNTY_ENABLED: undefined,
CCTV_KINGCOUNTY_MAX_SOURCES: undefined,
CCTV_MAX_SOURCES: undefined,
});
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) !== KING_COUNTY_CAMERAS_URL)
return new Response('', { status: 503 });
return Response.json({
type: 'FeatureCollection',
features: [
kingCountyFeature(56, 47.70059, -122.02443, {
properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' },
}),
kingCountyFeature(90, 47.674, -122.119, {
properties: { Model: null, Manufacturer: 'Cohu' },
}),
],
});
});
const body = await requestSources(cctvProxy({ sourceRoot: root }));
const served = new Map(body.sources.map((source) => [source.id, source]));
assert.deepEqual([...served.keys()].sort(), [
'kingcounty-56',
'kingcounty-90',
]);
assert.equal(served.get('kingcounty-56').model, 'Q6135-LE');
assert.equal(served.get('kingcounty-56').manufacturer, 'Axis');
// Absent hardware is an explicit empty string on the wire, like
// headingConfidence, so consumers never have to probe for the key.
assert.equal(served.get('kingcounty-90').model, '');
assert.equal(served.get('kingcounty-90').manufacturer, '');
const cameras = browserCatalog().buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE');
assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis');
assert.equal(byId.get('kingcounty-90').model, '');
assert.equal(byId.get('kingcounty-90').manufacturer, '');
});
test('King County loader honors the max-sources cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
const features = [];
for (let i = 0; i < 24; i += 1) {
features.push(
kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02),
);
}
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ type: 'FeatureCollection', features }),
);
const cameras = await loadKingCountySourcesFromOpenData();
assert.equal(cameras.length, 8);
});
test('King County loader defaults its cap and survives upstream failure', async (t) => {
quiet(t);
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125);
t.mock.method(
globalThis,
'fetch',
async () => new Response('', { status: 503 }),
);
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
});
test('King County loader does not follow a redirected catalog', async (t) => {
quiet(t);
let init;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
init = options;
return new Response(
JSON.stringify({
type: 'FeatureCollection',
features: [kingCountyFeature(56, 47.70059, -122.02443)],
}),
{ status: 302, headers: { Location: 'https://example.com/cams' } },
);
});
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
assert.equal(init.redirect, 'manual');
});
test('King County loader rejects an oversized catalog', async (t) => {
quiet(t);
const features = [kingCountyFeature(56, 47.70059, -122.02443)];
const body = JSON.stringify({
type: 'FeatureCollection',
features,
padding: 'x'.repeat(KING_COUNTY_MAX_CATALOG_BYTES),
});
t.mock.method(globalThis, 'fetch', async () => new Response(body));
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
});
test('King County loader drops duplicate ids before the per-pack cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
// Repeats of the camera nearest the anchor would fill an 8-camera cap
// before the second, farther camera if duplicates reached prioritization.
const features = [];
for (let i = 0; i < 10; i += 1) {
features.push(kingCountyFeature(56, 47.4829, -122.2171));
}
features.push(kingCountyFeature(57, 47.70059, -122.02443));
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ type: 'FeatureCollection', features }),
);
const cameras = await loadKingCountySourcesFromOpenData();
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'kingcounty-56',
'kingcounty-57',
]);
});
test('King County loader omits vendor-generic model strings', async (t) => {
quiet(t);
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
type: 'FeatureCollection',
features: [
kingCountyFeature(61, 47.5, -122.2, {
properties: { Model: 'PTZ', Manufacturer: 'Bosch' },
}),
kingCountyFeature(62, 47.51, -122.2, {
properties: { Model: 'PTZ IP', Manufacturer: 'Cohu' },
}),
kingCountyFeature(63, 47.52, -122.2, {
properties: { Model: 'BOSCH/PTZ', Manufacturer: 'Bosch' },
}),
kingCountyFeature(64, 47.53, -122.2, {
properties: { Model: 'AUTODOME IP 5000', Manufacturer: 'Bosch' },
}),
],
}),
);
const byId = new Map(
(await loadKingCountySourcesFromOpenData()).map((camera) => [
camera.id,
camera,
]),
);
for (const id of ['kingcounty-61', 'kingcounty-62', 'kingcounty-63']) {
assert.equal(byId.get(id).model, undefined, id);
assert.equal(byId.get(id).manufacturer, undefined, id);
}
assert.equal(byId.get('kingcounty-64').model, 'AUTODOME IP 5000');
assert.equal(byId.get('kingcounty-64').manufacturer, 'Bosch');
});
+198
View File
@@ -0,0 +1,198 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
lithuaniaCameraToSource,
loadLithuaniaSources,
LITHUANIA_CAMERAS_URL,
LITHUANIA_LOCATIONS_URL,
LITHUANIA_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/lithuania.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const row = {
id: 72,
name: 'Vilnius A1 10,04',
x: 576154,
y: 6056867,
image:
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
};
const feature = {
id: '72',
points: [{ point: [54.642550940833566, 25.17981587877114] }],
};
const layers = [{ layer: 'VKR', features: [feature] }];
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
const camera = lithuaniaCameraToSource(row, feature);
assert.equal(camera.id, 'lithuania-72');
assert.equal(camera.lat, 54.642550940833566);
assert.equal(camera.lon, 25.17981587877114);
assert.equal(camera.name, 'Vilnius A1 10,04');
assert.equal(camera.feedType, 'image');
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.url, row.image);
assert.match(camera.credit, /Via Lietuva/);
});
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
for (const invalid of [
null,
{ ...feature, id: '8' },
{ ...feature, points: [] },
{ ...feature, points: [{ point: [25.18, 54.64] }] },
{ ...feature, points: [{ point: [576154, 6056867] }] },
{ ...feature, points: [{ point: [null, 25] }] },
]) {
assert.equal(lithuaniaCameraToSource(row, invalid), null);
}
for (const image of [
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
row.image.replace('https://', 'https://user:pass@'),
row.image.replace('id=72', 'id=8'),
'http://127.0.0.1/private',
'',
null,
]) {
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
}
assert.equal(
lithuaniaCameraToSource(
{ ...row, image: row.image + '&extra=ignored' },
feature,
).url,
row.image,
);
assert.equal(lithuaniaCameraToSource(null, feature), null);
});
function mockCatalogs(t) {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
requested.push(String(url));
if (url === LITHUANIA_CAMERAS_URL) {
assert.equal(options.redirect, 'manual');
return Response.json([row, row, { ...row, id: 999 }]);
}
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
return Response.json([]);
});
return requested;
}
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
const requested = mockCatalogs(t);
assert.deepEqual(
(await loadLithuaniaSources()).map((c) => c.id),
['lithuania-72'],
);
assert.deepEqual(
requested.sort(),
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
);
});
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
* (undefined leaves it unset), restoring the environment afterwards. */
async function runCatalogWithLithuaniaFlag(t, flag) {
const requested = mockCatalogs(t);
const saved = process.env.CCTV_LITHUANIA_ENABLED;
try {
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
} finally {
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = saved;
}
}
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
assert.equal(
sources.find((c) => c.id === 'lithuania-72')?.cityId,
'lithuania',
);
});
// The camera endpoints are the eismoinfo.lt website backend, not a service the
// open-data statement lists, so an install that has not opted in must never
// contact them.
for (const [label, flag] of [
['an unset CCTV_LITHUANIA_ENABLED', undefined],
['an empty CCTV_LITHUANIA_ENABLED', ''],
['CCTV_LITHUANIA_ENABLED=0', '0'],
['CCTV_LITHUANIA_ENABLED=true', 'true'],
]) {
test(`${label} makes no Lithuania request`, async (t) => {
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('eismoinfo.lt')),
false,
'the pack never reaches its upstream',
);
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
});
}
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
t.mock.method(console, 'warn', () => {});
let siblingSignal;
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url === LITHUANIA_CAMERAS_URL) {
return new Response('', { status: 503 });
}
siblingSignal = options.signal;
// Never settles on its own; only an abort ends it.
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () =>
reject(options.signal.reason),
);
});
});
assert.deepEqual(await loadLithuaniaSources(), []);
assert.equal(siblingSignal?.aborted, true);
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
});
test('a redirected catalog is reported as not followed', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
t.mock.method(
globalThis,
'fetch',
async () =>
new Response('', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
);
assert.deepEqual(await loadLithuaniaSources(), []);
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
});
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const makeResponse of [
() => new Response('', { status: 503 }),
() =>
new Response('', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
() => new Response('invalid json'),
() => Response.json({ unexpected: true }),
() =>
new Response('[]', {
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
}),
]) {
t.mock.method(globalThis, 'fetch', async () => makeResponse());
assert.deepEqual(await loadLithuaniaSources(), []);
}
});
+4
View File
@@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
fintraffic: 10 * 60 * 1000,
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
// matches the other highway packs' 3-minute cadence.
'servei català de trànsit': 3 * 60 * 1000,
});
/**
+4
View File
@@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
assert.equal(
staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }),
180_000,
);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
+270
View File
@@ -0,0 +1,270 @@
// /api/cctv/media is the live-feed relay. A still camera used to be relayed
// through it as well, by an ordinary redirect-following fetch with no content
// type check and no body cap, which side-stepped everything /api/cctv/frame
// enforces for the same URL. These pin that a still is refused there and only
// reachable through /frame, while live feeds keep their relay.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
const STILL = {
id: 'still-fixture',
name: 'Still fixture',
lat: 30.27,
lon: -97.74,
feedType: 'image',
url: 'https://cams.upstream.invalid/still.jpg',
};
// 'jpg' normalizes to 'image'; the alias must not reopen the relay.
const STILL_ALIAS = {
...STILL,
id: 'still-alias',
feedType: 'jpg',
url: 'https://cams.upstream.invalid/alias.jpg',
};
const VIDEO = {
id: 'video-fixture',
name: 'Video fixture',
lat: 30.27,
lon: -97.74,
feedType: 'video',
url: 'https://cams.upstream.invalid/live.mp4',
};
const MJPEG = {
id: 'mjpeg-fixture',
name: 'MJPEG fixture',
lat: 30.27,
lon: -97.74,
feedType: 'mjpeg',
url: 'https://cams.upstream.invalid/live.mjpg',
};
/** A minimal upstream Response with a streaming body. */
function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) {
const store = new Map(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
body: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
};
}
/** Collects what a handler wrote, the way a Node ServerResponse would. */
function recordingResponse() {
const chunks = [];
let finished;
const done = new Promise((resolve) => {
finished = resolve;
});
const res = {
statusCode: 0,
headers: {},
writableEnded: false,
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
},
write(chunk) {
chunks.push(Buffer.from(chunk));
return true;
},
end(chunk) {
if (chunk) chunks.push(Buffer.from(chunk));
res.writableEnded = true;
res.body = Buffer.concat(chunks).toString('utf8');
finished();
},
on() {},
once() {},
emit() {},
destroy() {},
};
return { res, done };
}
/**
* Mount the real provider over the fixture cameras and answer every upstream
* request from the supplied function.
*/
function mount(t, respond) {
const before = {
json: process.env.CCTV_SOURCES_JSON,
file: process.env.CCTV_SOURCES_FILE,
austin: process.env.CCTV_FORCE_AUSTIN,
serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY,
browserKey: process.env.GOOGLE_MAPS_API_KEY,
};
process.env.CCTV_SOURCES_JSON = JSON.stringify([
STILL,
STILL_ALIAS,
VIDEO,
MJPEG,
]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
process.env.CCTV_FORCE_AUSTIN = '0';
// No Street View key: a /frame miss lands on the synthetic billboard rather
// than a network call this file does not model.
delete process.env.GOOGLE_MAPS_SERVER_API_KEY;
delete process.env.GOOGLE_MAPS_API_KEY;
const nativeFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({ url: String(url), redirect: init.redirect });
return respond({ url: String(url), init });
};
t.after(() => {
globalThis.fetch = nativeFetch;
for (const [name, value] of [
['CCTV_SOURCES_JSON', before.json],
['CCTV_SOURCES_FILE', before.file],
['CCTV_FORCE_AUSTIN', before.austin],
['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey],
['GOOGLE_MAPS_API_KEY', before.browserKey],
]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let handler = null;
cctvProxy().configureServer({
middlewares: {
use: (_route, fn) => {
handler = fn;
},
},
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
const call = async (path) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
await done;
return res;
};
const health = async (id) => {
const res = await call('/health');
return JSON.parse(res.body).cameras.find((entry) => entry.id === id);
};
return { call, health, requests };
}
test('a still camera is refused by /media without any upstream request', async (t) => {
// Everything the old relay would have done with this camera: redirect to a
// private address, then answer with HTML.
const app = mount(t, ({ url }) =>
url.includes('169.254.169.254')
? upstreamResponse({
headers: { 'content-type': 'text/html' },
body: '<html>metadata</html>',
})
: upstreamResponse({
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
body: '',
}),
);
for (const camera of [STILL, STILL_ALIAS]) {
const res = await app.call(`/media/${camera.id}`);
assert.equal(res.statusCode, 404, camera.id);
assert.equal(res.headers['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(res.body), {
error: 'No live media for this camera; use the frame endpoint',
});
assert.doesNotMatch(res.body, /metadata/);
// Being asked on the wrong route is not a camera fault.
assert.equal(await app.health(camera.id), undefined);
}
assert.deepEqual(app.requests, [], 'a still camera reached its upstream');
});
test('an unregistered camera id is refused by /media the same way', async (t) => {
const app = mount(t, () => {
throw new Error('an unknown camera must not fetch');
});
const res = await app.call('/media/not-a-camera');
assert.equal(res.statusCode, 404);
assert.equal(app.requests.length, 0);
});
test('a video camera is still relayed by /media', async (t) => {
const app = mount(t, () =>
upstreamResponse({
headers: { 'content-type': 'video/mp4', 'content-length': '5' },
body: 'video',
}),
);
const res = await app.call(`/media/${VIDEO.id}`);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'video/mp4');
assert.equal(res.headers['X-CCTV-Source'], 'live-media');
assert.equal(res.body, 'video');
assert.deepEqual(
app.requests.map(({ url }) => url),
[VIDEO.url],
);
assert.equal((await app.health(VIDEO.id)).status, 'ok');
});
test('an MJPEG camera is still relayed by /media', async (t) => {
const app = mount(t, () =>
upstreamResponse({
headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' },
body: '--frame',
}),
);
const res = await app.call(`/media/${MJPEG.id}`);
assert.equal(res.statusCode, 200);
assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/);
assert.equal(res.body, '--frame');
assert.deepEqual(
app.requests.map(({ url }) => url),
[MJPEG.url],
);
});
test('a still camera is still served through /frame with its bounded fetch', async (t) => {
const app = mount(t, () =>
upstreamResponse({
headers: { 'content-type': 'image/jpeg' },
body: 'jpeg-bytes',
}),
);
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'image/jpeg');
assert.equal(res.headers['X-CCTV-Source'], 'upstream-image');
assert.equal(res.body, 'jpeg-bytes');
assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]);
});
test('/frame does not follow a still camera off its origin', async (t) => {
// The protection /media used to bypass, shown on the route that keeps it.
const app = mount(t, ({ url }) =>
url === STILL.url
? upstreamResponse({
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
body: '',
})
: upstreamResponse({
headers: { 'content-type': 'image/jpeg' },
body: 'private',
}),
);
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['X-CCTV-Source'], 'synthetic');
assert.doesNotMatch(res.body, /private/);
assert.deepEqual(
app.requests.map(({ url }) => url),
[STILL.url],
);
});
+423
View File
@@ -0,0 +1,423 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseRescuCameraList,
torontoCameraToSource,
loadTorontoSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('j'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */
const row = (overrides = {}) => ({
Number: '8048',
Name: 'BATHURST ST & BLOOR ST W',
Latitude: '43.665249',
Longitude: '-79.41114',
D1: 'n',
D2: 'e',
D3: 's',
D4: 'w',
Group: 'Arterial',
...overrides,
});
/** The city wraps the list in a JSONP callback; this is that envelope. */
const jsonp = (rows) =>
`jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`;
test('a RESCU row maps to a source on the constructed frame URL', () => {
const source = torontoCameraToSource(row());
assert.equal(source.id, 'toronto-8048');
assert.equal(source.name, 'BATHURST ST & BLOOR ST W');
assert.equal(source.city, 'Toronto');
assert.equal(source.cityId, 'toronto');
assert.equal(source.provider, 'City of Toronto');
assert.equal(source.lat, 43.665249);
assert.equal(source.lon, -79.41114);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'toronto-rescu-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – Toronto',
);
assert.equal(
source.url,
`${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`,
'the documented loc####.jpg convention',
);
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN));
});
test('the frame URL is built from the camera number, never read from the payload', () => {
// The list carries no URL field at all, so an added one must stay inert: the
// frame proxy is pinned to the city origin by construction.
const planted = torontoCameraToSource(
row({
url: 'https://evil.example/x.jpg',
snapshotUrl: 'https://evil.example/x.jpg',
Url: 'https://evil.example/x.jpg',
}),
);
assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`);
assert.equal(planted.snapshotUrl, planted.url);
// A number that is not plain digits cannot reach the URL builder: path
// traversal and absolute URLs are refused outright rather than escaped.
for (const Number_ of [
'../../etc/passwd',
'8048/../../x',
'https://evil.example/1',
'80 48',
'8048.jpg',
'-8048',
'',
' ',
null,
]) {
assert.equal(
torontoCameraToSource(row({ Number: Number_ })),
null,
`refused: ${String(Number_)}`,
);
}
});
test('the comparison directions never become a camera heading', () => {
// D1-D4 are the city's *comparison image* directions (static 2016 reference
// stills served from ComparisonImages/), not a facing for the live frame:
// these cameras pan. The letters read like a compass and map trivially onto
// one (n -> 0, w -> 270), so the temptation to wire them up is real — and a
// heading derived from them would be confidently wrong for every camera in
// the city. Nobody may "fix" this.
const base = torontoCameraToSource(row());
for (const dirs of [
{ D1: 'n', D2: 'e', D3: 's', D4: 'w' },
{ D1: '', D2: 'e', D3: '', D4: 'w' },
{ D1: '', D2: '', D3: '', D4: '' },
{ D1: 's', D2: '', D3: '', D4: '' },
]) {
const source = torontoCameraToSource(row(dirs));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the directions say.
assert.equal(source.headingDeg, base.headingDeg);
}
});
test('the two cameras at one intersection are kept apart', () => {
// 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They
// are distinct units, so both survive, and the id-hash fallback fans their
// gizmos apart instead of stacking them on one bearing.
const a = torontoCameraToSource(
row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }),
);
const b = torontoCameraToSource(row());
assert.equal(a.id, 'toronto-8047');
assert.equal(b.id, 'toronto-8048');
assert.notEqual(a.url, b.url);
assert.notEqual(a.headingDeg, b.headingDeg);
});
test('rows outside Toronto and unusable geometry are dropped', () => {
// Calgary: a plausible lat/lon, but not a Toronto camera.
assert.equal(
torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })),
null,
);
// Null island, and the empty strings that would become it.
assert.equal(
torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })),
null,
);
assert.equal(
torontoCameraToSource(row({ Latitude: '', Longitude: '' })),
null,
);
assert.equal(
torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })),
null,
);
assert.equal(torontoCameraToSource({}), null);
assert.equal(torontoCameraToSource(null), null);
});
test('a nameless row still gets a label', () => {
const source = torontoCameraToSource(row({ Name: ' ' }));
assert.equal(source.name, 'Toronto Camera 8048');
});
test('the JSONP envelope is unwrapped, and only that envelope', () => {
assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]);
// Whitespace and a missing trailing semicolon are both tolerated.
assert.equal(
parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length,
1,
);
// Should the city ever drop the wrapper, the bare document still parses.
assert.equal(
parseRescuCameraList(JSON.stringify({ Data: [row()] })).length,
1,
);
// Anything else yields no cameras rather than a thrown loader.
for (const text of [
'otherCallback({"Data":[]})',
'jsonTMCEarthCamerasCallback(',
'jsonTMCEarthCamerasCallback({"Data":',
'{"Data":"not-an-array"}',
'{"nope":[]}',
'<html>503</html>',
'',
null,
]) {
assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`);
}
});
test('the loader reads the keyless list and collapses duplicate numbers', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return new Response(
jsonp([
row(),
// Same camera number twice: one camera, not two.
row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }),
row({
Number: '8001',
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
Latitude: '43.643120',
Longitude: '-79.381386',
}),
row({ Number: '../evil' }),
]),
);
});
const cameras = await loadTorontoSourcesFromOpenData();
assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor
// is three kilometres out.
['toronto-8001', 'toronto-8048'],
);
assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
});
test('the list fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
assert.deepEqual(seen, [[TORONTO_RESCU_LIST_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(jsonp([row()]), {
headers: {
'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(TORONTO_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
);
});
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W');
const long = torontoCameraToSource(
row({
Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)',
}),
);
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY'));
});
test('a reduced catalog cap thins every pack instead of dropping Toronto', () => {
// Toronto merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('calgary', 215), lane('toronto', 336)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['calgary', 10],
['toronto', 10],
],
);
// Toronto contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `toronto-${i}`),
);
});
/**
* Serve the RESCU list to the Toronto endpoint and an empty payload to every
* other pack, so one catalog refresh exercises the registration without
* reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === TORONTO_RESCU_LIST_URL) {
return new Response(
jsonp([
row({
Number: '8001',
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
Latitude: '43.643120',
Longitude: '-79.381386',
}),
row(),
]),
);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the pack is registered, so its cameras reach the served catalog', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_TORONTO_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(TORONTO_RESCU_LIST_URL),
'the registered lane reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'toronto').map((s) => s.id),
['toronto-8001', 'toronto-8048'],
'Toronto cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_TORONTO_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(TORONTO_RESCU_LIST_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'toronto'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
+302
View File
@@ -0,0 +1,302 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_WSDOT_MAX_SOURCES,
WSDOT_CAMERAS_URL,
WSDOT_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
loadWsdotSourcesFromOpenData,
webMercatorToWgs84,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */
function mercator(lat, lon) {
const R = 6378137;
return {
x: (lon * Math.PI * R) / 180,
y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)),
};
}
/** A `Cameras.json` point feature carrying the fields the loader reads. */
function wsdotFeature(id, lat, lon, overrides = {}) {
const { attributes = {}, geometry } = overrides;
return {
attributes: {
CameraID: id,
CameraTitle: `Camera ${id}`,
CompassDirection: 'N',
ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`,
...attributes,
},
geometry: geometry === undefined ? mercator(lat, lon) : geometry,
};
}
/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */
function cp1252Response(payload) {
const text = JSON.stringify(payload);
const bytes = new Uint8Array(text.length);
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
// U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships.
bytes[i] = code === 0x2013 ? 0x96 : code;
}
return new Response(bytes, {
headers: { 'Content-Type': 'application/json' },
});
}
test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => {
// Real feature from the live layer: I-5 at the Interstate Bridge.
const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545);
assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`);
assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`);
});
test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => {
quiet(t);
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return cp1252Response({
features: [
wsdotFeature(1001, 47.6097, -122.3331, {
attributes: {
// The 0x2013 below is serialized as the feed's 0x96 byte.
CameraTitle: 'I-5 – Seattle, looking south',
CompassDirection: 'S',
},
}),
wsdotFeature(9059, 47.66, -117.43, {
attributes: { CompassDirection: 'B' },
}),
// Partner-hosted frame (Oregon DOT): skipped, never proxied.
wsdotFeature(1002, 45.62, -122.675, {
attributes: {
ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg',
},
}),
// Bad rows: non-integer id, missing geometry, out-of-state point.
wsdotFeature('1003', 47.6, -122.3),
wsdotFeature(1004, 47.6, -122.3, { geometry: null }),
wsdotFeature(1005, 34.05, -118.24),
],
});
});
const cameras = await loadWsdotSourcesFromOpenData();
assert.deepEqual(requested, [WSDOT_CAMERAS_URL]);
// Exactly the two valid official-host cameras survive; the partner-hosted
// frame and the malformed rows are dropped.
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'wsdot-1001',
'wsdot-9059',
]);
const seattle = cameras.find((camera) => camera.id === 'wsdot-1001');
assert.equal(seattle.name, 'I-5 – Seattle, looking south');
assert.equal(seattle.provider, 'WSDOT');
assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg');
assert.equal(seattle.snapshotUrl, seattle.url);
assert.equal(seattle.feedType, 'image');
assert.equal(seattle.headingDeg, 180);
assert.equal(seattle.headingConfidence, 'high');
assert.equal(seattle.fovDeg, 56);
assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001);
assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001);
// `B` (both directions) carries no single facing: id-hash fallback at low
// confidence, with the low-confidence pose personality.
const spokane = cameras.find((camera) => camera.id === 'wsdot-9059');
assert.equal(spokane.headingConfidence, 'low');
assert.equal(spokane.fovDeg, 44);
assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360);
});
test('WSDOT loader maps every CompassDirection code', async (t) => {
quiet(t);
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
const codes = {
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
// Lower-case and padded codes are the same code.
' nw ': 315,
};
// The live feed's no-facing codes, plus an unknown one.
const noFacing = ['B', 'O', null, 'BW'];
const entries = [...Object.keys(codes), ...noFacing];
t.mock.method(globalThis, 'fetch', async () =>
cp1252Response({
features: entries.map((code, i) =>
wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, {
attributes: { CompassDirection: code },
}),
),
}),
);
const cameras = await loadWsdotSourcesFromOpenData();
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
entries.forEach((code, i) => {
const camera = byId.get(`wsdot-${3000 + i}`);
if (code in codes) {
assert.equal(camera.headingDeg, codes[code], `heading for ${code}`);
assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`);
assert.equal(camera.fovDeg, 56, `pose personality for ${code}`);
} else {
assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`);
assert.equal(camera.fovDeg, 44, `pose personality for ${code}`);
}
});
});
test('WSDOT loader honors the max-sources cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' });
const features = [];
for (let i = 0; i < 24; i += 1) {
features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05));
}
t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features }));
const cameras = await loadWsdotSourcesFromOpenData();
assert.equal(cameras.length, 8);
});
test('WSDOT loader defaults its cap and survives upstream failure', async (t) => {
quiet(t);
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250);
t.mock.method(
globalThis,
'fetch',
async () => new Response('', { status: 503 }),
);
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
});
test('the catalog fetch refuses a redirect and cancels its body', async (t) => {
quiet(t);
// A redirect is never followed, and its still-open body is cancelled.
const seen = [];
const state = { cancelled: false };
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return new Response(body, {
status: 302,
headers: { location: 'https://evil.example/Cameras.json' },
});
});
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]);
assert.equal(state.cancelled, true, 'the redirect body is cancelled');
});
test('the catalog fetch refuses a body over the byte cap', async (t) => {
quiet(t);
// A valid catalog padded past the cap is refused rather than buffered.
t.mock.method(globalThis, 'fetch', async () => {
const text = JSON.stringify({
features: [wsdotFeature(1001, 47.6097, -122.3331)],
});
const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(padded));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
});
test('CCTV_WSDOT_ENABLED=0 skips the WSDOT request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_WSDOT_ENABLED: undefined,
CCTV_WSDOT_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only WSDOT answers.
return String(url) === WSDOT_CAMERAS_URL
? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] })
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['wsdot-1001'],
);
process.env.CCTV_WSDOT_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(WSDOT_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
+38
View File
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
// NHC wrote the 03:00Z advisory at 02:34Z.
const early = Date.UTC(2026, 8, 16, 2, 34);
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
assert.throws(
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
/invalid_cyclone_data/,
);
});
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
const MINUTE = 60_000;
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
const fields = {
issuedAt: (value) =>
status({
forecastAdvisory: {
advNum: '010',
issuance: value,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
}),
positionAt: (value) => status({ lastUpdate: value }),
};
for (const [field, payload] of Object.entries(fields)) {
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
assert.equal(storm[field], at(offset), `${field} ${offset}`);
}
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
assert.throws(
() => parseCycloneStatus(payload(at(offset)), NOW),
/invalid_cyclone_data/,
`${field} ${offset}`,
);
}
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
+73 -1
View File
@@ -169,6 +169,13 @@ export const DATA_CREDITS = [
},
OSM_CREDIT,
OPENMAPTILES_CREDIT,
{
key: 'eccc-aqhi',
html:
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
},
{
key: 'overture-military-names',
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
@@ -241,12 +248,38 @@ export const DATA_CREDITS = [
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0',
},
{
key: 'lithuania-cctv',
html:
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
// among the services Via Lietuva's open-data statement lists (its
// conditions: attribution, reuse on the same terms).
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'catalonia-cctv',
html:
'CCTV cameras &amp; frames (Catalonia): ' +
'<a href="https://transit.gencat.cat/" target="_blank" rel="noopener">Servei Català de Trànsit</a> ' +
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
},
{
key: 'kingcounty-cctv',
html:
'CCTV cameras &amp; frames (King County, WA): King County Road Services — ' +
'<a href="https://kingcounty.gov/en/dept/local-services/roads" target="_blank" rel="noopener">kingcounty.gov</a>, ' +
'per the ' +
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'vegvesen-cctv',
html:
@@ -261,9 +294,38 @@ export const DATA_CREDITS = [
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
NYC_DOT_DISCLAIMER,
},
{
key: 'toronto-cctv',
html:
'Traffic cameras (Toronto): contains information licensed under the ' +
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
},
{
key: 'urbansharing-gbfs',
html:
'City bikes (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
},
{
key: 'entur-mobility-gbfs',
html:
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
},
{
key: 'jcdecaux-gbfs',
html:
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
},
{
key: 'osrm-routing',
@@ -342,6 +404,16 @@ export const DATA_CREDITS = [
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
},
{
key: 'wsdot-cctv',
html:
'CCTV cameras &amp; frames (Washington State): ' +
'<a href="https://wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> ' +
'(<a href="https://wsdot.com/travel/real-time/cameras" target="_blank" rel="noopener">Travel Center</a>), ' +
'used per the ' +
'<a href="https://wsdot.wa.gov/about/policies/travel-information-disclaimer" target="_blank" rel="noopener">WSDOT travel information disclaimer</a> ' +
'(low-volume public feed; catalog cached, frames proxied on demand)',
},
{
key: 'tallinn-cctv',
html:
+28 -16
View File
@@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) {
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
// Bare cardinals — dedicated direction fields only.
if (allowBare) {
if (/\bNORTH\b/.test(text)) return 0;
if (/\bSOUTH\b/.test(text)) return 180;
if (/\bEAST\b/.test(text)) return 90;
if (/\bWEST\b/.test(text)) return 270;
const tiers = [
[
[/\bNORTHBOUND\b|\bNB\b/, 0],
[/\bSOUTHBOUND\b|\bSB\b/, 180],
[/\bEASTBOUND\b|\bEB\b/, 90],
[/\bWESTBOUND\b|\bWB\b/, 270],
],
[
[/\bNORTHEAST\b|\bNE\b/, 45],
[/\bNORTHWEST\b|\bNW\b/, 315],
[/\bSOUTHEAST\b|\bSE\b/, 135],
[/\bSOUTHWEST\b|\bSW\b/, 225],
],
];
if (allowBare)
tiers.push([
[/\bNORTH\b/, 0],
[/\bSOUTH\b/, 180],
[/\bEAST\b/, 90],
[/\bWEST\b/, 270],
]);
for (const forms of tiers) {
const headings = new Set();
for (const [pattern, heading] of forms) {
if (pattern.test(text)) headings.add(heading);
}
if (headings.size)
return headings.size === 1 ? headings.values().next().value : NaN;
}
return NaN;
}
+36
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GBFS_CLIENT_IDENTIFIER,
GBFS_MAX_BODY_BYTES,
fetchGbfsUpstream,
} from '../../server/providers/gbfs.js';
@@ -56,6 +57,41 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
});
});
test('GBFS upstream fetch names the app in the Client-Identifier header', async () => {
let observedHeaders = null;
await fetchGbfsUpstream(
'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json',
{
fetchImpl: async (_url, options) => {
observedHeaders = options.headers;
return new Response(STATION_BODY, { status: 200 });
},
},
);
assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view');
assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
});
test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => {
const headersFor = async (url) => {
let observed = null;
await fetchGbfsUpstream(url, {
fetchImpl: async (_url, options) => {
observed = options.headers;
return new Response(STATION_BODY, { status: 200 });
},
});
return observed;
};
const entur = await headersFor(
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
);
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare');
assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
const lyft = await headersFor(STATION_URL);
assert.equal(lyft['ET-Client-Name'], undefined);
});
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(null, { status: 404 }),
+152
View File
@@ -0,0 +1,152 @@
// GBFS PROXY — the frequency the allowlist never bounded.
//
// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a
// station_information/station_status path, a 12 s timeout, a 5 MB body cap and
// no redirects. Nothing said HOW OFTEN, so the dev server would relay an
// unbounded stream of requests at a third-party bikeshare operator on any
// caller's behalf — one outbound fetch and one timeout timer each.
//
// These cases drive the real mounted middleware. `fetch` is stubbed per test
// and restored afterwards, so a case that reaches the network fails loudly,
// and the stub's call count is what proves a refused request costs nothing.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js';
const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
const STATION_BODY = '{"data":{"stations":[]}}';
/** Mount the proxy the way the dev server does; returns its route handler. */
function mount(options) {
const routes = new Map();
gbfsProxy(options).configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return routes.get('/api/gbfs');
}
function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) {
return new Promise((resolve, reject) => {
const req = {
method: method || 'GET',
// connect strips the mount prefix before the handler sees the URL.
url: '/' + encodeURIComponent(target),
headers: {},
socket: remoteAddress ? { remoteAddress } : undefined,
};
let status = 200;
let headers = {};
const res = {
writeHead(code, sent) {
status = code;
headers = sent || {};
return res;
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({ status, headers, body: parsed });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
/** Answer every allowlisted upstream, counting the calls that got through. */
function stubUpstream(t) {
const asked = [];
const original = globalThis.fetch;
globalThis.fetch = async (url) => {
asked.push(String(url));
return new Response(STATION_BODY, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
t.after(() => {
globalThis.fetch = original;
});
return asked;
}
test('a maximal real session is never throttled', async (t) => {
const asked = stubUpstream(t);
const handler = mount();
// The bikeshare layer catalogues 15 systems and refreshes each one's status
// once every 60 s, so a minute of the busiest possible session is ~15
// requests. Run eight times that and expect every one of them served.
let served = 0;
for (let i = 0; i < 120; i += 1) {
const res = await request(handler);
if (res.status === 200) served += 1;
}
assert.equal(served, 120, 'the shipped ceiling leaves a real session alone');
assert.equal(asked.length, 120);
assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on');
});
test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 5 });
const responses = [];
for (let i = 0; i < 12; i += 1) responses.push(await request(handler));
const served = responses.filter((r) => r.status === 200);
const refused = responses.filter((r) => r.status === 429);
assert.equal(served.length, 5, 'exactly the admitted budget is served');
assert.equal(refused.length, 7);
assert.equal(refused[0].body.error, 'Rate limit exceeded');
assert.equal(refused[0].headers['Retry-After'], '10');
assert.equal(refused[0].headers['Cache-Control'], 'no-store');
assert.equal(
asked.length,
5,
'a refused request never reaches the GBFS operator',
);
});
test('a different client keeps its own budget', async (t) => {
stubUpstream(t);
const handler = mount({ ratePerMinute: 3 });
for (let i = 0; i < 6; i += 1)
await request(handler, { remoteAddress: '10.0.0.1' });
const noisy = await request(handler, { remoteAddress: '10.0.0.1' });
assert.equal(noisy.status, 429, 'the noisy client spent its own budget');
const quiet = await request(handler, { remoteAddress: '10.0.0.2' });
assert.equal(quiet.status, 200, 'a quiet client is not collateral');
});
test('the limiter does not displace the existing guards', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 10 });
const wrongMethod = await request(handler, { method: 'POST' });
assert.equal(wrongMethod.status, 405);
const offAllowlist = await request(handler, {
target: 'https://example.com/gbfs/station_status.json',
});
assert.equal(offAllowlist.status, 403);
assert.equal(offAllowlist.body.error, 'GBFS host not allowed');
const wrongPath = await request(handler, {
target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json',
});
assert.equal(wrongPath.status, 400);
assert.equal(asked.length, 0, 'none of those reached upstream');
});
+58 -5
View File
@@ -4,10 +4,36 @@ const GBFS_ALLOWED_HOSTS = new Set([
'gbfs.bluebikes.com',
'gbfs.bcycle.com',
'gbfs.biketownpdx.com',
'gbfs.cogobikeshare.com',
'reddybikeshare.socialbicycles.com',
'austin.publicbikesystem.net',
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
'gbfs.nextbike.net',
'gbfs.urbansharing.com',
'api.entur.io',
'api.cyclocity.fr',
]);
/**
* Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's
* api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux
* contract endpoint.
*/
const GBFS_HOST_PATH_PREFIXES = new Map([
['api.entur.io', '/mobility/v2/gbfs/'],
['api.cyclocity.fr', '/contracts/'],
]);
/**
* Identifying headers an operator asks every GBFS client to send. Entur rate
* limits or blocks consumers that do not send ET-Client-Name, in the form
* "company-application".
*/
const GBFS_HOST_HEADERS = new Map([
[
'api.entur.io',
Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }),
],
]);
/**
@@ -28,13 +54,40 @@ export function isAllowedGbfsHost(hostname) {
}
/**
* Only allow station_information.json and station_status.json endpoints.
* Only allow station_information and station_status endpoints. The `.json`
* suffix is optional because Entur serves its feeds without one. When a
* hostname is given and that host serves more than GBFS, the path must also
* sit under the host's GBFS prefix.
*
* @param {string} pathname
* @param {string} [hostname]
* @returns {boolean}
*/
export function isAllowedGbfsPath(pathname) {
return /\/station_(information|status)\.json$/i.test(String(pathname || ''));
export function isAllowedGbfsPath(pathname, hostname = '') {
const path = String(pathname || '');
if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false;
const prefix = GBFS_HOST_PATH_PREFIXES.get(
String(hostname || '')
.trim()
.toLowerCase(),
);
return !prefix || path.startsWith(prefix);
}
/**
* Extra request headers an upstream GBFS host asks clients to send.
*
* @param {string} hostname
* @returns {Record<string, string>}
*/
export function gbfsUpstreamHeaders(hostname) {
return (
GBFS_HOST_HEADERS.get(
String(hostname || '')
.trim()
.toLowerCase(),
) || {}
);
}
/**
@@ -47,7 +100,7 @@ export function isAllowedGbfsPath(pathname) {
* @returns {string} Cache-Control header value.
*/
export function gbfsCacheControl(pathname) {
if (/\/station_information\.json$/i.test(String(pathname || ''))) {
if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) {
return 'public, max-age=300';
}
return 'no-store';
+28
View File
@@ -26,6 +26,7 @@ import {
validMilitaryInstallationBox,
writeMilitaryInstallationDisk,
} from '../../vite.config.js';
import { resolveOverpassUpstreamTag } from '../../server/providers/overpass/constants.js';
const DAY_MS = 86_400_000;
test('installation failure reasons disclose no raw upstream error and do not guess overload', () => {
@@ -274,3 +275,30 @@ test('an unwritable cache directory degrades to a miss instead of throwing', asy
await fsp.rm(dir, { recursive: true, force: true });
}
});
test('an installation entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => {
const prior = process.env.OVERPASS_UPSTREAMS;
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
const dir = tempCacheDir();
const key = '30.250,-97.750,30.300,-97.700';
try {
process.env.OVERPASS_UPSTREAMS = 'https://a.example/api/interpreter,https://b.example/api/interpreter';
const tag = resolveOverpassUpstreamTag();
await writeMilitaryInstallationDisk(key, { ...entry(Date.now()), upstreams: tag }, dir);
assert.ok(await readMilitaryInstallationDisk(key, TTL_MS, dir), 'its own entry is a hit');
process.env.OVERPASS_UPSTREAMS = 'https://b.example/api/interpreter,https://a.example/api/interpreter';
assert.equal(resolveOverpassUpstreamTag(), tag, 'reordering the same instances keeps the tag');
process.env.OVERPASS_UPSTREAMS = 'https://c.example/api/interpreter';
assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'fresh read refused');
assert.equal(await readMilitaryInstallationDisk(key, Infinity, dir), null, 'stale read refused');
await writeMilitaryInstallationDisk('legacy', entry(Date.now()), dir);
assert.equal(await readMilitaryInstallationDisk('legacy', Infinity, dir), null, 'untagged entry refused');
delete process.env.OVERPASS_UPSTREAMS;
assert.ok(await readMilitaryInstallationDisk(key, Infinity, dir), 'nothing configured accepts any entry');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+24 -4
View File
@@ -296,6 +296,24 @@ function imageryPinOption(key, token) {
});
}
function satelliteFilterOption() {
const percentage = boundedIntegerOption('infrared', 'i', 100, {
min: 0,
max: 100,
});
return Object.freeze({
...percentage,
normalize: (value) =>
value === 'filtered'
? 100
: value === 'full'
? 0
: percentage.normalize(value),
decode: (value) =>
value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value),
});
}
const OPTION_GROUPS = Object.freeze({
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
@@ -317,10 +335,7 @@ const OPTION_GROUPS = Object.freeze({
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
satelliteFilterOption(),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
@@ -567,6 +582,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS =
* owns stable URL ordering.
*/
export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({
id: 'air-quality',
token: '4',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'ais-live-vessels',
token: 'a',
+69 -21
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 30);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
assert.equal(REGISTERED_LAYER_IDS.length, 31);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '4');
assert.equal(nextLayerStateToken(), '5');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '4',
bravo: '5',
alpha: '5',
bravo: '6',
}),
'6',
'7',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '4',
next: '5',
future: '5',
next: '6',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 4/,
/next free token 5/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
),
/next free token 5/,
/next free token 6/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2422,23 +2422,74 @@ test('wind appearance shares round trip while old links retain weather defaults'
assert.deepEqual(invalid.options.wind, defaults);
});
test('observed weather round trips product and opacity without persisting historical playback', () => {
test('observed weather round trips product and presets without persisting historical playback', () => {
const state = normalizeLayerState({
enabledLayerIds: ['weather-radar', 'weather-satellite'],
options: {
'weather-radar': { opacity: 'light', play: true },
'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 },
'weather-satellite': {
product: 'clouds',
opacity: 'light',
infrared: 'filtered',
step: -1,
},
},
});
const params = new URLSearchParams(encode(state));
const decoded = decodeLayerStateParams(params);
assert.deepEqual(decoded, state);
assert.equal(state.options['weather-satellite'].product, 'clouds');
assert.equal(state.options['weather-satellite'].opacity, 'light');
assert.equal(state.options['weather-satellite'].infrared, 100);
assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false);
});
test('satellite infrared display mode round trips and invalid or absent values use filtered', () => {
for (const infrared of ['full', 'filtered', undefined, 'invalid']) {
test('satellite filter strength percentages round-trip and legacy mode codes remain readable', () => {
const state = normalizeLayerState({
enabledLayerIds: ['weather-satellite'],
options: { 'weather-satellite': { infrared: 53, opacity: 'light' } },
});
const params = encodeLayerStateParams(
new URLSearchParams([['v', '2']]),
state,
);
assert.deepEqual(decodeLayerStateParams(params), state);
assert.equal(state.options['weather-satellite'].infrared, 53);
assert.equal(state.options['weather-satellite'].opacity, 'light');
for (const [legacy, expected] of [
['f', 100],
['a', 0],
]) {
const decoded = decodeLayerStateParams(
new URLSearchParams(`v=2&l=o&lo=o.i.${legacy}`),
);
assert.equal(decoded.options['weather-satellite'].infrared, expected);
}
for (const [legacy, expected] of [
['filtered', 100],
['full', 0],
]) {
const normalized = normalizeLayerState({
enabledLayerIds: ['weather-satellite'],
options: { 'weather-satellite': { infrared: legacy } },
});
assert.equal(normalized.options['weather-satellite'].infrared, expected);
}
const invalid = normalizeLayerState({
enabledLayerIds: ['weather-satellite'],
options: { 'weather-satellite': { infrared: 101 } },
});
assert.equal(invalid.options['weather-satellite'].infrared, 100);
});
test('satellite cloud filter round trips and invalid or absent values default to Clouds only', () => {
for (const [infrared, expected] of [
['full', 0],
['filtered', 100],
[53, 53],
[undefined, 100],
['invalid', 100],
]) {
const state = normalizeLayerState({
enabledLayerIds: ['weather-satellite'],
options: {
@@ -2454,10 +2505,7 @@ test('satellite infrared display mode round trips and invalid or absent values u
decodeLayerStateParams(new URLSearchParams(encode(state))),
state,
);
assert.equal(
state.options['weather-satellite'].infrared,
infrared === 'full' ? 'full' : 'filtered',
);
assert.equal(state.options['weather-satellite'].infrared, expected);
assert.equal(
Object.hasOwn(state.options['weather-satellite'], 'step'),
false,
+35 -29
View File
@@ -128,17 +128,23 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-integrity': '3',
'air-quality': '4',
},
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
() =>
parseLayerStateTokenReservations(
// Drop a legacy row by id; the first row sorts as air-quality, which
// is not a legacy reservation.
reservationRows.filter(([id]) => id !== 'ais-live-vessels'),
),
/Missing or changed legacy/,
);
assert.throws(
@@ -155,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
.future,
'4',
'5',
);
assert.throws(
() =>
@@ -175,14 +181,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '4']] },
{ rows: [...reservationRows, ['retired-layer', '5']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '5');
assert.equal(nextLayerStateToken(published), '6');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '5',
newcomer: '6',
}),
true,
);
@@ -190,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '4',
competing: '5',
}),
/next free token 5/,
/next free token 6/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '4',
newcomer: '5',
}),
/changed or removed: retired-layer/,
);
@@ -206,8 +212,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -228,8 +234,8 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '4']],
[{ id: 'pr-b', token: '4' }],
[...reservationRows, ['pr-b', '5']],
[{ id: 'pr-b', token: '5' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -237,10 +243,10 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
[
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '4' },
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '5' },
],
);
const staleRebase = runCheck();
@@ -248,16 +254,16 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '5');
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
assert.equal(nextLayerStateToken(published), '6');
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 5/);
assert.match(helper.stdout, /pr-b: 6/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -265,20 +271,20 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
[
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '5' },
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '6' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /31 published, 1 new/);
assert.match(corrected.stdout, /32 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -330,7 +336,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'456789'].map((digit) => [
const priorDigits = [...'56789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -367,7 +373,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 71);
assert.equal(params.get('l').length, 73);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -385,7 +391,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 31);
assert.equal(Object.keys(published).length, 32);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -1,4 +1,5 @@
[
["air-quality", "4"],
["ais-live-vessels", "a"],
["alpr-cameras", "p"],
["bhote-koshi-2026", "h"],
+64
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import https from 'node:https';
import { Readable } from 'node:stream';
import test from 'node:test';
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
import {
createRadioProxyMiddleware,
isPublicRadioAddress,
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
}
assert.equal(fetchCount, 0);
});
// Answer every pinned request with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of leaving the fetch pending.
function mockMirrorStatus(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'application/json' },
}),
),
);
return request;
});
return crashed;
}
function fetchMirror() {
return fetchPinnedRadioResponse(
new URL('https://de1.api.radio-browser.info/json/servers'),
{ headers: {} },
[{ address: '93.184.216.34', family: 4 }],
);
}
for (const status of [204, 205, 304]) {
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
const response = await Promise.race([fetchMirror(), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
await assert.rejects(
Promise.race([fetchMirror(), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
+498 -7
View File
@@ -115,6 +115,265 @@ function metroTransitRouteMode(routeId) {
return 'bus';
}
/**
* TTC BusTime is the SURFACE feed: buses and streetcars, never the subway.
* Streetcar routes are the 500-series, plus the four Blue Night routes that
* run on streetcar trackage (301 Queen, 304 King, 306 Carlton, 310 Spadina);
* the rest of the 300-series is night buses.
* @param {string|null} routeId
* @returns {string}
*/
function ttcRouteMode(routeId) {
if (!routeId) return 'unknown';
if (/^5\d\d$/.test(routeId)) return 'tram';
if (/^(301|304|306|310)$/.test(routeId)) return 'tram';
return 'bus';
}
/**
* The fleet number settles TTC's mode before the route does: buses replace
* streetcars on 5xx routes during track work, and streetcars running out of
* service report no route at all. Flexity streetcars are numbered 4400–4699;
* every other TTC number is a bus.
* @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number).
* @returns {string}
*/
function ttcVehicleMode(vehicleId) {
if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown';
const fleet = Number(vehicleId);
return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus';
}
/**
* Guelph Transit's realtime feed publishes the internal database key of a
* route (`2991`), not the number on the front of the bus (`1`). Without this
* table the detection label reads "Route 2991", which is not what anyone at
* the stop calls it. Route ids and short names are from the City of Guelph's
* own published GTFS `routes.txt` (see DATA_SOURCES.md); an id missing here
* falls through unchanged, so a route added later degrades to its raw id
* rather than disappearing.
*/
const GUELPH_ROUTE_NAMES = Object.freeze({
2991: '1',
2992: '10',
2993: '11',
2994: '12',
2995: '13',
2996: '14',
2997: '15',
2998: '16',
2999: '17',
3000: '18',
3001: '19',
3002: '2',
3003: '20',
3004: '3',
3005: '4',
3006: '5',
3007: '50 U',
3008: '52 U',
3009: '56 U',
3010: '58 U',
3011: '6',
3012: '7',
3013: '8',
3014: '9',
3015: '98',
3016: '99',
3017: '99Lite',
3018: 'BREW',
3019: 'EdiCol',
3023: 'IroHarv',
3030: 'Sun LN',
3032: 'WHanSco',
3039: 'ZehCha',
});
/**
* Guelph route id to the number a rider would say.
* @param {string|null} routeId
* @returns {string|null}
*/
function guelphRouteLabel(routeId) {
return GUELPH_ROUTE_NAMES[routeId] || routeId;
}
/**
* Hamilton publishes an internal key for every route: not one of HSR's 80 route
* ids equals the number on the bus, so an untranslated feed reads as
* "Route 5783" city-wide. Two generations of id are live at once — `5688` and
* `5780` are both the 2 BARTON — and both are listed, because both arrive.
*
* Numbers are unpadded to match the rest of the registry (`routes.txt` says
* `02`; a rider says 2). The seasonal and event shuttles publish a name rather
* than a number — `TC230`, `PEACH`, `ROCKTON` — and those are already what a
* rider would say, so they survive as published. Ids and names are from the
* City's own published GTFS `routes.txt`, under the same terms as the feed.
*/
const HAMILTON_ROUTE_NAMES = Object.freeze({
5687: '1',
5688: '2',
5689: '3',
5690: '4',
5691: '5',
5692: '6',
5693: '7',
5694: '8',
5695: '9',
5696: '10',
5697: '11',
5698: '12',
5699: '16',
5700: '18',
5701: '20',
5702: '21',
5703: '22',
5704: '23',
5705: '24',
5706: '25',
5707: '26',
5708: '27',
5709: '33',
5710: '34',
5711: '35',
5712: '41',
5713: '42',
5714: '43',
5715: '44',
5716: '51',
5717: '52',
5718: '55',
5719: '56',
5720: '99',
5731: 'X-IND',
5780: '2',
5781: '3',
5782: '4',
5783: '5',
5784: '6',
5785: '7',
5786: '8',
5787: '9',
5789: '11',
5790: '12',
5791: '16',
5792: '18',
5793: '20',
5794: '21',
5795: '22',
5796: '23',
5797: '24',
5798: '25',
5799: '26',
5800: '27',
5801: '33',
5802: '34',
5803: '35',
5805: '42',
5806: '43',
5807: '44',
5808: '51',
5809: '52',
5811: '56',
5812: '99',
5820: 'TC230',
5823: 'X IND',
5824: 'TC-700',
5825: 'TC-300',
5826: 'CAN',
5827: 'PEACH',
5828: '1',
5829: '10',
5830: '41',
5831: '55',
5832: 'TC300',
5833: 'TC700',
5834: 'TC730',
5835: 'ANCFAIR',
5836: 'ROCKTON',
});
/**
* Hamilton route id to the number a rider would say.
* @param {string|null} routeId
* @returns {string|null}
*/
function hamiltonRouteLabel(routeId) {
return HAMILTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* YRT publishes Viva bus rapid transit by route id (`601`), while every rider,
* station sign and map calls it "Viva Blue". Plain local routes are already the
* number on the bus and are left alone, unpadded: `105` reads better than the
* `005` of `routes.txt`.
*
* Branches are the exception. YRT suffixes a two-digit branch onto the base
* route — the scheme Viva's own `60102` follows — so a branch id reaches a
* rider as "8301" unless it is translated. Names and colours are from York
* Region's own published GTFS `routes.txt`; Viva Green (604) and Pink (606)
* are not in current service and so are absent here too.
*
* Only branches observed live AND published in `routes.txt` are listed. `9101`
* runs but is unpublished, so it is deliberately missing: the pattern suggests
* "91A", and suggesting is not knowing.
*/
const YRT_ROUTE_NAMES = Object.freeze({
601: 'Viva Blue',
60102: 'Viva Blue B',
603: 'Viva Purple',
60301: 'Viva Purple A',
605: 'Viva Orange',
607: 'Viva Yellow',
8301: '83A',
9002: '90B',
10702: '107B',
});
/**
* YRT route id to the name a rider would say.
* @param {string|null} routeId
* @returns {string|null}
*/
function yrtRouteLabel(routeId) {
return YRT_ROUTE_NAMES[routeId] || routeId;
}
/**
* Burlington publishes the internal key of a route, and the keys are a trap:
* `351` is route 1 while `3510` is route 10, so trimming a `35` prefix gets
* the wrong answer on half the network. The join is the only correct route.
* Ids and numbers are from the City's own published GTFS `routes.txt`, which
* sits in the same directory as the realtime feed under the same terms.
*/
const BURLINGTON_ROUTE_NAMES = Object.freeze({
351: '1',
352: '2',
353: '3',
354: '4',
356: '6',
3510: '10',
3511: '11',
3512: '12',
3525: '25',
3548: '48',
3550: '50',
3551: '51',
3552: '52',
3580: '80',
3581: '81',
3587: '87',
});
/**
* Burlington route id to the number a rider would say.
* @param {string|null} routeId
* @returns {string|null}
*/
function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -270,6 +529,204 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'ttc-toronto',
name: 'TTC',
operator: 'Toronto Transit Commission',
region: 'Toronto, ON',
center: Object.freeze({ lat: 43.6532, lon: -79.3832 }),
loadRadiusKm: 45,
url: 'https://bustime.ttc.ca/gtfsrt/vehicles',
license: 'Open Government Licence \u2013 Toronto',
licenseUrl: 'https://open.toronto.ca/open-data-licence/',
attribution:
'Toronto Transit Commission \u2014 City of Toronto Open Data (Open Government Licence \u2013 Toronto)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider.',
note: 'The City publishes this endpoint from the TTC BusTime NVAS catalogue entry, whose own licence field reads \"License not specified\"; the quote above is the portal-wide Open Government Licence \u2013 Toronto the City applies to its open data, so the credit carries the licence name the way the Metro Transit entry carries its public-domain note. BusTime is the surface system: buses and streetcars report real positions, the subway reports none, so Lines 1/2/4 are simply absent rather than inferred. The endpoint sends no ETag or Last-Modified, so conditional requests fall through to the proxy snapshot TTL.',
}),
defaultMode: 'bus',
// Toronto rides streetcars. The vehicles are the shared `tram` mode and
// share its silhouette and speed limits; only the word a reader sees
// changes, because "tram" is not what anyone at Queen and Spadina says.
modeWords: Object.freeze({
tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }),
}),
routeMode: ttcRouteMode,
vehicleMode: ttcVehicleMode,
}),
Object.freeze({
id: 'miway-mississauga',
name: 'MiWay',
operator: 'MiWay (City of Mississauga)',
region: 'Mississauga, ON',
center: Object.freeze({ lat: 43.589, lon: -79.6441 }),
loadRadiusKm: 30,
url: 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
license: 'City of Mississauga Open Data Terms of Use',
licenseUrl:
'http://www5.mississauga.ca/research_catalogue/CityofMississauga_TermsofUse.pdf',
attribution: 'MiWay \u2014 City of Mississauga Open Data',
defaultEnabled: true,
terms: Object.freeze({
quote:
'The City of Mississauga (the City) now grants you a world-wide, royalty-free, non-exclusive, revocable licence to use, modify, and distribute the Datasets in all current and future media and formats for any lawful purpose.',
note: 'Two conditions shape how this ships. The licence is REVOCABLE, and it asks that anyone who provides access to the data pass on the Terms of Use URL \u2014 so `licenseUrl` points at the terms themselves rather than at a catalogue page, and the attribution popover carries the link. Credit is optional here ("you are not required to credit the City"), and is given anyway as a courtesy, the way the other municipal feeds are.',
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'hsr-hamilton',
name: 'Hamilton HSR',
operator: 'Hamilton Street Railway (City of Hamilton)',
region: 'Hamilton, ON',
center: Object.freeze({ lat: 43.2557, lon: -79.8711 }),
loadRadiusKm: 30,
url: 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
license: 'City of Hamilton Open Data Licence',
licenseUrl:
'https://www.hamilton.ca/city-initiatives/strategies-actions/open-data-licence-terms-and-conditions',
attribution:
'Contains public sector Data made available under the City of Hamilton\u2019s Open Data Licence',
defaultEnabled: true,
terms: Object.freeze({
quote:
'The City of Hamilton grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Data, including for commercial purposes ... you must acknowledge the source of the Data by including the following attribution statement: \u201cContains public sector Data made available under the City of Hamilton\u2019s Open Data Licence\u201d.',
note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.',
}),
defaultMode: 'bus',
routeLabel: hamiltonRouteLabel,
}),
Object.freeze({
id: 'durham-region',
name: 'Durham Region Transit',
operator: 'Durham Region Transit (Regional Municipality of Durham)',
region: 'Durham Region, ON',
center: Object.freeze({ lat: 43.9496, lon: -78.9365 }),
loadRadiusKm: 45,
url: 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
license: 'Region of Durham Open Data Licence v.1.0',
licenseUrl: 'https://www.durham.ca/en/regional-government/open-data.aspx',
attribution:
"Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence",
defaultEnabled: true,
terms: Object.freeze({
quote:
'Durham Region grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information ... You are free to: Copy, publish, distribute and transmit the information ... Use the information commercially.',
note: 'The licence link the open-data catalogue publishes for this feed (OpenDataLicenceAgreement.pdf) is dead after a site relaunch; `licenseUrl` points at the live Open Data page carrying the same licence v.1.0 text, which is where the quote above was read. Credit is optional and the suggested statement is used verbatim.',
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'guelph-transit',
name: 'Guelph Transit',
operator: 'Guelph Transit (City of Guelph)',
region: 'Guelph, ON',
center: Object.freeze({ lat: 43.5325, lon: -80.2482 }),
loadRadiusKm: 15,
url: 'https://glphprdtmgtfs.glphtrpcloud.com/tmgtfsrealtimewebservice/vehicle/vehiclepositions.pb',
license: 'Open Government Licence \u2013 City of Guelph (v2.0)',
licenseUrl: 'https://explore.guelph.ca/pages/open-data-license',
attribution:
'Contains information licensed under the Open Government Licence \u2013 City of Guelph',
defaultEnabled: true,
terms: Object.freeze({
quote:
'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. ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence.',
note: 'The endpoint sits on a vendor host, so provenance matters: the City publishes this exact URL on its own open-data developer page, https://explore.guelph.ca/pages/transit-gtfs-data, under an open-data initiative heading (that page is JavaScript-rendered; its readable source is the ArcGIS item JSON behind it). The licence is a verbatim OGL-Canada 2.0 derivative and the attribution wording above is its prescribed fallback, so it is used as written. The transit datasets carry a BLANK licence field in the portal catalogue; the Terms of Use supply it by default ("Except where otherwise noted this is the Open Government Licence"), the same shape of gap the TTC entry records. The Terms also publish a traffic limit \u2014 no concurrent requests, at most 5 per second \u2014 which one poll every 15 s satisfies many times over. Crests and logos are excluded from the grant, so the credit stays text.',
}),
defaultMode: 'bus',
routeLabel: guelphRouteLabel,
}),
Object.freeze({
id: 'kingston-transit',
name: 'Kingston Transit',
operator: 'Kingston Transit (The Corporation of the City of Kingston)',
region: 'Kingston, ON',
center: Object.freeze({ lat: 44.24, lon: -76.57 }),
loadRadiusKm: 20,
url: 'https://api.cityofkingston.ca/gtfs-realtime/vehicleupdates.pb',
license: 'City of Kingston Open Data Licence 1.0',
licenseUrl:
'https://www.cityofkingston.ca/media/wtpgpkb0/gis_license_opendata.pdf',
attribution:
'Contains information licensed under the Open Data Licence \u2013 City of Kingston',
defaultEnabled: true,
terms: Object.freeze({
quote:
'The Information Provider, the City of Kingston, grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information provided for any purpose, including for commercial purposes, subject to the terms below. ... Acknowledge the source of the information by including any attribution statement specified by the City of Kingston and, where possible, provide a link to this licence.',
note: 'The City catalogues this exact URL as an open-data dataset, so provenance is direct rather than inferred. `licenseUrl` is the canonical location of the licence PDF; the address the catalogue prints is a legacy path that currently redirects there, and pinning the redirect target is what survives the next site relaunch \u2014 the Durham entry records what happens when it does not. Names, crests and logos are excluded from the grant, so the credit stays text. Roughly one entity in ten carries no usable position (the position field absent, occasionally a momentary 0,0 GPS dropout); both are discarded before rendering.',
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'barrie-transit',
name: 'Barrie Transit',
operator: 'Barrie Transit (City of Barrie)',
region: 'Barrie, ON',
center: Object.freeze({ lat: 44.371, lon: -79.6733 }),
loadRadiusKm: 10,
url: 'https://www.myridebarrie.ca/gtfs/GTFS_VehiclePositions.pb',
license: 'Barrie Transit Data licence',
licenseUrl:
'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs',
attribution:
'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence',
defaultEnabled: true,
terms: Object.freeze({
quote:
'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.',
note: 'The City offers this licence for acceptance behind a click-through checkbox, and the licence body also makes use itself acceptance: \u201cBy using Barrie Transit Data, you agree to be bound by these terms.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on; nothing gates the endpoint, which needs no key, token or cookie. Two oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.',
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'yrt-york',
name: 'YRT/Viva',
operator:
'York Region Transit (YRT/Viva), The Regional Municipality of York',
region: 'York Region, ON',
center: Object.freeze({ lat: 44.025, lon: -79.436 }),
loadRadiusKm: 38,
url: 'https://rtu.york.ca/gtfsrealtime/VehiclePositions',
license: 'YRT Open Data Licence v2.0 (UK OGL-derived)',
licenseUrl:
'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx',
attribution:
"Contains public transit Information made available under YRT's Open Data Licence",
defaultEnabled: true,
terms: Object.freeze({
quote:
'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.',
note: 'The operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential), while the licence body says acceptance is by use: \u201cYour Use of Information indicates Your acceptance of the terms and conditions below.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on. Nothing gates the endpoint. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.',
}),
defaultMode: 'bus',
routeLabel: yrtRouteLabel,
}),
Object.freeze({
id: 'burlington-transit',
name: 'Burlington Transit',
operator: 'Burlington Transit (City of Burlington)',
region: 'Burlington, ON',
center: Object.freeze({ lat: 43.3342, lon: -79.8089 }),
loadRadiusKm: 12,
url: 'https://opendata.burlington.ca/gtfs-rt/GTFS_VehiclePositions.pb',
license: 'City of Burlington Open Data Terms of Use',
licenseUrl:
'https://opendata.burlington.ca/opendata-terms-of-use/City%20of%20Burlington%20-%20Open%20Data%20Terms%20of%20Use.pdf',
attribution:
'Transit data \u00a9 City of Burlington (Burlington Transit), used under the City of Burlington Open Data Terms of Use',
defaultEnabled: true,
terms: Object.freeze({
quote:
'The Corporation of the City of Burlington (the City) now grants you a worldwide, royalty-free, non-exclusive, revocable licence to use, reproduce, modify, and distribute the datasets in all current and future media and formats for any lawful purpose. ... If you distribute or provide access to these datasets to any other person ... you agree to include a copy of, or this Uniform Resource Locator (URL) for, these Terms of Use.',
note: 'Same 2011-era municipal template as Mississauga, and the same two consequences: the grant is REVOCABLE, and the terms have to travel with the data, so `licenseUrl` is the Terms document itself rather than a catalogue page and the attribution popover carries the link. Credit is optional here and given anyway. Crests, logos and marks are excluded and the terms forbid implying any association, so the credit stays plain text. The catalogue record for this data leads with a licence link that 404s; the URL above is the live one. Plain http:// has no listener at all \u2014 not a redirect, a hang \u2014 so the https:// URL is mandatory rather than preferred. The feed updates once a minute and answers conditional requests with an ETag.',
}),
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
]);
/**
@@ -353,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
}
/**
* Mode for a vehicle: the feed's route hint when it has one, else its default.
* The feed's own hint for a vehicle: its fleet number when the feed maps
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
* @param {object} feed Registry entry.
* @param {string|null} routeId
* @param {string|null} vehicleId
* @returns {string|null}
*/
function hintedMode(feed, routeId, vehicleId) {
const byVehicle =
typeof feed?.vehicleMode === 'function'
? feed.vehicleMode(vehicleId)
: null;
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
}
/**
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
* its default.
* @param {object} feed Registry entry.
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
* @param {string|null} [vehicleId] GTFS-RT vehicle id.
* @returns {string} One of TRANSIT_MODES.
*/
export function transitModeFor(feed, routeId) {
const hinted =
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
export function transitModeFor(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
const mode =
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
@@ -373,16 +848,32 @@ export function transitModeFor(feed, routeId) {
* not hold a defaulted vehicle to a bus's limits.
* @param {object} feed Registry entry.
* @param {string|null} routeId
* @param {string|null} [vehicleId]
* @returns {boolean}
*/
export function transitModeResolved(feed, routeId) {
const hinted =
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
export function transitModeResolved(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
return (
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
);
}
/**
* The route as a rider would say it. Most feeds publish that already, so this
* is identity for them; a feed that publishes internal keys supplies its own
* `routeLabel`. Never invents a label: an id the feed does not know survives
* unchanged, because a raw id a person can compare against the bus is more
* use than a blank.
* @param {object|null} feed Registry entry.
* @param {string|null} routeId
* @returns {string|null}
*/
export function transitRouteLabel(feed, routeId) {
if (!routeId || typeof feed?.routeLabel !== 'function') return routeId;
const named = feed.routeLabel(routeId);
return typeof named === 'string' && named ? named : routeId;
}
/**
* Public catalog shape served by `/api/transit/feeds` — everything the browser
* needs to gate polling and credit the source, and nothing it could misuse.
+243
View File
@@ -13,6 +13,7 @@ import {
transitFeedsInRange,
transitModeFor,
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
@@ -239,3 +240,245 @@ test('published transit history scope agrees with the catalog capability', () =>
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
);
});
test('the TTC feed is registered, routable, and covers Toronto', () => {
const ttc = getTransitFeed('ttc-toronto');
assert.equal(ttc?.url, 'https://bustime.ttc.ca/gtfsrt/vehicles');
assert.equal(ttc.defaultEnabled, true);
assert.match(ttc.attribution, /Toronto Transit Commission/);
assert.equal(ttc.historyRetention, undefined, 'no proxy retention');
assert.ok(
transitFeedsInRange(43.6532, -79.3832).some(
(feed) => feed.id === 'ttc-toronto',
),
'downtown Toronto is inside the load radius',
);
});
test('TTC route numbers tell a streetcar from a bus', () => {
// BusTime is the surface feed: 500-series are streetcar routes and the
// streetcar-operated Blue Night routes keep their own numbers. Everything
// else on this feed is a bus, so the mode is established, not defaulted.
const ttc = getTransitFeed('ttc-toronto');
assert.equal(transitModeFor(ttc, '501'), 'tram');
assert.equal(transitModeFor(ttc, '512'), 'tram');
assert.equal(transitModeFor(ttc, '304'), 'tram');
assert.equal(transitModeFor(ttc, '36'), 'bus');
// 52 Lawrence West is a bus. Only the three-digit 500-series is streetcar,
// so the rule must not match on a leading 5 alone.
assert.equal(transitModeFor(ttc, '52'), 'bus');
assert.equal(transitModeFor(ttc, '59'), 'bus');
assert.equal(transitModeFor(ttc, '900'), 'bus');
assert.equal(transitModeFor(ttc, '352'), 'bus');
assert.equal(transitModeResolved(ttc, '501'), true);
assert.equal(transitModeResolved(ttc, null), false);
assert.equal(
transitModeFor(ttc, null),
'bus',
'an unrouted vehicle falls back to the feed default, unresolved',
);
});
test('a TTC fleet number outranks the route', () => {
// Buses replace streetcars on 5xx routes during track work, and streetcars
// out of service report no route; the Flexity fleet is 4400–4699.
const ttc = getTransitFeed('ttc-toronto');
assert.equal(transitModeFor(ttc, '506', '8515'), 'bus');
assert.equal(transitModeFor(ttc, '512', '3101'), 'bus');
assert.equal(transitModeFor(ttc, null, '4507'), 'tram');
assert.equal(transitModeResolved(ttc, null, '4507'), true);
assert.equal(transitModeFor(ttc, '504', '4400'), 'tram');
assert.equal(transitModeFor(ttc, '29', '4699'), 'tram');
assert.equal(transitModeFor(ttc, '501', '4700'), 'bus');
// A vehicle id that is not a fleet number leaves the route to decide.
assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram');
assert.equal(transitModeFor(ttc, '501'), 'tram');
});
test('the TTC row records that BusTime carries no subway', () => {
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
const row = sources.split('\n').find((line) => line.startsWith('| **TTC**'));
assert.ok(row, 'TTC has a DATA_SOURCES row');
assert.match(row, /Toronto/);
assert.match(sources, /surface vehicles \(buses and streetcars\) only/);
assert.match(sources, /Open Government Licence – Toronto/);
});
test('the Golden Horseshoe neighbours are registered, keyless and routable', () => {
const expected = {
'miway-mississauga':
'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
'hsr-hamilton':
'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
'durham-region':
'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
};
for (const [id, url] of Object.entries(expected)) {
const feed = getTransitFeed(id);
assert.ok(feed, `${id} is routable`);
assert.equal(feed.url, url);
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.defaultMode, 'bus');
assert.equal(feed.historyRetention, undefined, `${id} retains nothing`);
assert.ok(feed.terms?.quote, `${id} records the terms it ships on`);
}
});
test('each Golden Horseshoe feed covers its own city and not its neighbours', () => {
const near = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
// Mississauga City Centre, downtown Hamilton, Oshawa.
assert.ok(near(43.589, -79.6441).includes('miway-mississauga'));
assert.ok(near(43.2557, -79.8711).includes('hsr-hamilton'));
assert.ok(near(43.8971, -78.8658).includes('durham-region'));
// Hamilton is 60 km from Toronto: far enough that the TTC feed is not polled
// for it, which is the whole point of a per-feed radius.
assert.equal(near(43.2557, -79.8711).includes('ttc-toronto'), false);
});
test('a bus-only feed never claims a mode its route ids cannot establish', () => {
for (const id of ['miway-mississauga', 'hsr-hamilton', 'durham-region']) {
const feed = getTransitFeed(id);
assert.equal(transitModeFor(feed, '1'), 'bus');
assert.equal(
transitModeResolved(feed, '1'),
false,
`${id} defaults rather than resolves`,
);
}
});
test('every Golden Horseshoe feed is documented with its licence', () => {
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
for (const name of ['MiWay', 'Hamilton HSR', 'Durham Region Transit']) {
assert.ok(
sources.split('\n').some((line) => line.startsWith(`| **${name}`)),
`${name} has a DATA_SOURCES row`,
);
}
assert.match(sources, /Durham Region Transit publishes no bearing/);
});
test('a feed whose route ids are internal database keys can name its routes', () => {
// Guelph's realtime feed emits `2991` where the rider sees route `1`. A feed
// that knows the difference translates it; every other feed is unaffected.
const guelph = getTransitFeed('guelph-transit');
assert.equal(transitRouteLabel(guelph, '2991'), '1');
assert.equal(transitRouteLabel(guelph, '3016'), '99');
assert.equal(transitRouteLabel(guelph, '3007'), '50 U');
assert.equal(transitRouteLabel(guelph, '3017'), '99Lite');
});
test('an unknown or absent route id survives translation unchanged', () => {
const guelph = getTransitFeed('guelph-transit');
// A route added after this table was built must still render its raw id
// rather than vanish or read as "undefined".
assert.equal(transitRouteLabel(guelph, '4242'), '4242');
assert.equal(transitRouteLabel(guelph, null), null);
assert.equal(transitRouteLabel(guelph, ''), '');
});
test('a feed with rider-facing route ids is left alone', () => {
// TTC and Kingston publish the route a rider would say, so translation is
// identity for them and no table is carried.
assert.equal(transitRouteLabel(getTransitFeed('ttc-toronto'), '501'), '501');
assert.equal(transitRouteLabel(getTransitFeed('mbta'), 'Red'), 'Red');
assert.equal(transitRouteLabel(null, '7'), '7');
});
test('the feeds whose licences are accepted by use are switched on', () => {
// Barrie and YRT each ask for acceptance through a form, and each licence
// also says that using the data is itself acceptance. That reading was
// taken deliberately, so both are routable and both record the reasoning
// in their terms note rather than leaving it to be re-derived later.
for (const id of ['barrie-transit', 'yrt-york']) {
assert.ok(getTransitFeed(id), `${id} is routable`);
assert.ok(
publicTransitCatalog().some((feed) => feed.id === id),
`${id} is offered to the browser`,
);
assert.match(
getRegisteredTransitFeed(id).terms.note,
/acceptance is by use|use itself acceptance|took that clause as sufficient/,
`${id} records why it ships on`,
);
}
});
test('every registered feed now ships on, and the gate still decides that', () => {
// The enabled set must stay a filter over the registry, not a copy of it:
// flipping one feed off has to make it unreachable.
assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva');
assert.deepEqual(
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled !== true),
[],
);
});
test('Viva bus rapid transit is named by its colour, not its route number', () => {
// YRT publishes `601` where every rider, map and station sign says
// "Viva Blue". The numeric local routes already read correctly and are
// left alone.
const yrt = getTransitFeed('yrt-york');
assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue');
assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A');
assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow');
assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number');
assert.equal(transitRouteLabel(yrt, '9899'), '9899');
});
test('YRT local branches read as the branch letter, not the padded id', () => {
// YRT suffixes a two-digit branch onto the base route, the same scheme the
// Viva table already relies on for `60102`. Unsuffixed, that leaks to a rider
// as "8301". The letters are York Region's own published GTFS
// `routes.txt` short names, unpadded to match the local routes beside them.
const yrt = getTransitFeed('yrt-york');
assert.equal(transitRouteLabel(yrt, '10702'), '107B');
assert.equal(transitRouteLabel(yrt, '8301'), '83A');
assert.equal(transitRouteLabel(yrt, '9002'), '90B');
// `9101` runs in the realtime feed but is absent from routes.txt, so there is
// no published name to give it. Inventing "91A" from the pattern is exactly
// the guess the registry refuses to make; it stays opaque until it is
// published.
assert.equal(transitRouteLabel(yrt, '9101'), '9101');
});
test('Hamilton route ids are internal keys, read as the number on the bus', () => {
// Not one of HSR's 80 route ids equals its published short name, so every
// vehicle reads as "Route 5783" until the table translates it. Hamilton also
// carries two generations of id for the same route — `5688` and `5780` are
// both the 2 BARTON — so both must land on the same answer.
const hsr = getTransitFeed('hsr-hamilton');
assert.equal(transitRouteLabel(hsr, '5780'), '2');
assert.equal(transitRouteLabel(hsr, '5688'), '2', 'the older id agrees');
assert.equal(transitRouteLabel(hsr, '5687'), '1');
assert.equal(transitRouteLabel(hsr, '5829'), '10', 'the B-Line');
assert.equal(transitRouteLabel(hsr, '5793'), '20', 'the A-Line');
// Published names that are not numbers are already what a rider would say,
// so they survive as published rather than being forced into a number.
assert.equal(transitRouteLabel(hsr, '5820'), 'TC230');
assert.equal(transitRouteLabel(hsr, '9999'), '9999', 'unknown survives');
});
test('Burlington route ids are joined, never prefix-stripped', () => {
// `351` is route 1 and `3510` is route 10, so no amount of trimming a `35`
// prefix works — the table is the only correct answer.
const bur = getTransitFeed('burlington-transit');
assert.equal(transitRouteLabel(bur, '351'), '1');
assert.equal(transitRouteLabel(bur, '3510'), '10');
assert.equal(transitRouteLabel(bur, '3512'), '12');
assert.equal(transitRouteLabel(bur, '3587'), '87');
assert.equal(transitRouteLabel(bur, '9999'), '9999', 'unknown survives');
});
test('Burlington is live and carries its terms link, which its licence requires', () => {
const bur = getTransitFeed('burlington-transit');
assert.equal(bur.defaultEnabled, true);
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
defaultGatewayFromRouteTable,
unwritableRootWarning,
resolveTrustedPeers,
} from '../scripts/docker-start.mjs';
// The shape of /proc/net/route inside a bridge-networked container:
// little-endian hex addresses, the default route first, an interface route
// after.
const BRIDGE_ROUTES = [
'Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
'eth0\t00000000\t0100A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
'eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0',
'',
].join('\n');
test('the container gateway is decoded from the kernel routing table', () => {
assert.equal(defaultGatewayFromRouteTable(BRIDGE_ROUTES), '192.168.0.1', 'little-endian hex, default route only');
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '011FAC')),
null,
'a malformed gateway field is not an address',
);
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('\t0003\t', '\t0002\t')),
null,
'a default route that is not up is ignored',
);
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '00000000')),
null,
'a zero gateway is not an address',
);
assert.equal(
defaultGatewayFromRouteTable(
BRIDGE_ROUTES.split('\n').filter((line) => !line.includes('00000000\t0100A8C0')).join('\n'),
),
null,
'--network none has no default route and must not invent one',
);
assert.equal(defaultGatewayFromRouteTable(''), null);
assert.equal(defaultGatewayFromRouteTable(undefined), null);
});
test('an operator list wins, and no gateway trusts nothing', () => {
assert.equal(resolveTrustedPeers({ env: {}, routeTable: BRIDGE_ROUTES }), '192.168.0.1');
assert.equal(resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.9.8.7 ' }, routeTable: BRIDGE_ROUTES }), '10.9.8.7');
assert.equal(
resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' ' }, routeTable: BRIDGE_ROUTES }),
'192.168.0.1',
'a blank override falls through to the gateway',
);
assert.equal(resolveTrustedPeers({ env: {}, routeTable: '' }), '', 'loopback-only when there is no gateway');
});
test('an unwritable checkout is named at start instead of failing quietly', () => {
const ids = { owner: { uid: 0, gid: 0 }, runAs: { uid: 1000, gid: 1000 } };
assert.equal(unwritableRootWarning({ root: '/app', writable: true, ...ids }), null, 'a writable checkout says nothing');
const warning = unwritableRootWarning({ root: '/app', writable: false, ...ids });
assert.match(warning, /owned by 0:0/, 'names the owner');
assert.match(warning, /runs as 1000:1000/, 'names the ids the container runs as');
assert.match(warning, /chown -R 1000:1000/, 'offers handing the checkout to the container user first');
assert.match(warning, /env UID=0 GID=0 docker compose up/, 'or the env form, which bash does not ignore');
});
test('the Docker install path keeps its loopback-only, launcher-owned contract', () => {
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8');
assert.match(dockerfile, /^CMD \["node", "scripts\/docker-start\.mjs"\]$/m, 'the launcher is the container entry point');
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded');
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
});
+3 -2
View File
@@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
// Air quality (ECCC AQHI) extends the manifest.
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from '../vite.config.js';
const UPSTREAM = 'https://geocode.test/json';
function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) {
const routes = new Map();
googlePlacesContextProxy({
resolveApiKey: () => apiKey,
fetchImpl,
endpoints: { geocode: UPSTREAM },
}).configureServer({
middlewares: { use: (name, handler) => routes.set(name, handler) },
});
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/' } = {}) {
return new Promise((resolve, reject) => {
const headers = new Map();
const res = {
statusCode: 200,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: body ? JSON.parse(String(body)) : null,
});
},
};
Promise.resolve(
handler(
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
res,
),
).catch(reject);
});
}
test('geocoder query and viewport bias are validated before Google is paid', () => {
assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), {
ok: false,
error: 'address is required',
});
assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), {
ok: false,
error: 'address is required',
});
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })),
{ ok: false, error: 'address is too long' },
);
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })),
{ ok: true, address: 'Austin' },
);
// A bias only ranks results, so an unusable one is dropped, not refused.
const bias = '30.1,-97.9|30.4,-97.6';
assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias);
for (const bounds of [
'',
'30.1,-97.9',
'30.1,-97.9|30.4',
'north,-97.9|30.4,-97.6',
'91,-97.9|30.4,-97.6',
'30.1,-181|30.4,-97.6',
// A blank component must not read as 0 — that is the equator, not a corner.
'30.1,-97.9|30.4,',
'30.1,|30.4,-97.6',
'30.1,-97.9| ,-97.6',
])
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
});
test('the geocode routes keep the key server-side and answer in Google shape', async () => {
const upstream = [];
const routes = installRoutes({
fetchImpl: async (url) => {
upstream.push(new URL(String(url)));
return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] });
},
});
const forward = routes.get('/api/google/geocode');
const reverse = routes.get('/api/google/reverse-geocode');
assert.equal(typeof forward, 'function');
assert.equal(typeof reverse, 'function');
const forwarded = await invokeRoute(forward, {
url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6',
});
assert.equal(forwarded.statusCode, 200);
assert.deepEqual(forwarded.body, {
status: 'OK',
results: [{ place_id: 'fixture' }],
});
assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin');
assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' });
assert.equal(reversed.statusCode, 200);
assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
// The key travels to Google, never back to the page that asked.
assert.ok(!JSON.stringify(forwarded.body).includes('server-secret'));
assert.ok(!JSON.stringify(reversed.body).includes('server-secret'));
// A malformed bias is dropped rather than forwarded.
await invokeRoute(forward, { url: '/?address=Austin&bounds=north' });
assert.equal(upstream.at(-1).searchParams.has('bounds'), false);
});
test('a bad or keyless geocode request never reaches Google', async () => {
const upstream = [];
const fetchImpl = async (url) => {
upstream.push(String(url));
return Response.json({ status: 'OK', results: [] });
};
const routes = installRoutes({ fetchImpl });
for (const [path, url] of [
['/api/google/geocode', '/?address='],
['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'],
]) {
const response = await invokeRoute(routes.get(path), { url });
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body.results, []);
assert.equal(response.body.status, 'REQUEST_DENIED');
}
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const rejected = await invokeRoute(routes.get(path), {
method: 'POST',
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(rejected.statusCode, 405);
}
// Keyless is an empty capability, not an outage: a 200 the browser adapters
// read as "this provider did not answer", so the chain moves on. It resolves
// ahead of validation, like the Places routes: with nothing configured there
// is no request to validate and no quota to protect.
const keyless = installRoutes({ apiKey: ' ', fetchImpl });
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode'])
for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) {
const response = await invokeRoute(keyless.get(path), { url });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
});
}
assert.deepEqual(upstream, []);
});
test('an upstream failure reaches the browser as fixed public text', async () => {
const routes = installRoutes({
fetchImpl: async () => {
throw new Error('connect ECONNREFUSED 10.0.0.7:443');
},
});
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const response = await invokeRoute(routes.get(path), {
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
}
});
test('an oversized upstream body is dropped rather than buffered', async () => {
const routes = installRoutes({
// Two hundred times the 256 KiB ceiling, declared honestly.
fetchImpl: async () =>
Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }),
});
const response = await invokeRoute(routes.get('/api/google/geocode'), {
url: '/?address=Austin',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
});
+12 -4
View File
@@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the
}
});
test('both Places routes select the intended key and keep it out of responses', async (t) => {
test('every Google route selects the intended key and keeps it out of responses', async (t) => {
const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN };
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, key: options.headers['X-Goog-Api-Key'] });
// Places sends the key as a header; Geocoding's web service takes it as a
// query parameter. Either way it must be the server key, and never the
// browser's copy of it.
calls.push({
url,
key:
options?.headers?.['X-Goog-Api-Key'] ??
new URL(String(url)).searchParams.get('key'),
});
return Response.json({ places: [] });
});
try {
@@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses',
for (const install of ['configureServer', 'configurePreviewServer']) {
const routes = new Map();
googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } });
assert.equal(routes.size, 2);
assert.equal(routes.size, 4);
for (const handler of routes.values()) {
const before = calls.length;
let body;
const res = { setHeader() {}, end(value) { body = value; } };
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
assert.equal(res.statusCode, 200);
assert.ok(!body.includes('server-secret'));
if (expected) {
+33 -2
View File
@@ -126,6 +126,32 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/** Socket addresses that count as this machine. */
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Whether a socket address is one the operator has explicitly declared to be
* this machine through GEV_KEY_SETUP_TRUSTED_PEERS: a comma-separated list of
* EXACT addresses, never wildcards, prefixes or hostnames. Unset trusts
* nothing, which is every launcher but one.
*
* The shipped use is the Docker launcher (scripts/docker-start.mjs). A port
* published on the host's loopback still reaches the container FROM the
* container network's gateway, never from 127.0.0.1, so the loopback check
* alone refuses the very machine running the server. Admitting that one
* address relaxes nothing else: the sharing, Host, Origin, Content-Type and
* proxy-header checks all still apply to it.
*/
function isTrustedPeerAddress(remoteAddress, env) {
const normalize = (value) =>
String(value || '')
.trim()
.toLowerCase()
.replace(/^::ffff:/, '');
const address = normalize(remoteAddress);
if (!address) return false;
return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '')
.split(',')
.some((entry) => entry.trim() !== '' && normalize(entry) === address);
}
/** Parse an exact local request authority from a Host header. */
function localAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '')
@@ -224,7 +250,9 @@ export function isSharingEnabled(env = {}) {
* outright — a credential-writing endpoint has no business existing on a
* shared instance, and tunnel traffic reaches the server FROM loopback, so
* the socket check below cannot carry that boundary alone;
* - loopback socket: refuses LAN peers when the server is bound wide;
* - loopback socket: refuses LAN peers when the server is bound wide. The
* only exception is an exact address listed in
* GEV_KEY_SETUP_TRUSTED_PEERS, and every check below still applies to it;
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
* Host even when the socket says loopback;
* - exact same Origin on POST: a hostile web page can make a browser POST to
@@ -274,7 +302,10 @@ export function admitKeySetupRequest({
error: 'Provider Settings is disabled while sharing is enabled',
};
}
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
if (
!LOOPBACK_ADDRESSES.has(String(remoteAddress || '')) &&
!isTrustedPeerAddress(remoteAddress, env)
) {
return {
ok: false,
status: 403,
+17
View File
@@ -259,6 +259,23 @@ test('the admission gate refuses every non-local shape, one assertion per refusa
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
// A LAN peer reaching a wide-bound server.
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
// A container publishes its port on the host's loopback, yet the host's own
// browser still arrives from the container network's gateway. Only an exact
// address the operator listed admits it, and nothing else is relaxed.
const trusted = { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.0.0.9 , 172.29.73.1 ' };
const gateway = { ...local, remoteAddress: '172.29.73.1' };
assert.equal(admitKeySetupRequest(gateway).ok, false, 'container gateway refused without the opt-in');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted }).ok, true, 'listed peer admitted');
assert.equal(admitKeySetupRequest({ ...gateway, remoteAddress: '::ffff:172.29.73.1', env: trusted }).ok, true, 'IPv4-mapped form of a listed peer admitted');
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20', env: trusted }).ok, false, 'unlisted peer still refused');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '*' } }).ok, false, 'wildcards are not addresses');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '172.29.73.0/24' } }).ok, false, 'prefixes are not addresses');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '' } }).ok, false, 'an empty list trusts nothing');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' , , ' } }).ok, false, 'blank entries trust nothing');
assert.equal(admitKeySetupRequest({ ...gateway, env: { ...trusted, PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'sharing still disables the surface for a listed peer');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'a listed peer still needs a local Host');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, origin: 'https://evil.example' }).ok, false, 'a listed peer still needs the exact Origin');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, proxyHeaders: { 'x-forwarded-for': '203.0.113.5' } }).ok, false, 'a listed peer still refuses proxied requests');
// Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket.
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
+286
View File
@@ -0,0 +1,286 @@
import * as Cesium from 'cesium';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
AIR_QUALITY_PROVIDERS,
createAirQualityOverlayEntry,
mapAnalystRecord,
newestObservation,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
} from './model.js';
export * from './model.js';
export { createEcccAirQualitySource } from './source.js';
/** Station markers are a point reading, not an extent. */
const STATION_MARKER_RADIUS_M = 6_000;
/** Readings listed in the readout card. */
const READOUT_LIMIT = 5;
/** Own one air-quality display, its refresh lifecycle, and its readout card. */
export function createAirQualityLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Air quality requires a snapshot source');
if (!overlayHost) throw new TypeError('Air quality requires an overlay host');
let _viewer = null;
let _request = null;
let _dataSource = null;
let _readings = [];
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let rowControlsListener = null;
const notify = () => rowControlsListener?.();
/** The camera's lon/lat rectangle in degrees, or null when unresolvable. */
const viewRectangle = () => {
const rectangle = _viewer?.camera?.computeViewRectangle?.();
if (!rectangle) return null;
const toDeg = Cesium.Math.toDegrees;
return {
west: toDeg(rectangle.west),
south: toDeg(rectangle.south),
east: toDeg(rectangle.east),
north: toDeg(rectangle.north),
};
};
const layer = {
id: 'air-quality',
name: 'Air quality',
icon: '◍',
source: 'ECCC MSC GeoMet',
updateInterval: 1_200_000,
init(viewer) {
if (_viewer) throw new Error('Air quality layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('air-quality');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true);
notify();
},
disable() {
// Abort in flight: a response arriving after disable must not redraw.
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
notify();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const readings = await source.getSnapshot({ signal: request.signal });
// A superseded or aborted request must not touch the scene, and neither
// must one that lands after the layer was switched off.
if (request.signal.aborted || _request !== request || !_enabled)
return false;
_readings = readings;
this._render();
_count = _readings.length;
_lastUpdate = Date.now();
_lastError = null;
notify();
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || 'Air quality request failed';
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
} finally {
if (_request === request) _request = null;
}
},
/** Rebuild entities and overlay labels from the current readings. */
_render() {
if (!_dataSource) return;
_dataSource.entities.removeAll();
const overlayEntries = [];
for (const reading of _readings) {
const position = Cesium.Cartesian3.fromDegrees(
reading.lon,
reading.lat,
);
const color = Cesium.Color.fromCssColorString(reading.color);
_dataSource.entities.add(
new Cesium.Entity({
id: `air-quality:${reading.id}`,
position,
ellipse: {
// Static axes, as in the earthquake layer: a per-frame axis
// re-tessellates the clamped ground primitive every frame.
semiMajorAxis: STATION_MARKER_RADIUS_M,
semiMinorAxis: STATION_MARKER_RADIUS_M,
material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)),
outline: true,
outlineColor: color.withAlpha(0.9),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
provider: reading.provider,
scale: reading.scale,
value: reading.value,
band: reading.band,
name: reading.name,
zone: reading.zone,
risk: reading.risk,
observedMs: reading.observedMs,
note: reading.note,
lat: reading.lat,
lon: reading.lon,
},
}),
);
overlayEntries.push(
createAirQualityOverlayEntry({
id: reading.id,
position,
title: `${reading.name} ${reading.label}`,
accent: reading.color,
value: reading.value,
}),
);
}
if (_enabled) {
overlayHost.setEntries(
AIR_QUALITY_OVERLAY_SOURCE_ID,
selectAirQualityOverlayCohort(overlayEntries),
{
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_enabled = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
if (_dataSource) {
viewer?.dataSources?.remove(_dataSource, true);
_dataSource = null;
}
_viewer = null;
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
rowControlsListener = null;
},
/**
* Readout descriptor for the Weather panel: the band legend, and the worst
* readings currently in view.
*
* `readout: true` renders the toggle and source line only; the card owns
* the rest. Coverage is stated plainly because this layer is not global.
*/
getRowControls() {
const provider = AIR_QUALITY_PROVIDERS.eccc;
const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT);
const newest = _readings.reduce(
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
})),
legendCategorical: true,
list: {
ariaLabel: 'Highest air-quality readings in view',
items: inView.map((reading, index) => ({
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk}`,
accent: reading.color,
})),
},
};
},
setRowControlsListener(listener) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
/**
* Snapshot readings as JSON-safe records for the analyst query engine.
* @param {number} [maxCount=500]
* @returns {Array<object>}
*/
getAnalystRecords(maxCount = 500) {
if (!_enabled || !_readings.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 500;
return _readings
.slice(0, limit)
.map((reading, index) => mapAnalystRecord(reading, index));
},
getStats() {
// `lastUpdate` is when a response was received; `observedAt` is when the
// network actually measured. Freshness is judged on the latter, so a feed
// that keeps answering with hours-old observations reports STALE rather
// than being narrated as live by the HUD and voice provenance envelope.
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
observedAt: newestObservation(_readings),
// Only a completed refresh can be stale; before one there is no feed.
stale: _lastUpdate !== null && readingsAreStale(_readings),
};
},
};
return layer;
}
+377
View File
@@ -0,0 +1,377 @@
/**
* @module layers/airQuality/model
*
* Air-quality readings, normalized across networks. Pure — no Cesium, no
* network.
*
* Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to
* 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints;
* Europe's EAQI is a five-step category. A reading is therefore carried as
* `{ provider, scale, value, band }` — who measured it, which index it is, the
* value on that index's own terms, and the band it falls in — so a second
* network can be added without retrofitting a shared numeric meaning that does
* not exist.
*
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
/** Colour used when a station reports no usable reading. */
export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785';
export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality';
export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48;
export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32;
/**
* A reading this old is stale enough that displaying it would misinform.
* AQHI publishes hourly, so six hours is five missed publications.
*/
export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000;
/**
* How old the newest observation may be before the layer reports itself stale.
*
* This is deliberately separate from the age at which a reading is dropped.
* `lastUpdate` records when we last *received* a response, which stays current
* even when the network publishes nothing new; freshness has to be judged on
* the source's own observation time instead. AQHI publishes hourly, so a newest
* reading beyond two hours means at least one publication was missed and the
* feed should not be described as live.
*/
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
/**
* The newest observation time across a set of readings, or null when empty.
* @param {Array<object>} readings
* @returns {number|null} Epoch ms.
*/
export function newestObservation(readings) {
if (!Array.isArray(readings) || readings.length === 0) return null;
let newest = null;
for (const reading of readings) {
const at = Number(reading?.observedMs);
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at;
}
return newest;
}
/**
* Is a set of readings stale, judged on source observation time? An empty
* set is stale: a refresh that kept no current observation has nothing live.
* @param {Array<object>} readings
* @param {number} [now=Date.now()]
* @returns {boolean}
*/
export function readingsAreStale(readings, now = Date.now()) {
const newest = newestObservation(readings);
// No current observation at all — every row was dropped as too old or
// unusable — is the stalest a feed can be, not a nominal one.
if (newest === null) return true;
return now - newest > STALE_AFTER_MS;
}
/**
* Registered provider descriptors. `coverage` is stated plainly because the
* layer is not global and the row must say so.
*/
export const AIR_QUALITY_PROVIDERS = Object.freeze({
eccc: Object.freeze({
id: 'eccc',
scale: 'AQHI',
label: 'Air quality',
network: 'Environment and Climate Change Canada',
coverage: 'Canada only · ECCC AQHI network',
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
Object.freeze({ id: 'moderate', max: 6 }),
Object.freeze({ id: 'high', max: 10 }),
Object.freeze({ id: 'very-high', max: Infinity }),
]),
}),
});
/** Band descriptor for an id, or null. */
export function airQualityBandById(bandId) {
return BAND_BY_ID.get(String(bandId ?? '')) || null;
}
/** Accent colour for a band id, falling back to neutral grey. */
export function airQualityColor(bandId) {
return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR;
}
/**
* The published AQHI value for a raw ECCC reading: an integer from 1 up.
*
* ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no
* "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error,
* and is published as 1.
*
* @param {number|string|null|undefined} raw - Raw `aqhi` value from the API.
* @returns {number|null} Published integer AQHI, or null when unusable.
*/
export function aqhiDisplayValue(raw) {
// Guard the empty forms BEFORE coercing: Number(null) and Number('') are both
// 0, which would otherwise floor to a confident "AQHI 1" for a station that
// reported nothing at all.
if (raw === null || raw === undefined || raw === '') return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return null;
if (typeof raw === 'string' && raw.trim() === '') return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.max(1, Math.round(value));
}
/**
* The band id for a value on a provider's scale.
* @param {string} providerId
* @param {number|null} value - Already in the provider's published form.
* @returns {string|null}
*/
export function bandForValue(providerId, value) {
const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')];
if (!provider || !Number.isFinite(value)) return null;
return provider.bands.find((band) => value <= band.max)?.id ?? null;
}
/**
* Display text for a value on a provider's scale.
* AQHI collapses everything above ten into the open-ended "10+".
* @param {string} providerId
* @param {number|null} value
* @returns {string}
*/
export function valueLabel(providerId, value) {
if (!Number.isFinite(value)) return '--';
if (providerId === 'eccc') return value > 10 ? '10+' : String(value);
return String(value);
}
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
}
/**
* Validate a station FeatureCollection into an id-keyed catalog.
*
* Atomic: a structurally broken response returns null so the caller keeps its
* last good catalog. A station missing coordinates is skipped, since one
* unusable station is not evidence the catalog is broken.
*
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
* @returns {Map<string, object>|null} Stations by location_id, or null.
*/
export function normalizeAqhiStations(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const stations = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
const [lon, lat] = coordinates.map(Number);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
const id = String(properties.location_id ?? feature.id ?? '').trim();
if (!id) continue;
stations.set(id, {
id,
name: String(properties.location_name_en || id).trim(),
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
zone: String(properties['eccc_administrative-zone'] || '').trim(),
lat,
lon,
});
}
return stations;
}
/**
* Join latest ECCC observations onto known stations into provider-shaped rows.
*
* The catalog is the only thing that supplies a reading its coordinates, so an
* observation without a matching station cannot be placed and is dropped.
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
* air-quality number presented as current is worse than no number.
*
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
* @param {Map<string, object>} stations - Known stations by location_id.
* @param {number} [now=Date.now()] - Clock seam for tests.
* @returns {Array<object>|null} Normalized readings, or null when malformed.
*/
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
if (!Array.isArray(geojson?.features)) return null;
if (!(stations instanceof Map)) return null;
const provider = AIR_QUALITY_PROVIDERS.eccc;
const newest = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const stationId = String(properties.location_id ?? '').trim();
const station = stations.get(stationId);
if (!station) continue;
const value = aqhiDisplayValue(properties.aqhi);
if (value === null) continue;
const observedMs = Date.parse(properties.observation_datetime);
if (!Number.isFinite(observedMs)) continue;
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
const previous = newest.get(stationId);
if (previous && previous.observedMs >= observedMs) continue;
const band = bandForValue(provider.id, value);
newest.set(stationId, {
id: `${provider.id}:${stationId}`,
provider: provider.id,
scale: provider.scale,
value,
band,
stationId,
name: station.name,
zone: station.zone,
lat: station.lat,
lon: station.lon,
observedMs,
color: airQualityColor(band),
label: valueLabel(provider.id, value),
risk: bandRiskText(band),
note: String(properties.special_notes_en || '').trim() || null,
});
}
return [...newest.values()].sort(
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
);
}
/**
* Build the source-owned presentation for one station label.
* @param {object} input
* @returns {object}
*/
export function createAirQualityOverlayEntry({
id,
position,
title,
accent,
value,
}) {
return {
id: String(id),
position,
variant: 'label',
title,
accent,
// Worse air wins the label budget — the reading people need to see first.
priority: Math.round(Number(value) || 0) * 1000,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the worst readings, with stable identity as the tie-break. */
export function selectAirQualityOverlayCohort(
entries,
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(
0,
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(entries) || cap === 0) return [];
return entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
)
.slice(0, cap);
}
/**
* The worst readings inside a lon/lat rectangle, for the readout card.
*
* A null rectangle means the camera could not resolve one (a fully oblique or
* space-level view); everything is then in scope rather than nothing, so the
* card degrades to the worst readings overall instead of going blank.
*
* @param {Array<object>} readings
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
* @param {number} [limit=5]
* @returns {Array<object>}
*/
export function readingsInView(readings, rect, limit = 5) {
if (!Array.isArray(readings)) return [];
const cap = Math.max(0, Math.floor(Number(limit) || 0));
if (cap === 0) return [];
const inside = !rect
? readings
: readings.filter((reading) => {
if (reading.lat < rect.south || reading.lat > rect.north) return false;
// A rectangle crossing the antimeridian has west > east.
return rect.west <= rect.east
? reading.lon >= rect.west && reading.lon <= rect.east
: reading.lon >= rect.west || reading.lon <= rect.east;
});
return inside
.slice()
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
.slice(0, cap);
}
/**
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
* @param {object|null|undefined} raw
* @param {number} [index=0]
* @returns {object}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
provider: text(raw?.provider),
scale: text(raw?.scale),
value: num(raw?.value),
band: text(raw?.band),
name: text(raw?.name),
zone: text(raw?.zone),
risk: text(raw?.risk),
lat: num(raw?.lat),
lon: num(raw?.lon),
observedMs: num(raw?.observedMs),
};
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_PROVIDERS,
AIR_QUALITY_UNKNOWN_COLOR,
OBSERVATION_MAX_AGE_MS,
STALE_AFTER_MS,
airQualityColor,
aqhiDisplayValue,
bandForValue,
bandRiskText,
mapAnalystRecord,
normalizeAqhiObservations,
normalizeAqhiStations,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
valueLabel,
} from './model.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const RECENT = '2026-09-24T18:00:00Z';
const collection = (features) => ({ type: 'FeatureCollection', features });
const station = (id, name, zone, lon, lat) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
location_id: id,
location_name_en: name,
'eccc_administrative-zone': zone,
},
});
const observation = (id, aqhi, when = RECENT) => ({
type: 'Feature',
properties: { location_id: id, aqhi, observation_datetime: when },
});
const STATIONS = collection([
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
]);
// ── provider-shaped records ────────────────────────────────────────────────
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
// US AQI is 0-500 on concentration. The shape names which index a value is on
// so a second network can be added without inventing a shared meaning.
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2.32)]),
stations,
NOW,
);
assert.equal(row.provider, 'eccc');
assert.equal(row.scale, 'AQHI');
assert.equal(row.value, 2);
assert.equal(row.band, 'low');
});
test('bands map from a value on a named provider scale', () => {
assert.equal(bandForValue('eccc', 1), 'low');
assert.equal(bandForValue('eccc', 3), 'low');
assert.equal(bandForValue('eccc', 4), 'moderate');
assert.equal(bandForValue('eccc', 7), 'high');
assert.equal(bandForValue('eccc', 11), 'very-high');
// An unregistered provider cannot be banded rather than being guessed at.
assert.equal(bandForValue('unknown-network', 5), null);
assert.equal(bandForValue('eccc', null), null);
});
test('the provider states its coverage plainly', () => {
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
});
test("values above ten collapse to the scale's open-ended label", () => {
assert.equal(valueLabel('eccc', 10), '10');
assert.equal(valueLabel('eccc', 11), '10+');
assert.equal(valueLabel('eccc', null), '--');
});
test('every band has a distinct colour, and an unknown band is neutral', () => {
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
assert.equal(new Set(colors).size, colors.length);
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
assert.equal(bandRiskText('high'), 'High health risk');
assert.equal(bandRiskText(null), null);
});
// ── published form ─────────────────────────────────────────────────────────
test('AQHI rounds to the published integer form and floors at 1', () => {
assert.equal(aqhiDisplayValue(1.08), 1);
assert.equal(aqhiDisplayValue(3.5), 4);
assert.equal(aqhiDisplayValue(0), 1);
assert.equal(aqhiDisplayValue('3'), 3);
});
test('an absent reading never becomes a confident AQHI 1', () => {
// Number(null) and Number('') are both 0, which would floor to 1.
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
}
});
// ── snapshot validation ────────────────────────────────────────────────────
test('stations normalize into an id-keyed catalog carrying their zone', () => {
const stations = normalizeAqhiStations(STATIONS);
assert.equal(stations.size, 3);
assert.equal(stations.get('OAAAA').zone, 'ont');
});
test('malformed payloads are rejected atomically', () => {
assert.equal(normalizeAqhiStations(null), null);
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
const stations = normalizeAqhiStations(STATIONS);
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
assert.equal(
normalizeAqhiObservations(collection([]), 'not a map', NOW),
null,
);
});
test('an observation with no matching station is dropped', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
stations,
NOW,
);
assert.equal(rows.length, 1);
});
test('a stale reading is dropped rather than shown as current', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.stationId),
['OAAAA'],
);
});
test('a snapshot whose every observation was too old is stale, not nominal', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
stations,
NOW,
);
assert.deepEqual(rows, []);
assert.equal(readingsAreStale(rows, NOW), true);
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
assert.equal(
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
true,
);
});
test('the newest reading per station wins and worst air sorts first', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
observation('OAAAA', 5),
]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.value),
[7, 5],
);
});
// ── readout selection ──────────────────────────────────────────────────────
const reading = (id, value, lat, lon) => ({
id,
stationId: id,
value,
lat,
lon,
name: id,
});
test('the readout lists the worst readings inside the view rectangle', () => {
const rows = [
reading('west', 9, 51.0, -114.0),
reading('east', 4, 43.6, -79.3),
reading('mild', 2, 51.1, -114.1),
];
const inView = readingsInView(
rows,
{ west: -115, south: 50, east: -113, north: 52 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['west', 'mild'],
);
});
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
// A space-level or fully oblique camera cannot produce a rectangle; the card
// should still say something useful.
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
assert.deepEqual(
readingsInView(rows, null, 5).map((r) => r.id),
['b', 'a'],
);
assert.deepEqual(readingsInView(rows, null, 0), []);
assert.deepEqual(readingsInView(null, null, 5), []);
});
test('a view rectangle crossing the antimeridian still selects correctly', () => {
// west > east is how a dateline-crossing rectangle is expressed.
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
const inView = readingsInView(
rows,
{ west: 170, south: -10, east: -170, north: 10 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['near'],
);
});
test('the overlay cohort is capped and deterministic', () => {
const entries = Array.from({ length: 120 }, (_, i) => ({
id: `s${i}`,
priority: i % 11,
}));
const cohort = selectAirQualityOverlayCohort(entries);
assert.ok(cohort.length <= 48);
assert.deepEqual(
cohort.map((e) => e.id),
selectAirQualityOverlayCohort(entries).map((e) => e.id),
);
});
test('analyst records are JSON-safe and name the provider and scale', () => {
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2)]),
stations,
NOW,
);
const record = mapAnalystRecord(row, 0);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(record.provider, 'eccc');
assert.equal(record.scale, 'AQHI');
const empty = mapAnalystRecord(undefined, 7);
assert.equal(empty.id, 'AIR-0007');
for (const [key, value] of Object.entries(empty)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
}
});
+275
View File
@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAirQualityLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createAirQualityLayer({
source,
overlayHost: {
setEntries(...args) {
events.push(args);
},
setVisible() {},
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
}
const reading = (id, value, overrides = {}) => ({
id: `eccc:${id}`,
provider: 'eccc',
scale: 'AQHI',
value,
band: 'low',
stationId: id,
name: id,
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: String(value),
risk: 'Low health risk',
note: null,
...overrides,
});
const staticSource = (rows) => ({ getSnapshot: async () => rows });
test('a layer requires both a snapshot source and an overlay host', () => {
assert.throws(() => createAirQualityLayer(), /snapshot source/);
assert.throws(
() => createAirQualityLayer({ source: staticSource([]) }),
/overlay host/,
);
});
test('initialization is single-use and owns exactly one data source', () => {
const { layer, viewer, sources } = harness(staticSource([]));
assert.equal(sources.length, 1);
assert.throws(() => layer.init(viewer), /already initialized/);
layer.destroy(viewer);
assert.equal(sources.length, 0);
});
test('a refresh renders readings and publishes an overlay cohort', async () => {
const { layer, events } = harness(
staticSource([reading('A', 2), reading('B', 7)]),
);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().error, null);
assert.equal(events.at(-1)[0], 'air-quality');
});
// ── the abort + enabled guard ──────────────────────────────────────────────
test('a response landing after disable cannot redraw the scene', async () => {
// The failure this prevents: disable() clears the overlay, then an in-flight
// response resolves and repopulates it behind the user's back.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const { layer, events } = harness({
getSnapshot: async ({ signal } = {}) => {
await gate;
signal?.throwIfAborted();
return [reading('A', 5)];
},
});
const pending = layer.update();
layer.disable();
const publishedBefore = events.length;
release();
assert.equal(await pending, false, 'a late response must not be applied');
assert.equal(
events.length,
publishedBefore,
'no overlay write after disable',
);
assert.equal(layer.getStats().count, 0);
});
test('disable aborts the request in flight', async () => {
let seenSignal = null;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
seenSignal = signal;
await new Promise((resolve) => setTimeout(resolve, 40));
signal?.throwIfAborted();
return [];
},
});
const pending = layer.update();
layer.disable();
await pending;
assert.ok(seenSignal, 'a signal was passed through to the source');
assert.equal(seenSignal.aborted, true);
});
test('a superseded refresh does not clobber the newer one', async () => {
let call = 0;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
call += 1;
const mine = call;
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
signal?.throwIfAborted();
return [reading(`call-${mine}`, mine)];
},
});
const first = layer.update();
const second = layer.update();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult, false, 'the superseded call reports no update');
assert.equal(secondResult, true);
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
});
test('update is a no-op while the layer is disabled', async () => {
const { layer } = harness(staticSource([reading('A', 2)]));
layer.disable();
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 0);
});
// ── readout descriptor ─────────────────────────────────────────────────────
test('the readout descriptor carries the band legend and coverage', async () => {
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
await layer.update();
const controls = layer.getRowControls();
assert.equal(controls.readout, true);
assert.match(controls.summary.coverage, /Canada only/);
// The Weather panel's legend shape: one {color, label} per band.
assert.equal(controls.legendCategorical, true);
assert.deepEqual(
controls.legend.map((entry) => entry.label),
['Low', 'Moderate', 'High', 'Very High'],
);
assert.ok(
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
);
// Worst first, and every item keyed.
assert.deepEqual(
controls.list.items.map((i) => i.text),
['B · Low health risk', 'A · Low health risk'],
);
assert.ok(controls.list.items.every((i) => i.id));
assert.match(controls.summary.status, /2 stations reporting/);
});
test('the readout reports an error rather than a silent empty card', async () => {
const { layer } = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
assert.match(layer.getRowControls().summary.status, /503/);
});
test('row-control listeners are notified on refresh and teardown-safe', async () => {
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
let calls = 0;
layer.setRowControlsListener(() => {
calls += 1;
});
await layer.update();
assert.ok(calls >= 1, 'a refresh notifies the card');
layer.destroy(viewer);
const after = calls;
layer.setRowControlsListener(() => {
calls += 1;
});
assert.equal(calls, after);
});
// ── feed-state honesty ─────────────────────────────────────────────────────
test('freshness is judged on observation time, not on when we last fetched', async () => {
// lastUpdate stays current every time the network answers, even when it
// answers with nothing new. Judging freshness on it would let the HUD and
// voice describe hours-old air quality as live.
const aged = Date.now() - STALE_AFTER_MS - 60_000;
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: aged })]),
);
await layer.update();
const stats = layer.getStats();
assert.ok(
stats.lastUpdate >= Date.now() - 5_000,
'we did just receive a response',
);
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
assert.equal(stats.stale, true);
assert.equal(
layerFeedState(stats),
'stale',
'provenance must not read this as nominal',
);
});
test('a current reading reports nominal', async () => {
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
);
await layer.update();
const stats = layer.getStats();
assert.equal(stats.stale, false);
assert.equal(layerFeedState(stats), 'nominal');
});
test('a refresh that kept no current observation reports stale, not nominal', async () => {
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
// that only has hours-old rows yields an empty snapshot. That is the
// stalest a feed can be; it must not read as a healthy empty layer.
const { layer } = harness(staticSource([]));
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
await layer.update();
const stats = layer.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.observedAt, null);
assert.equal(stats.stale, true);
assert.equal(layerFeedState(stats), 'stale');
assert.equal(layer.getRowControls().summary.status, 'No current readings');
});
test('a failed refresh over good readings reports degraded, not nominal', async () => {
let fail = false;
const { layer } = harness({
getSnapshot: async () => {
if (fail) throw new Error('ECCC HTTP 503');
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
},
});
await layer.update();
assert.equal(layerFeedState(layer.getStats()), 'nominal');
fail = true;
await layer.update();
const stats = layer.getStats();
assert.ok(stats.count > 0, 'the previous readings are still displayed');
assert.equal(
layerFeedState(stats),
'degraded',
'showing old data after a failure is degraded',
);
});
+104
View File
@@ -0,0 +1,104 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
/** Comfortably above Canada's ~134 stations without inviting a huge page. */
const STATION_LIMIT = 500;
const OBSERVATION_LIMIT = 500;
/** Station geometry is near-static — a few changes a year. Refetch twice a day. */
const STATION_TTL_MS = 12 * 60 * 60 * 1000;
/** A full 500-row GeoJSON page is a few hundred KB; anything past this is not a page. */
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
/**
* Request and validate a complete ECCC AQHI snapshot before it can replace
* displayed readings. The first registered air-quality provider.
*
* Two collections are involved. `aqhi-stations` is the catalog — it supplies
* every reading its coordinates and is cached for twelve hours because it
* barely changes. `aqhi-observations-realtime` is the hourly reading feed,
* requested with `latest=true` so the response is current values rather than
* the full observation history, which is roughly 2,000 rows nationally.
*
* The station catalog is only replaced when a refresh actually returns
* stations, so a transient empty response cannot blank the layer.
*
* @param {object} [options]
* @param {Function} [options.fetchImpl] - Injected fetch, for tests.
* @param {() => number} [options.now] - Clock seam, for tests.
* @param {number} [options.timeoutMs=15000] - Deadline for each request.
* @returns {{getSnapshot: Function}}
*/
export function createEcccAirQualitySource({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
timeoutMs = 15_000,
} = {}) {
/** @type {Map<string, object>} */
let stations = new Map();
let stationsFetchedAt = 0;
/** One bounded GeoJSON page: deadline, caller abort, byte cap, row cap. */
const getJson = async (url, limit, signal) => {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('ECCC request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
controller.signal,
);
// The server's `limit` is a request, not a guarantee.
if (Array.isArray(payload?.features) && payload.features.length > limit)
return { ...payload, features: payload.features.slice(0, limit) };
return payload;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
};
return {
async getSnapshot({ signal } = {}) {
const clock = now();
if (stations.size === 0 || clock - stationsFetchedAt > STATION_TTL_MS) {
const payload = await getJson(
`${STATIONS_URL}?f=json&limit=${STATION_LIMIT}`,
STATION_LIMIT,
signal,
);
const next = normalizeAqhiStations(payload);
if (!next) throw new Error('Malformed AQHI station response');
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0) throw new Error('No AQHI stations available');
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
OBSERVATION_LIMIT,
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings) throw new Error('Malformed AQHI observation response');
return readings;
},
};
}
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEcccAirQualitySource } from './source.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const HOUR = 60 * 60 * 1000;
const station = (id) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
properties: { location_id: id, location_name_en: id },
});
const observation = (id, at = NOW, aqhi = 3) => ({
type: 'Feature',
properties: {
location_id: id,
aqhi,
observation_datetime: new Date(at - 10 * 60_000).toISOString(),
},
});
const json = (body, init) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
...init,
});
/** A fetch that serves the station catalog and observations from mutable state. */
function feed() {
const state = {
clock: NOW,
stations: [station('A')],
stationIds: null,
urls: [],
};
state.source = createEcccAirQualitySource({
now: () => state.clock,
fetchImpl: async (url) => {
state.urls.push(url);
return json({
type: 'FeatureCollection',
// Every known station reports ten minutes before the current clock.
features: url.includes('aqhi-stations')
? state.stations
: (state.stationIds || ['A']).map((id) =>
observation(id, state.clock),
),
});
},
});
state.stationRequests = () =>
state.urls.filter((url) => url.includes('aqhi-stations')).length;
return state;
}
test('the station catalog is cached for twelve hours, then refetched', async () => {
const state = feed();
await state.source.getSnapshot();
state.clock = NOW + 12 * HOUR;
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 1, 'within the TTL the cache serves');
state.clock = NOW + 12 * HOUR + 1;
await state.source.getSnapshot();
assert.equal(
state.stationRequests(),
2,
'past the TTL the catalog refreshes',
);
});
test('an empty catalog refresh keeps the previous catalog', async () => {
const state = feed();
await state.source.getSnapshot();
state.stations = [];
state.clock = NOW + 13 * HOUR;
const readings = await state.source.getSnapshot();
assert.deepEqual(
readings.map((reading) => reading.stationId),
['A'],
);
// The empty refresh did not count as fresh, so the next call retries it.
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 3);
});
test('an empty first catalog is an error, not an empty layer', async () => {
const state = feed();
state.stations = [];
await assert.rejects(state.source.getSnapshot(), /No AQHI stations/);
});
test('requests ask for JSON, the latest observations only, and a bounded page', async () => {
const state = feed();
await state.source.getSnapshot();
const [stations, observations] = state.urls.map((url) => new URL(url));
assert.equal(stations.pathname, '/collections/aqhi-stations/items');
assert.equal(stations.searchParams.get('f'), 'json');
assert.equal(stations.searchParams.get('limit'), '500');
assert.equal(
observations.pathname,
'/collections/aqhi-observations-realtime/items',
);
// Without latest=true the collection returns the full history (~2,000 rows).
assert.equal(observations.searchParams.get('latest'), 'true');
assert.equal(observations.searchParams.get('limit'), '500');
});
test('rows past the requested limit are ignored', async () => {
const state = feed();
state.stations = Array.from({ length: 600 }, (_, index) =>
station(`S${String(index).padStart(3, '0')}`),
);
state.stationIds = state.stations.map((row) => row.properties.location_id);
const readings = await state.source.getSnapshot();
assert.equal(readings.length, 500);
});
test('a malformed catalog and HTTP errors reject', async () => {
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({ features: 'no' }),
}).getSnapshot(),
/Malformed AQHI station/,
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({}, { status: 503 }),
}).getSnapshot(),
/ECCC HTTP 503/,
);
});
test('an oversized response is refused before it is parsed', async () => {
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
new Response(new Blob([big]).stream(), {
headers: { 'content-type': 'application/json' },
}),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
json({}, { headers: { 'content-length': String(5 * 1024 * 1024) } }),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
});
test('a caller abort cancels the request and detaches its listener', async () => {
const caller = new AbortController();
let requestSignal = null;
const source = createEcccAirQualitySource({
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot({ signal: caller.signal });
caller.abort(new Error('layer disabled'));
await assert.rejects(pending, /layer disabled/);
assert.equal(requestSignal.aborted, true);
const already = new AbortController();
already.abort(new Error('before start'));
let calls = 0;
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => {
calls += 1;
return json({ features: [] });
},
}).getSnapshot({ signal: already.signal }),
/before start/,
);
assert.equal(calls, 0);
});
test('a request that outlives its deadline is aborted', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const source = createEcccAirQualitySource({
timeoutMs: 1_000,
fetchImpl: (_url, { signal }) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
});
const pending = source.getSnapshot();
t.mock.timers.tick(1_000);
await assert.rejects(pending, /ECCC request timed out/);
});
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Barcelona system's host, and the stations farthest from its registered
// centre in the live feed (checked 2026-10-16). AMBici spans the metropolitan
// area, so it lists its south-western and north-eastern ends.
const SYSTEMS = {
'barcelona-bicing': {
host: 'barcelona.publicbikesystem.net',
provider: 'Bicing',
stations: [
{ name: 'AV. RASOS DE PEGUERA, 10', lat: 41.462095, lon: 2.178959 },
{ name: 'C/ 60, NÚMERO 25', lat: 41.3467746, lon: 2.1436235 },
],
},
'barcelona-ambici': {
host: 'gbfs.nextbike.net',
provider: 'AMBici',
stations: [
{ name: 'Poal', lat: 41.268865, lon: 1.943849 },
{ name: 'Platja del Manresà', lat: 41.460101, lon: 2.267271 },
{ name: 'Can Ruti', lat: 41.482205, lon: 2.237607 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bicing and AMBici are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.provider, expected.provider);
assert.deepEqual(system.hosts, [expected.host]);
for (const feed of [
system.stationInformationUrl,
system.stationStatusUrl,
]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(
isAllowedGbfsPath(url.pathname, url.hostname),
`${id} path ${url.pathname}`,
);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km, so the test above passes
// with any loadRadiusKm; get_bike_share uses the radius as registered.
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
system.centerLat,
system.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= system.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('a camera over central Barcelona activates both systems', () => {
// Plaça de Catalunya.
const active = computeInRangeCities({ lat: 41.387, lon: 2.17 });
assert.ok(active.has('barcelona-bicing'));
assert.ok(active.has('barcelona-ambici'));
});
test('the nextbike allowance is that one host, not its domain', () => {
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net'), true);
assert.equal(isAllowedGbfsHost('api.nextbike.net'), false);
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net.example'), false);
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Bike Itaú city's PBSC host, and the station farthest from its
// registered centre in the live feed (checked 2026-10-15).
const CITIES = {
'sao-paulo-bike-itau': {
host: 'saopaulo.publicbikesystem.net',
stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }],
},
'rio-de-janeiro-bike-itau': {
host: 'riodejaneiro.publicbikesystem.net',
stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }],
},
'salvador-bike-itau': {
host: 'salvador.publicbikesystem.net',
stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }],
},
'recife-bike-itau': {
host: 'recife.publicbikesystem.net',
stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }],
},
'porto-alegre-bike-itau': {
host: 'portoalegre.publicbikesystem.net',
stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }],
},
'brasilia-bike-itau': {
host: 'brasilia.publicbikesystem.net',
stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }],
},
'curitiba-bike-itau': {
host: 'curitiba.publicbikesystem.net',
stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('the Bike Itaú systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, 'Bike Itaú');
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
+16 -2
View File
@@ -56,7 +56,7 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
*/
getStats() {
const stats = {
@@ -70,7 +70,21 @@ export function createControls({ state: layerState, services, parts, source }) {
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
}
if (layerState._error) stats.error = layerState._error;
// A failed city is only the layer's failure when it left nothing on the
// map. While other cities still render, the partial failure reads
// DEGRADED; when every in-range city failed and only previously drawn
// points remain, they read STALE.
if (layerState._failedCityIds.size > 0) {
if (layerState._stationRenderMap.size === 0) {
stats.error = 'GBFS fetch error';
} else {
stats.degraded = true;
const allFailed = Array.from(layerState._activeCityIds).every(
(cityId) => layerState._failedCityIds.has(cityId),
);
if (allFailed) stats.stale = true;
}
}
return stats;
},
};
@@ -0,0 +1,376 @@
// BIKESHARE FAILURE REPORTING — one dead city is not a dead layer.
//
// City feeds are fetched independently, but the layer reported failure through
// a single flag. A city whose operator had retired its feed set that flag on
// every proximity check, and the only code that cleared it ran on a successful
// *activation* — which an already-loaded city never repeats. The result was a
// permanent LOAD FAILED over a map drawing a thousand live stations.
//
// These cases pin the rule that replaced it: the layer reports failure only
// when it has nothing to show, and a city that comes back clears its own
// failure rather than waiting for a neighbour to succeed.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createControls } from './controls.js';
function makeLayer() {
const services = {
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
};
const state = createState({ services });
const parts = { queries: { collectDetectableStations: () => [] } };
const { methods } = createControls({ state, services, parts, source: {} });
return { state, methods };
}
/** Stand in for a rendered station without reaching into the renderer. */
function renderStations(state, count) {
for (let i = 0; i < count; i += 1) state._stationRenderMap.set(`s${i}`, {});
state._count = state._stationRenderMap.size;
}
test('a failed city does not fail a layer that is drawing stations', () => {
const { state, methods } = makeLayer();
renderStations(state, 1069);
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 1069);
assert.equal(
stats.error,
undefined,
'a layer drawing 1069 stations reported LOAD FAILED',
);
});
test('the layer reports failure when a failed city left it with nothing', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.error, 'GBFS fetch error');
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
});
test('a recovered city clears its own failure', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
assert.equal(methods.getStats().error, 'GBFS fetch error');
state._failedCityIds.delete('buffalo-reddy');
assert.equal(
methods.getStats().error,
undefined,
'the failure outlived the city that caused it',
);
});
// The cases above pin how getStats() reads failure state. The cases below
// drive the real proximity check, status poll and lifecycle against a fake
// GBFS source, so the failure state they read is the state the layer builds.
import * as Cesium from 'cesium';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
const TORONTO = 'toronto-bike-share';
const BUFFALO = 'buffalo-reddy';
// Within range of both Toronto and Buffalo.
const BETWEEN = { lat: 43.25, lon: -79.1 };
// Within range of Toronto only.
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
// Within range of Buffalo only.
const BUFFALO_ONLY = { lat: 42.5, lon: -78.0 };
const LOW_M = 10_000;
// disable() detaches a keydown listener; node has no document.
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
/** A GBFS source whose per-city answers each test can change. */
function makeSource() {
const cityOf = (url) => {
for (const [id, city] of CITY_BY_ID) {
if (url === city.stationInformationUrl) return { id, kind: 'info' };
if (url === city.stationStatusUrl) return { id, kind: 'status' };
}
throw new Error(`unexpected GBFS url ${url}`);
};
const source = {
dead: new Set(),
deadStatus: new Set(),
/** Optional per-call override: (cityId, kind) => Promise | undefined. */
hook: null,
async getStations(url, { signal } = {}) {
const { id, kind } = cityOf(url);
const hooked = source.hook?.(id, kind);
if (hooked) return hooked;
await new Promise((resolve) => setTimeout(resolve, 1));
signal?.throwIfAborted();
if (source.dead.has(id)) throw new Error('GBFS HTTP 404');
if (kind === 'status' && source.deadStatus.has(id))
throw new Error('GBFS HTTP 503');
const city = CITY_BY_ID.get(id);
if (kind === 'info') {
return {
data: {
stations: [
{
station_id: `${id}-1`,
name: `${id} dock`,
lat: city.centerLat,
lon: city.centerLon,
capacity: 10,
},
],
},
};
}
return {
data: {
stations: [
{
station_id: `${id}-1`,
num_bikes_available: 3,
num_docks_available: 7,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
};
},
};
return source;
}
/** Assemble the layer from its real parts around a camera the test moves. */
function makeLiveLayer() {
const source = makeSource();
const services = {
render: { governorRequestRender() {} },
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
// Selection owns a DOM click handler; nothing here selects a station.
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.queries = { collectDetectableStations: () => [] };
parts.lifecycle = createLifecycle(context);
const { methods: controls } = createControls(context);
const camera = { ...BETWEEN, height: LOW_M };
const viewer = {
camera: {
get positionCartographic() {
return Cesium.Cartographic.fromDegrees(
camera.lon,
camera.lat,
camera.height,
);
},
computeViewRectangle: () => undefined,
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: { globe: {}, primitives: { add() {}, remove() {} } },
};
parts.lifecycle.methods.init(viewer);
return {
state,
source,
stats: () => controls.getStats(),
moveTo(place, height = LOW_M) {
Object.assign(camera, place, { height });
return parts.viewport.runProximityCheck();
},
async enable() {
parts.lifecycle.methods.enable(viewer);
await settle();
},
disable: () => parts.lifecycle.methods.disable(viewer),
poll: () => parts.ingestion.methods.update(),
};
}
/** Let fire-and-forget activation started by enable() finish. */
async function settle() {
for (let i = 0; i < 5; i += 1)
await new Promise((resolve) => setTimeout(resolve, 2));
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
test('a city that fails beside one that renders reads DEGRADED, not healthy', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
const stats = layer.stats();
assert.equal(stats.count, 1, 'Toronto should still draw');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, true, 'the Buffalo failure was hidden');
assert.equal(stats.stale, undefined);
});
test('a failed city that comes back clears its own failure', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
assert.equal(layer.stats().degraded, true);
layer.source.dead.clear();
await layer.moveTo(BETWEEN);
const stats = layer.stats();
assert.equal(stats.count, 2);
assert.equal(stats.degraded, undefined);
assert.equal(stats.error, undefined);
});
test('a status poll that recovers clears the failure the last poll set', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
layer.source.deadStatus.add(TORONTO);
await layer.poll();
assert.equal(layer.stats().degraded, true);
layer.source.deadStatus.clear();
await layer.poll();
assert.equal(layer.stats().degraded, undefined);
assert.equal(layer.stats().stale, undefined);
});
test('a failure does not outlive its city leaving range', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
await layer.moveTo(BUFFALO_ONLY);
assert.equal(layer.stats().error, 'GBFS fetch error');
// Low over the open Atlantic: nothing in range is empty, not broken.
await layer.moveTo({ lat: 30, lon: -40 });
const stats = layer.stats();
assert.equal(stats.count, 0);
assert.equal(stats.error, undefined, 'LOAD FAILED over empty ocean');
assert.equal(stats.degraded, undefined);
});
test('zooming out above the altitude gate drops a city failure', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
await layer.moveTo(BUFFALO_ONLY);
assert.equal(layer.stats().error, 'GBFS fetch error');
await layer.moveTo(BUFFALO_ONLY, 200_000);
const stats = layer.stats();
assert.equal(stats.count, 0);
assert.equal(stats.error, undefined, 'LOAD FAILED above the gate');
});
test('a late rejection from before a disable/re-enable is ignored', async () => {
const layer = makeLiveLayer();
await layer.moveTo(BUFFALO_ONLY);
const first = deferred();
// The first info request hangs and ignores its abort signal.
layer.source.hook = (id, kind) => {
if (id !== BUFFALO || kind !== 'info') return undefined;
layer.source.hook = null;
return first.promise;
};
await layer.enable();
layer.disable();
await layer.enable();
assert.equal(layer.stats().count, 1, 're-enable should draw Buffalo');
first.reject(new Error('GBFS HTTP 500'));
await settle();
const stats = layer.stats();
assert.equal(stats.count, 1, 'a stale rejection tore down live points');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, undefined);
});
test('a late status poll rejection from before a disable/re-enable is ignored', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
const first = deferred();
layer.source.hook = (id, kind) => {
if (id !== TORONTO || kind !== 'status') return undefined;
layer.source.hook = null;
return first.promise;
};
const poll = layer.poll();
layer.disable();
await layer.enable();
first.reject(new Error('GBFS HTTP 500'));
await poll;
const stats = layer.stats();
assert.equal(stats.count, 1);
assert.equal(stats.degraded, undefined);
assert.equal(stats.error, undefined);
});
test('every status poll failing over cached points reads STALE, not fresh', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
const loadedAt = layer.stats().lastUpdate;
assert.ok(loadedAt, 'activation should record freshness');
layer.source.deadStatus.add(TORONTO);
await new Promise((resolve) => setTimeout(resolve, 5));
await layer.poll();
const stats = layer.stats();
assert.equal(stats.count, 1, 'the cached points stay drawn');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, true);
assert.equal(stats.stale, true);
assert.equal(stats.lastUpdate, loadedAt, 'a failed poll advanced freshness');
});
+17 -10
View File
@@ -97,7 +97,10 @@ export function createIngestion({
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
const stationMap = parts.model.stationsWithinCity(
city,
parts.model.parseStationInformation(payload),
);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
@@ -171,30 +174,34 @@ export function createIngestion({
const generation = layerState._proximityGeneration;
const cityIds = Array.from(layerState._activeCityIds);
const isCurrent = (cityId) =>
layerState._enabled &&
layerState._activeCityIds.has(cityId) &&
generation === layerState._proximityGeneration;
let refreshed = false;
await Promise.all(
cityIds.map(async (cityId) => {
try {
const statusMap = await loadCityStationStatus(cityId, generation);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
if (!isCurrent(cityId)) return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._failedCityIds.delete(cityId);
refreshed = true;
} catch (error) {
if (error?.name === 'AbortError') return;
if (error?.name === 'AbortError' || !isCurrent(cityId)) return;
console.warn(
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
layerState._failedCityIds.add(cityId);
}
}),
);
layerState._count = layerState._stationRenderMap.size;
if (layerState._count > 0) layerState._lastUpdate = Date.now();
// Freshness advances only on a poll that actually refreshed a city.
if (refreshed && layerState._count > 0)
layerState._lastUpdate = Date.now();
},
};
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more