Merge 0a8e07c9fb into 759652207f
This commit is contained in:
commit
dac3032cab
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -5,6 +5,21 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Transit layer** — live public-transit vehicles from open GTFS-Realtime
|
||||
feeds. Seven keyless, openly licensed regions ship in the registry (MBTA
|
||||
Boston, CapMetro Austin, Metro Transit Minneapolis–St Paul, HSL Helsinki,
|
||||
OVapi Netherlands, Entur Norway, TransLink South East Queensland). Vehicles
|
||||
render as points colored by mode (bus, tram, metro, rail, ferry) and glide
|
||||
from their last drawn position to each new fix over the 15 s poll interval;
|
||||
clicking one opens a card with route, speed, heading, next stop, occupancy
|
||||
and report age. Only feeds whose coverage contains the camera's look-at point
|
||||
are polled. The new `/api/transit` proxy fetches registered feed URLs only,
|
||||
decodes the protobuf server-side, caches 15 s in memory, and serves a stale
|
||||
snapshot for up to 10 minutes when an upstream fails. Share links carry the
|
||||
layer as token `p`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mapped-site outages show their scheduled retry countdown and distinguish
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ How to read this:
|
|||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **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)" |
|
||||
| **GTFS-Realtime vehicle-position feeds** — MBTA (Boston), CapMetro (Austin), Metro Transit (Minneapolis–St Paul), HSL (Helsinki), OVapi (Netherlands), Entur (Norway), TransLink (South East Queensland) | The Transit layer's live vehicles, via `/api/transit` | Per feed, all keyless: MassDOT Developers License Agreement · Texas Open Data Portal terms (CapMetro) · Minnesota public domain (Government Data Practices Act) · CC BY 4.0 (HSL) · CC0 / free use (OVapi README) · NLOD (Entur) · CC BY 4.0 (TransLink) — see the note below | Each operator is credited in the Data attribution popover the moment its vehicles are shown (`src/data/transitFeeds.js` holds every license URL) |
|
||||
|
||||
### Notes on the live sources
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ How to read this:
|
|||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **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 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; 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).
|
||||
- **Transit (GTFS-Realtime).** `/api/transit/vehicles/<id>` fetches only the feed URLs registered in `src/data/transitFeeds.js` — the browser names a registered id, never a URL. Every registered feed is keyless and openly licensed for display with attribution: **MBTA** under the [MassDOT Developers License Agreement](https://www.mbta.com/developers/gtfs-realtime); **CapMetro Austin** from the [Texas Open Data Portal](https://data.texas.gov/Transportation/CapMetro-Vehicle-Positions-PB-File/eiei-9rpf) (attribution: Capital Metropolitan Transportation Authority); **Metro Transit** Minneapolis–St Paul, public domain under the Minnesota Government Data Practices Act; **HSL** Helsinki under [CC BY 4.0](https://www.hsl.fi/en/hsl/open-data); **OVapi** Netherlands, free to use per its [README](https://gtfs.ovapi.nl/README) (best-effort, identify yourself in the User-Agent, never claim to represent an operator); **Entur** Norway under the [Norwegian Licence for Open Government Data](https://developer.entur.org/pages-intro-authentication) (the proxy sends the required `ET-Client-Name` header); **TransLink** South East Queensland under [CC BY 4.0](https://translink.com.au/about-translink/open-data). Feeds are fetched over https only (a redirect must stay on https), capped at 8 MB, decoded server-side, cached in memory for 15 s per feed and served stale for at most 10 minutes during an upstream outage; nothing is written to disk and nothing is bundled or redistributed. Vehicle positions are operator-reported telemetry, typically 5–60 s old on arrival and shown one poll interval behind so they glide; they are not evidence of where a vehicle is at this second. NYCT's subway feed is deliberately absent: it publishes stop-relative positions without coordinates.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
|
|||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Thirteen layers and map sources. **Eleven have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
Fourteen layers and map sources. **Twelve have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
|-------|--------------|--------|------|
|
||||
|
|
@ -276,6 +276,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
|
|||
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📻 **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 | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries gliding between fixes, colored by mode — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ The data proxies in `vite.config.js` are written so the browser cannot turn the
|
|||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL. Upstreams are https-only (a redirect must land on https), bodies are capped at 8 MB, the protobuf is decoded server-side, and snapshots live 15 s in memory with no disk cache.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
|
|
|
|||
|
|
@ -1621,11 +1621,35 @@ its criteria cannot be silently ignored.
|
|||
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + 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 |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/data/transit.js` | `/api/transit` | 15s (poll + glide) |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
|
||||
Transit polls only the registered feeds whose coverage circle contains the
|
||||
camera's look-at point (with a 40 km hysteresis once active) and only below a
|
||||
3,000 km altitude gate, so a session over Boston costs one MBTA request per
|
||||
15 s and nothing elsewhere. Each vehicle is one point primitive colored by mode
|
||||
(bus, tram, metro, rail, ferry — the feed's default refined by per-operator
|
||||
route-id hints); on every poll it glides from its last drawn position to the
|
||||
new fix over the next poll interval, one interval behind real time, and a
|
||||
vehicle missing from two consecutive polls is removed. Fixes older than ten
|
||||
minutes are ignored. Surface height is sampled once per ~0.002° cell and at
|
||||
most 300 cells per poll, never per frame; points render through the mesh so a
|
||||
bus under a 3D roof stays visible. Clicking a vehicle opens a protected
|
||||
shared-host card (route, speed, heading, stop status, occupancy, report age)
|
||||
that re-anchors every 250 ms while the vehicle moves; Escape or a click on empty
|
||||
globe clears it. The layer holds continuous render only while it has vehicles.
|
||||
The `/api/transit` proxy resolves an id against the registry, fetches https
|
||||
upstreams only (redirects must stay on https), caps bodies at 8 MB, decodes the
|
||||
protobuf server-side with `pbf` (`src/data/gtfsRealtime.js` — no
|
||||
`gtfs-realtime-bindings`), caches each feed in memory for 15 s with a
|
||||
single-flight refresh, and serves a stale snapshot marked `X-GEV-Cache:
|
||||
STALE-ERROR` for up to 10 minutes when the upstream fails; the layer reads that
|
||||
header as STALE. Each operator's credit is registered dynamically the first
|
||||
time its vehicles render.
|
||||
|
||||
`src/data/militaryAwareness.js` remains registered internally as the Contacts
|
||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
||||
|
|
|
|||
|
|
@ -131,6 +131,11 @@ export const DATA_CREDITS = [
|
|||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
html:
|
||||
|
|
@ -201,6 +206,26 @@ export const NATURAL_EARTH_CREDIT = {
|
|||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* @module gtfsRealtime
|
||||
* @description Minimal GTFS-Realtime decoder for VehiclePosition feeds.
|
||||
*
|
||||
* Decodes only what the Transit layer needs from a `FeedMessage` — the header
|
||||
* and every entity's `VehiclePosition` — straight from the protobuf wire
|
||||
* format with `pbf` (already a dependency via the TomTom flow tiles). No
|
||||
* `gtfs-realtime-bindings`, no protobufjs, no generated code: the GTFS-RT
|
||||
* schema is stable, and field numbers are the contract.
|
||||
*
|
||||
* Field numbers (gtfs-realtime.proto, v2.0):
|
||||
* FeedMessage 1 header, 2 entity[]
|
||||
* FeedHeader 1 gtfs_realtime_version, 2 incrementality, 3 timestamp
|
||||
* FeedEntity 1 id, 2 is_deleted, 3 trip_update, 4 vehicle, 5 alert
|
||||
* VehiclePosition 1 trip, 2 position, 3 current_stop_sequence,
|
||||
* 4 current_status, 5 timestamp, 6 congestion_level,
|
||||
* 7 stop_id, 8 vehicle, 9 occupancy_status
|
||||
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
|
||||
* 4 schedule_relationship, 5 route_id, 6 direction_id
|
||||
* Position 1 latitude, 2 longitude, 3 bearing, 4 odometer, 5 speed
|
||||
* VehicleDescriptor 1 id, 2 label, 3 license_plate
|
||||
*
|
||||
* Unknown fields (extensions, newer additions) are skipped by pbf, so a feed
|
||||
* that carries OVapi/NYCT extensions decodes the same as a plain one.
|
||||
*
|
||||
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
|
||||
*/
|
||||
|
||||
import { PbfReader } from 'pbf';
|
||||
|
||||
/** VehiclePosition.VehicleStopStatus enum → label. */
|
||||
export const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
0: 'INCOMING_AT',
|
||||
1: 'STOPPED_AT',
|
||||
2: 'IN_TRANSIT_TO',
|
||||
});
|
||||
|
||||
/** VehiclePosition.OccupancyStatus enum → label. */
|
||||
export const OCCUPANCY_STATUS = Object.freeze({
|
||||
0: 'EMPTY',
|
||||
1: 'MANY_SEATS_AVAILABLE',
|
||||
2: 'FEW_SEATS_AVAILABLE',
|
||||
3: 'STANDING_ROOM_ONLY',
|
||||
4: 'CRUSHED_STANDING_ROOM_ONLY',
|
||||
5: 'FULL',
|
||||
6: 'NOT_ACCEPTING_PASSENGERS',
|
||||
7: 'NO_DATA_AVAILABLE',
|
||||
8: 'NOT_BOARDABLE',
|
||||
});
|
||||
|
||||
function readFeedHeader(tag, header, pbf) {
|
||||
if (tag === 1) header.version = pbf.readString();
|
||||
else if (tag === 2) header.incrementality = pbf.readVarint();
|
||||
else if (tag === 3) header.timestamp = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readTripDescriptor(tag, trip, pbf) {
|
||||
if (tag === 1) trip.tripId = pbf.readString();
|
||||
else if (tag === 2) trip.startTime = pbf.readString();
|
||||
else if (tag === 3) trip.startDate = pbf.readString();
|
||||
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
|
||||
else if (tag === 5) trip.routeId = pbf.readString();
|
||||
else if (tag === 6) trip.directionId = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readPosition(tag, position, pbf) {
|
||||
if (tag === 1) position.latitude = pbf.readFloat();
|
||||
else if (tag === 2) position.longitude = pbf.readFloat();
|
||||
else if (tag === 3) position.bearing = pbf.readFloat();
|
||||
else if (tag === 4) position.odometer = pbf.readDouble();
|
||||
else if (tag === 5) position.speed = pbf.readFloat();
|
||||
}
|
||||
|
||||
function readVehicleDescriptor(tag, descriptor, pbf) {
|
||||
if (tag === 1) descriptor.id = pbf.readString();
|
||||
else if (tag === 2) descriptor.label = pbf.readString();
|
||||
else if (tag === 3) descriptor.licensePlate = pbf.readString();
|
||||
}
|
||||
|
||||
function readVehiclePosition(tag, vehicle, pbf) {
|
||||
if (tag === 1) vehicle.trip = pbf.readMessage(readTripDescriptor, {});
|
||||
else if (tag === 2) vehicle.position = pbf.readMessage(readPosition, {});
|
||||
else if (tag === 3) vehicle.currentStopSequence = pbf.readVarint();
|
||||
else if (tag === 4) vehicle.currentStatus = pbf.readVarint();
|
||||
else if (tag === 5) vehicle.timestamp = pbf.readVarint();
|
||||
else if (tag === 6) vehicle.congestionLevel = pbf.readVarint();
|
||||
else if (tag === 7) vehicle.stopId = pbf.readString();
|
||||
else if (tag === 8) vehicle.vehicle = pbf.readMessage(readVehicleDescriptor, {});
|
||||
else if (tag === 9) vehicle.occupancyStatus = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readFeedEntity(tag, entity, pbf) {
|
||||
if (tag === 1) entity.id = pbf.readString();
|
||||
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
|
||||
else if (tag === 4) entity.vehicle = pbf.readMessage(readVehiclePosition, {});
|
||||
// 3 (trip_update) and 5 (alert) are skipped: pbf advances past any tag the
|
||||
// reader leaves untouched.
|
||||
}
|
||||
|
||||
function readFeedMessage(tag, message, pbf) {
|
||||
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
|
||||
else if (tag === 2) message.entities.push(pbf.readMessage(readFeedEntity, {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw GTFS-Realtime FeedMessage.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ header: {version?: string, incrementality?: number, timestamp?: number}, entities: object[] }}
|
||||
*/
|
||||
export function decodeFeedMessage(bytes) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const pbf = new PbfReader(view);
|
||||
return pbf.readFields(readFeedMessage, { header: {}, entities: [] });
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a lat/lon pair is a usable surface position. Rejects non-finite
|
||||
* values, out-of-range degrees, and the (0,0) null island a cold GPS reports.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPlausibleVehiclePosition(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
|
||||
if (Math.abs(lat) < 1e-6 && Math.abs(lon) < 1e-6) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one decoded FeedEntity into the record the Transit layer renders,
|
||||
* or null when the entity carries no usable vehicle position.
|
||||
* @param {object} entity Decoded FeedEntity.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function normalizeVehicleEntity(entity) {
|
||||
if (!entity || entity.isDeleted === true) return null;
|
||||
const vehicle = entity.vehicle;
|
||||
const position = vehicle?.position;
|
||||
if (!vehicle || !position) return null;
|
||||
const lat = finiteOrNull(position.latitude);
|
||||
const lon = finiteOrNull(position.longitude);
|
||||
if (!isPlausibleVehiclePosition(lat, lon)) return null;
|
||||
const id = nonEmptyString(vehicle.vehicle?.id) || nonEmptyString(entity.id);
|
||||
if (!id) return null;
|
||||
const bearing = finiteOrNull(position.bearing);
|
||||
const speed = finiteOrNull(position.speed);
|
||||
const timestamp = Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0 ? vehicle.timestamp : null;
|
||||
return {
|
||||
id,
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lon: Number(lon.toFixed(6)),
|
||||
bearing: bearing === null ? null : ((bearing % 360) + 360) % 360,
|
||||
speedMps: speed === null || speed < 0 ? null : speed,
|
||||
timestamp,
|
||||
routeId: nonEmptyString(vehicle.trip?.routeId),
|
||||
tripId: nonEmptyString(vehicle.trip?.tripId),
|
||||
directionId: Number.isInteger(vehicle.trip?.directionId) ? vehicle.trip.directionId : null,
|
||||
label: nonEmptyString(vehicle.vehicle?.label),
|
||||
stopId: nonEmptyString(vehicle.stopId),
|
||||
status: VEHICLE_STOP_STATUS[vehicle.currentStatus] || null,
|
||||
occupancy: OCCUPANCY_STATUS[vehicle.occupancyStatus] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VehiclePositions feed into the compact snapshot the proxy serves.
|
||||
* Duplicate vehicle ids keep the newest timestamp (feeds occasionally repeat
|
||||
* a vehicle across two trip entities during a handover).
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ version: string|null, timestamp: number|null, entityCount: number, vehicles: object[] }}
|
||||
*/
|
||||
export function decodeVehiclePositions(bytes) {
|
||||
const message = decodeFeedMessage(bytes);
|
||||
const byId = new Map();
|
||||
for (const entity of message.entities) {
|
||||
const record = normalizeVehicleEntity(entity);
|
||||
if (!record) continue;
|
||||
const existing = byId.get(record.id);
|
||||
if (!existing || (record.timestamp ?? 0) >= (existing.timestamp ?? 0)) {
|
||||
byId.set(record.id, record);
|
||||
}
|
||||
}
|
||||
const headerTimestamp = message.header?.timestamp;
|
||||
return {
|
||||
version: nonEmptyString(message.header?.version),
|
||||
timestamp: Number.isFinite(headerTimestamp) && headerTimestamp > 0 ? headerTimestamp : null,
|
||||
entityCount: message.entities.length,
|
||||
vehicles: [...byId.values()],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
decodeFeedMessage,
|
||||
decodeVehiclePositions,
|
||||
isPlausibleVehiclePosition,
|
||||
normalizeVehicleEntity,
|
||||
} from './gtfsRealtime.js';
|
||||
|
||||
/** Encode a nested message with a fresh writer and embed it as a bytes field. */
|
||||
function embed(writer, tag, build) {
|
||||
const inner = new PbfWriter();
|
||||
build(inner);
|
||||
writer.writeBytesField(tag, inner.finish());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-encode one FeedEntity carrying a VehiclePosition, from the proto field
|
||||
* numbers — the same contract the decoder reads, written independently.
|
||||
*/
|
||||
function writeVehicleEntity(writer, {
|
||||
entityId, vehicleId, label, lat, lon, bearing, speed, timestamp, routeId, tripId,
|
||||
directionId, stopId, status, occupancy, isDeleted, extraUnknownFields = false,
|
||||
}) {
|
||||
embed(writer, 2, (entity) => {
|
||||
if (entityId != null) entity.writeStringField(1, entityId);
|
||||
if (isDeleted) entity.writeBooleanField(2, true);
|
||||
embed(entity, 4, (vehicle) => {
|
||||
if (routeId != null || tripId != null || directionId != null) {
|
||||
embed(vehicle, 1, (trip) => {
|
||||
if (tripId != null) trip.writeStringField(1, tripId);
|
||||
if (routeId != null) trip.writeStringField(5, routeId);
|
||||
if (directionId != null) trip.writeVarintField(6, directionId);
|
||||
});
|
||||
}
|
||||
if (lat != null) {
|
||||
embed(vehicle, 2, (position) => {
|
||||
position.writeFloatField(1, lat);
|
||||
position.writeFloatField(2, lon);
|
||||
if (bearing != null) position.writeFloatField(3, bearing);
|
||||
if (speed != null) position.writeFloatField(5, speed);
|
||||
if (extraUnknownFields) position.writeDoubleField(4, 123456.5); // odometer
|
||||
});
|
||||
}
|
||||
if (status != null) vehicle.writeVarintField(4, status);
|
||||
if (timestamp != null) vehicle.writeVarintField(5, timestamp);
|
||||
if (stopId != null) vehicle.writeStringField(7, stopId);
|
||||
if (vehicleId != null || label != null) {
|
||||
embed(vehicle, 8, (descriptor) => {
|
||||
if (vehicleId != null) descriptor.writeStringField(1, vehicleId);
|
||||
if (label != null) descriptor.writeStringField(2, label);
|
||||
});
|
||||
}
|
||||
if (occupancy != null) vehicle.writeVarintField(9, occupancy);
|
||||
if (extraUnknownFields) vehicle.writeStringField(1001, 'operator-extension');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFeed(entities, { version = '2.0', timestamp = 1_788_936_000 } = {}) {
|
||||
const writer = new PbfWriter();
|
||||
embed(writer, 1, (header) => {
|
||||
header.writeStringField(1, version);
|
||||
header.writeVarintField(2, 0);
|
||||
header.writeVarintField(3, timestamp);
|
||||
});
|
||||
for (const entity of entities) writeVehicleEntity(writer, entity);
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('decodes header and every VehiclePosition field the layer renders', () => {
|
||||
const bytes = encodeFeed([{
|
||||
entityId: 'e1', vehicleId: 'bus-17', label: '1557', lat: 44.927551, lon: -93.386711,
|
||||
bearing: 248, speed: 11.2, timestamp: 1_788_936_945, routeId: '17', tripId: '1134773',
|
||||
directionId: 0, stopId: '57458', status: 1, occupancy: 2,
|
||||
}]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.timestamp, 1_788_936_000);
|
||||
assert.equal(snapshot.entityCount, 1);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
const [v] = snapshot.vehicles;
|
||||
assert.equal(v.id, 'bus-17');
|
||||
assert.equal(v.label, '1557');
|
||||
assert.ok(Math.abs(v.lat - 44.927551) < 1e-5);
|
||||
assert.ok(Math.abs(v.lon + 93.386711) < 1e-5);
|
||||
assert.equal(v.bearing, 248);
|
||||
assert.ok(Math.abs(v.speedMps - 11.2) < 1e-5);
|
||||
assert.equal(v.timestamp, 1_788_936_945);
|
||||
assert.equal(v.routeId, '17');
|
||||
assert.equal(v.tripId, '1134773');
|
||||
assert.equal(v.directionId, 0);
|
||||
assert.equal(v.stopId, '57458');
|
||||
assert.equal(v.status, 'STOPPED_AT');
|
||||
assert.equal(v.occupancy, 'FEW_SEATS_AVAILABLE');
|
||||
});
|
||||
|
||||
test('unknown and extension fields are skipped, not misread', () => {
|
||||
const bytes = encodeFeed([{ entityId: 'x', lat: 51.3, lon: 5.33, extraUnknownFields: true }]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].id, 'x');
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 51.3) < 1e-5);
|
||||
});
|
||||
|
||||
test('entities without a usable position are dropped and never throw', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'no-position' },
|
||||
{ entityId: 'deleted', lat: 1, lon: 1, isDeleted: true },
|
||||
{ entityId: 'null-island', lat: 0, lon: 0 },
|
||||
{ entityId: 'out-of-range', lat: 91, lon: 10 },
|
||||
{ entityId: 'nan', lat: Number.NaN, lon: 10 },
|
||||
{ entityId: 'keeper', lat: 60.17, lon: 24.94 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.entityCount, 6);
|
||||
assert.deepEqual(snapshot.vehicles.map((v) => v.id), ['keeper']);
|
||||
});
|
||||
|
||||
test('vehicle id falls back to the entity id and the vehicle is dropped when neither exists', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'entity-only', lat: 42.3, lon: -71.1 },
|
||||
{ lat: 42.3, lon: -71.1 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.deepEqual(snapshot.vehicles.map((v) => v.id), ['entity-only']);
|
||||
});
|
||||
|
||||
test('duplicate vehicle ids keep the newest timestamp', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'a', vehicleId: 'v1', lat: 42.30, lon: -71.10, timestamp: 100 },
|
||||
{ entityId: 'b', vehicleId: 'v1', lat: 42.31, lon: -71.11, timestamp: 200 },
|
||||
{ entityId: 'c', vehicleId: 'v1', lat: 42.32, lon: -71.12, timestamp: 150 },
|
||||
]);
|
||||
const snapshot = decodeVehiclePositions(bytes);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
assert.equal(snapshot.vehicles[0].timestamp, 200);
|
||||
assert.ok(Math.abs(snapshot.vehicles[0].lat - 42.31) < 1e-5);
|
||||
});
|
||||
|
||||
test('bearing is normalized to [0, 360) and negative speed is treated as unknown', () => {
|
||||
const bytes = encodeFeed([
|
||||
{ entityId: 'neg', lat: 30.2, lon: -97.7, bearing: -90, speed: -1 },
|
||||
{ entityId: 'wrap', lat: 30.2, lon: -97.7, bearing: 450 },
|
||||
]);
|
||||
const [neg, wrap] = decodeVehiclePositions(bytes).vehicles;
|
||||
assert.equal(neg.bearing, 270);
|
||||
assert.equal(neg.speedMps, null);
|
||||
assert.equal(wrap.bearing, 90);
|
||||
});
|
||||
|
||||
test('missing header yields null version/timestamp rather than garbage', () => {
|
||||
const writer = new PbfWriter();
|
||||
writeVehicleEntity(writer, { entityId: 'solo', lat: 10, lon: 10 });
|
||||
const snapshot = decodeVehiclePositions(writer.finish());
|
||||
assert.equal(snapshot.version, null);
|
||||
assert.equal(snapshot.timestamp, null);
|
||||
assert.equal(snapshot.vehicles.length, 1);
|
||||
});
|
||||
|
||||
test('decodeFeedMessage accepts an ArrayBuffer and an empty feed', () => {
|
||||
const empty = decodeFeedMessage(new ArrayBuffer(0));
|
||||
assert.deepEqual(empty, { header: {}, entities: [] });
|
||||
const bytes = encodeFeed([]);
|
||||
const asBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
assert.equal(decodeFeedMessage(asBuffer).header.version, '2.0');
|
||||
});
|
||||
|
||||
test('isPlausibleVehiclePosition and normalizeVehicleEntity guard their inputs', () => {
|
||||
assert.equal(isPlausibleVehiclePosition(45, 90), true);
|
||||
assert.equal(isPlausibleVehiclePosition(0, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(-90.0001, 0), false);
|
||||
assert.equal(isPlausibleVehiclePosition(1, Infinity), false);
|
||||
assert.equal(normalizeVehicleEntity(null), null);
|
||||
assert.equal(normalizeVehicleEntity({ id: 'no-vehicle' }), null);
|
||||
assert.equal(normalizeVehicleEntity({ id: 'v', vehicle: { position: { latitude: 1, longitude: 2 }, currentStatus: 99 } })?.status, null);
|
||||
});
|
||||
|
|
@ -291,6 +291,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'transit', token: 'p', disposition: 'enabled-only' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
|
|
|
|||
|
|
@ -155,8 +155,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, 16);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 17);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
|
|
|||
|
|
@ -2176,6 +2176,16 @@ export class DataLayerManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the toggle panel now. For layers whose data arrives outside their
|
||||
* manager tick (camera-driven loads such as Transit's proximity polls), so a
|
||||
* row shows its count when the data lands instead of at the next interval.
|
||||
* One DOM pass; skipped while the document is hidden.
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
this._refreshTogglePanel();
|
||||
}
|
||||
|
||||
_refreshTogglePanel() {
|
||||
if (!this._toggleContainer) return;
|
||||
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
|||
'cctv',
|
||||
'firms',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'ais',
|
||||
'military',
|
||||
'flights',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,778 @@
|
|||
/**
|
||||
* @module transit
|
||||
* @description Live public transit — buses, trams, metros, trains and ferries
|
||||
* from open GTFS-Realtime VehiclePositions feeds, moving on the globe.
|
||||
*
|
||||
* One point primitive per vehicle, colored by mode. Feeds are polled through
|
||||
* the server-side `/api/transit` proxy (registered URLs only, never
|
||||
* client-supplied), and only the feeds whose coverage circle contains the
|
||||
* camera's look-at point are polled — so a session over Boston costs one MBTA
|
||||
* request every 15 s and nothing for Norway.
|
||||
*
|
||||
* Motion: each poll gives every vehicle a new fix; the point then glides from
|
||||
* where it was drawn to the new fix over the next poll interval. That is one
|
||||
* interval behind real time (the same trade the flights layer makes) and
|
||||
* buys smooth motion with no extrapolation snap-back. Vehicles missing from
|
||||
* two consecutive polls are removed.
|
||||
*
|
||||
* Height: points sit on sampled terrain/mesh height (once per ~0.002° cell,
|
||||
* bounded per poll — never per frame) and always render through the mesh so
|
||||
* a bus under a 3D building roof still shows.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { registerDynamicCredit, transitFeedCredit } from './dataCredits.js';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_MODE_ICON,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 0,
|
||||
moving: true,
|
||||
});
|
||||
|
||||
const DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
let _overlayHost = DEFAULT_OVERLAY_HOST;
|
||||
|
||||
// --- Polling / activation ---
|
||||
/** Poll interval (ms). Also the glide duration between two fixes. */
|
||||
export const TRANSIT_POLL_MS = 15_000;
|
||||
/** Camera altitude (m) above which the layer idles: a national fleet still reads at 3,000 km. */
|
||||
const ACTIVATION_ALTITUDE_M = 3_000_000;
|
||||
const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000;
|
||||
const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000;
|
||||
/** Debounce (ms) for camera-change proximity checks. */
|
||||
const CAMERA_DEBOUNCE_MS = 340;
|
||||
/** Extra coverage radius (km) granted to a feed that is already active, so it does not flap at the edge. */
|
||||
const RANGE_SLACK_KM = 40;
|
||||
/** Hard cap on rendered vehicles across all active feeds. */
|
||||
const MAX_VEHICLES_TOTAL = 15_000;
|
||||
/** A vehicle absent from this many consecutive polls is removed. */
|
||||
const MISSED_POLLS_TO_DROP = 2;
|
||||
/** Feed fixes older than this (s) are ignored — a parked bus reporting yesterday's position. */
|
||||
const VEHICLE_MAX_AGE_S = 10 * 60;
|
||||
|
||||
// --- Height sampling ---
|
||||
/** Vertical lift (m) above the sampled surface. */
|
||||
const HEIGHT_LIFT_M = 3;
|
||||
/** Above this camera altitude (m) a few metres of terrain height are invisible — skip sampling. */
|
||||
const HEIGHT_SAMPLE_MAX_ALTITUDE_M = 60_000;
|
||||
/** Sampling budget per poll; the rest render at the ellipsoid until a later poll. */
|
||||
const HEIGHT_SAMPLES_PER_POLL = 300;
|
||||
/** Height cache cell size (deg) — about 200 m. */
|
||||
const HEIGHT_CELL_DEG = 0.002;
|
||||
const HEIGHT_CACHE_MAX = 20_000;
|
||||
|
||||
// --- Rendering ---
|
||||
const POINT_PIXEL_SIZE = 7;
|
||||
const SELECTED_PIXEL_SIZE = 13;
|
||||
const POINT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(20_000, 1.0, 2_500_000, 0.45);
|
||||
const OUTLINE_COLOR = Cesium.Color.BLACK.withAlpha(0.4);
|
||||
const SELECTED_OUTLINE_COLOR = Cesium.Color.CYAN;
|
||||
/** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */
|
||||
export const TRANSIT_MODE_COLORS = Object.freeze({
|
||||
bus: '#4ade80',
|
||||
tram: '#fbbf24',
|
||||
subway: '#f87171',
|
||||
rail: '#c084fc',
|
||||
ferry: '#38bdf8',
|
||||
unknown: '#cbd5e1',
|
||||
});
|
||||
const MODE_CESIUM_COLORS = Object.fromEntries(
|
||||
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [mode, Cesium.Color.fromCssColorString(css).withAlpha(0.95)]),
|
||||
);
|
||||
|
||||
/** Throttle (ms) for re-anchoring the selected vehicle's card while it glides. */
|
||||
const SELECTED_CARD_REFRESH_MS = 250;
|
||||
|
||||
// --- Module state ---
|
||||
let _viewer = null;
|
||||
let _points = null;
|
||||
let _enabled = false;
|
||||
let _cameraChangedAttached = false;
|
||||
let _cameraDebounceTimer = null;
|
||||
let _altitudeGateOpen = false;
|
||||
let _generation = 0;
|
||||
let _preRenderRemove = null;
|
||||
let _renderHeld = false;
|
||||
let _clickHandler = null;
|
||||
|
||||
/** @type {Map<string, object>} feedId → registry entry currently polled */
|
||||
let _activeFeeds = new Map();
|
||||
/** @type {Map<string, {count:number, lastUpdate:number|null, error:string|null, stale:boolean, pollSeq:number, loading:boolean}>} */
|
||||
let _feedStatus = new Map();
|
||||
/** @type {Map<string, {controller: AbortController, promise: Promise<void>}>} feedId → request in flight */
|
||||
let _inFlight = new Map();
|
||||
/** @type {Map<string, object>} vehicle key → runtime entry */
|
||||
let _vehicles = new Map();
|
||||
/** @type {Map<string, number>} height cell → sampled height (m) */
|
||||
let _heightCache = new Map();
|
||||
let _selectedKey = null;
|
||||
let _selectedCardAt = 0;
|
||||
/** @type {{ refreshLayerStats?: () => void }|null} Manager handle for out-of-tick panel repaints. */
|
||||
let _dataManager = null;
|
||||
let _lastUpdate = null;
|
||||
let _error = null;
|
||||
let _limitWarned = false;
|
||||
|
||||
const _scratchCartesian = new Cesium.Cartesian3();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable key for a vehicle across polls.
|
||||
* @param {string} feedId
|
||||
* @param {string} vehicleId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitVehicleKey(feedId, vehicleId) {
|
||||
return `${feedId}:${vehicleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a gliding vehicle is drawn at `now`: linear between its last drawn
|
||||
* fix and its newest fix over one poll interval, clamped at the ends.
|
||||
* @param {{from:{lat:number,lon:number}, to:{lat:number,lon:number}, tStart:number, tEnd:number}} entry
|
||||
* @param {number} now ms
|
||||
* @returns {{lat:number, lon:number, settled:boolean}}
|
||||
*/
|
||||
export function interpolatedVehiclePosition(entry, now) {
|
||||
const { from, to, tStart, tEnd } = entry;
|
||||
if (!from || tEnd <= tStart || now >= tEnd) return { lat: to.lat, lon: to.lon, settled: true };
|
||||
if (now <= tStart) return { lat: from.lat, lon: from.lon, settled: false };
|
||||
const t = (now - tStart) / (tEnd - tStart);
|
||||
return {
|
||||
lat: from.lat + (to.lat - from.lat) * t,
|
||||
lon: from.lon + (to.lon - from.lon) * t,
|
||||
settled: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feed fix is too old to draw.
|
||||
* @param {object} record Normalized vehicle record (`timestamp` in epoch seconds or null).
|
||||
* @param {number} nowMs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isStaleVehicleFix(record, nowMs) {
|
||||
if (!Number.isFinite(record?.timestamp)) return false; // feeds without per-vehicle timestamps are trusted
|
||||
return nowMs / 1000 - record.timestamp > VEHICLE_MAX_AGE_S;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human lines of the selection card from a vehicle record.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {number} nowMs
|
||||
* @returns {{title:string, details:string[]}}
|
||||
*/
|
||||
export function buildTransitSelectionCopy(feed, record, mode, nowMs) {
|
||||
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
|
||||
const routeLabel = record.routeId ? `Route ${record.routeId}` : (record.label ? `Vehicle ${record.label}` : `Vehicle ${record.id}`);
|
||||
const title = `${icon} ${routeLabel}`;
|
||||
const details = [`${feed.name} · ${feed.region}`];
|
||||
const motion = [];
|
||||
if (Number.isFinite(record.speedMps)) motion.push(`${Math.round(record.speedMps * 3.6)} km/h`);
|
||||
if (Number.isFinite(record.bearing)) motion.push(`hdg ${Math.round(record.bearing)}°`);
|
||||
if (motion.length) details.push(motion.join(' · '));
|
||||
const state = [];
|
||||
if (record.status === 'STOPPED_AT' && record.stopId) state.push(`Stopped at stop ${record.stopId}`);
|
||||
else if (record.status === 'INCOMING_AT' && record.stopId) state.push(`Arriving at stop ${record.stopId}`);
|
||||
else if (record.status === 'IN_TRANSIT_TO' && record.stopId) state.push(`Next stop ${record.stopId}`);
|
||||
if (record.occupancy && record.occupancy !== 'NO_DATA_AVAILABLE') state.push(record.occupancy.toLowerCase().replaceAll('_', ' '));
|
||||
if (state.length) details.push(state.join(' · '));
|
||||
if (record.label && record.routeId) details.push(`Vehicle ${record.label}`);
|
||||
if (Number.isFinite(record.timestamp)) {
|
||||
const ageS = Math.max(0, Math.round(nowMs / 1000 - record.timestamp));
|
||||
details.push(ageS < 90 ? `Reported ${ageS} s ago` : `Reported ${Math.round(ageS / 60)} min ago`);
|
||||
}
|
||||
return { title, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared-host card for the selected vehicle.
|
||||
* @param {string} key
|
||||
* @param {Cesium.Cartesian3} position
|
||||
* @param {{title:string, details:string[]}} copy
|
||||
* @param {string} mode
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createTransitSelectedOverlayEntry(key, position, copy, mode) {
|
||||
if (!key || !position) return null;
|
||||
return {
|
||||
id: String(key),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent: TRANSIT_MODE_COLORS[mode] || TRANSIT_MODE_COLORS.unknown,
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 11,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCameraAltitude(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
function getCameraCenterLatLon(viewer) {
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid);
|
||||
if (rect) {
|
||||
const center = Cesium.Rectangle.center(rect);
|
||||
return { lat: Cesium.Math.toDegrees(center.latitude), lon: Cesium.Math.toDegrees(center.longitude) };
|
||||
}
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (carto) return { lat: Cesium.Math.toDegrees(carto.latitude), lon: Cesium.Math.toDegrees(carto.longitude) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function altitudeGateOpen(altitude) {
|
||||
if (_altitudeGateOpen) return altitude <= ACTIVATION_EXIT_ALTITUDE_M;
|
||||
return altitude <= ACTIVATION_ENTER_ALTITUDE_M;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function heightCellKey(lat, lon) {
|
||||
return `${Math.round(lat / HEIGHT_CELL_DEG)}:${Math.round(lon / HEIGHT_CELL_DEG)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sampled surface height for a vehicle, from a per-cell cache. `budget` is a
|
||||
* mutable counter shared by one poll so sampling stays bounded.
|
||||
* @returns {number} metres above the ellipsoid (0 when unsampled)
|
||||
*/
|
||||
function surfaceHeightFor(lat, lon, budget) {
|
||||
const key = heightCellKey(lat, lon);
|
||||
const cached = _heightCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
if (budget.remaining <= 0) return 0;
|
||||
const scene = _viewer?.scene;
|
||||
if (!scene?.sampleHeightSupported || getCameraAltitude(_viewer) > HEIGHT_SAMPLE_MAX_ALTITUDE_M) return 0;
|
||||
budget.remaining -= 1;
|
||||
let height = 0;
|
||||
try {
|
||||
const sampled = scene.sampleHeight(Cesium.Cartographic.fromDegrees(lon, lat));
|
||||
if (Number.isFinite(sampled)) height = sampled;
|
||||
} catch { /* tiles not ready — render at the ellipsoid for now */ }
|
||||
if (_heightCache.size >= HEIGHT_CACHE_MAX) _heightCache.clear();
|
||||
_heightCache.set(key, height);
|
||||
return height;
|
||||
}
|
||||
|
||||
function positionFor(lat, lon, heightM, out) {
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, heightM + HEIGHT_LIFT_M, undefined, out);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vehicles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function feedStatus(feedId) {
|
||||
let status = _feedStatus.get(feedId);
|
||||
if (!status) {
|
||||
status = { count: 0, lastUpdate: null, error: null, stale: false, pollSeq: 0, loading: false };
|
||||
_feedStatus.set(feedId, status);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function removeVehicle(key) {
|
||||
const entry = _vehicles.get(key);
|
||||
if (!entry) return;
|
||||
if (_selectedKey === key) _clearSelection();
|
||||
if (_points && entry.point) _points.remove(entry.point);
|
||||
_vehicles.delete(key);
|
||||
}
|
||||
|
||||
function removeFeedVehicles(feedId) {
|
||||
for (const [key, entry] of _vehicles) {
|
||||
if (entry.feedId === feedId) removeVehicle(key);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(feed, snapshot, { stale }) {
|
||||
const now = Date.now();
|
||||
const status = feedStatus(feed.id);
|
||||
status.pollSeq += 1;
|
||||
const pollSeq = status.pollSeq;
|
||||
const budget = { remaining: HEIGHT_SAMPLES_PER_POLL };
|
||||
let seen = 0;
|
||||
|
||||
for (const record of snapshot.vehicles || []) {
|
||||
if (isStaleVehicleFix(record, now)) continue;
|
||||
const key = transitVehicleKey(feed.id, record.id);
|
||||
const mode = transitModeFor(feed, record.routeId);
|
||||
let entry = _vehicles.get(key);
|
||||
if (entry) {
|
||||
const drawn = interpolatedVehiclePosition(entry, now);
|
||||
entry.from = { lat: drawn.lat, lon: drawn.lon };
|
||||
entry.to = { lat: record.lat, lon: record.lon };
|
||||
entry.tStart = now;
|
||||
entry.tEnd = now + TRANSIT_POLL_MS;
|
||||
if (entry.mode !== mode) {
|
||||
entry.mode = mode;
|
||||
if (_selectedKey !== key) entry.point.color = MODE_CESIUM_COLORS[mode];
|
||||
}
|
||||
if (entry.heightM === 0) entry.heightM = surfaceHeightFor(record.lat, record.lon, budget);
|
||||
} else {
|
||||
if (_vehicles.size >= MAX_VEHICLES_TOTAL) {
|
||||
if (!_limitWarned) {
|
||||
_limitWarned = true;
|
||||
console.warn(`[Data:Transit] vehicle cap ${MAX_VEHICLES_TOTAL} reached — extra vehicles are not rendered`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const heightM = surfaceHeightFor(record.lat, record.lon, budget);
|
||||
const point = _points.add({
|
||||
id: key,
|
||||
position: positionFor(record.lat, record.lon, heightM),
|
||||
color: MODE_CESIUM_COLORS[mode],
|
||||
pixelSize: POINT_PIXEL_SIZE,
|
||||
outlineColor: OUTLINE_COLOR,
|
||||
outlineWidth: 1,
|
||||
scaleByDistance: POINT_SCALE_BY_DISTANCE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
entry = {
|
||||
key,
|
||||
feedId: feed.id,
|
||||
point,
|
||||
mode,
|
||||
heightM,
|
||||
from: null,
|
||||
to: { lat: record.lat, lon: record.lon },
|
||||
tStart: now,
|
||||
tEnd: now,
|
||||
record,
|
||||
pollSeq,
|
||||
};
|
||||
_vehicles.set(key, entry);
|
||||
}
|
||||
entry.record = record;
|
||||
entry.pollSeq = pollSeq;
|
||||
seen += 1;
|
||||
}
|
||||
|
||||
// Drop vehicles this feed stopped reporting.
|
||||
for (const [key, entry] of _vehicles) {
|
||||
if (entry.feedId === feed.id && pollSeq - entry.pollSeq >= MISSED_POLLS_TO_DROP) removeVehicle(key);
|
||||
}
|
||||
|
||||
status.count = seen;
|
||||
status.lastUpdate = now;
|
||||
status.error = null;
|
||||
status.stale = stale === true;
|
||||
status.loading = false;
|
||||
_lastUpdate = now;
|
||||
_error = null;
|
||||
if (_viewer) registerDynamicCredit(_viewer, transitFeedCredit(feed));
|
||||
if (_selectedKey && _vehicles.get(_selectedKey)?.feedId === feed.id) _refreshSelectedCard(true);
|
||||
syncRenderHold();
|
||||
governorRequestRender('transit-poll');
|
||||
_dataManager?.refreshLayerStats?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one feed. A request already in flight is younger than one poll
|
||||
* interval, so a second caller (enable() and the manager's first update()
|
||||
* both ask within the same tick) awaits that request instead of aborting it —
|
||||
* the manager's first update then settles with data on the globe.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {number} generation Enable generation the poll belongs to.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function pollFeed(feed, generation) {
|
||||
if (!_enabled || generation !== _generation) return Promise.resolve();
|
||||
const existing = _inFlight.get(feed.id);
|
||||
if (existing) return existing.promise;
|
||||
const controller = new AbortController();
|
||||
const status = feedStatus(feed.id);
|
||||
status.loading = status.count === 0;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/transit/vehicles/${encodeURIComponent(feed.id)}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`transit proxy HTTP ${response.status}`);
|
||||
const snapshot = await response.json();
|
||||
if (!_enabled || generation !== _generation || !_activeFeeds.has(feed.id)) return;
|
||||
applySnapshot(feed, snapshot, { stale: response.headers.get('x-gev-cache') === 'STALE-ERROR' });
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (generation !== _generation) return;
|
||||
console.warn(`[Data:Transit] ${feed.id} poll failed:`, error?.message || error);
|
||||
status.error = `${feed.name} feed unavailable`;
|
||||
status.loading = false;
|
||||
_error = status.error;
|
||||
_dataManager?.refreshLayerStats?.();
|
||||
} finally {
|
||||
if (_inFlight.get(feed.id)?.controller === controller) _inFlight.delete(feed.id);
|
||||
}
|
||||
})();
|
||||
_inFlight.set(feed.id, { controller, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
function abortAllInFlight() {
|
||||
for (const { controller } of _inFlight.values()) controller.abort();
|
||||
_inFlight.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proximity / activation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runProximityCheck() {
|
||||
if (!_enabled || !_viewer) return;
|
||||
const altitude = getCameraAltitude(_viewer);
|
||||
_altitudeGateOpen = altitudeGateOpen(altitude);
|
||||
const center = _altitudeGateOpen ? getCameraCenterLatLon(_viewer) : null;
|
||||
const desired = new Map();
|
||||
if (center) {
|
||||
for (const feed of transitFeedsInRange(center.lat, center.lon)) desired.set(feed.id, feed);
|
||||
// Hysteresis: a feed already active stays active a little past its edge.
|
||||
for (const feed of transitFeedsInRange(center.lat, center.lon, RANGE_SLACK_KM)) {
|
||||
if (_activeFeeds.has(feed.id)) desired.set(feed.id, feed);
|
||||
}
|
||||
}
|
||||
for (const feedId of _activeFeeds.keys()) {
|
||||
if (!desired.has(feedId)) {
|
||||
_inFlight.get(feedId)?.controller.abort();
|
||||
_inFlight.delete(feedId);
|
||||
_activeFeeds.delete(feedId);
|
||||
_feedStatus.delete(feedId);
|
||||
removeFeedVehicles(feedId);
|
||||
}
|
||||
}
|
||||
for (const [feedId, feed] of desired) {
|
||||
if (!_activeFeeds.has(feedId)) {
|
||||
_activeFeeds.set(feedId, feed);
|
||||
void pollFeed(feed, _generation);
|
||||
}
|
||||
}
|
||||
syncRenderHold();
|
||||
governorRequestRender('transit-proximity');
|
||||
}
|
||||
|
||||
function onCameraChanged() {
|
||||
clearTimeout(_cameraDebounceTimer);
|
||||
_cameraDebounceTimer = setTimeout(() => {
|
||||
_cameraDebounceTimer = null;
|
||||
runProximityCheck();
|
||||
}, CAMERA_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function syncRenderHold() {
|
||||
const shouldHold = _enabled && _vehicles.size > 0;
|
||||
if (shouldHold && !_renderHeld) {
|
||||
holdContinuousRender('transit');
|
||||
_renderHeld = true;
|
||||
} else if (!shouldHold && _renderHeld) {
|
||||
releaseContinuousRender('transit');
|
||||
_renderHeld = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPreRender() {
|
||||
if (!_enabled || _vehicles.size === 0) return;
|
||||
const now = Date.now();
|
||||
for (const entry of _vehicles.values()) {
|
||||
if (!entry.from || now >= entry.tEnd) {
|
||||
if (entry.from) {
|
||||
// Settle exactly on the fix once, then stop touching the primitive.
|
||||
entry.point.position = positionFor(entry.to.lat, entry.to.lon, entry.heightM, _scratchCartesian);
|
||||
entry.from = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const { lat, lon } = interpolatedVehiclePosition(entry, now);
|
||||
entry.point.position = positionFor(lat, lon, entry.heightM, _scratchCartesian);
|
||||
}
|
||||
if (_selectedKey && now - _selectedCardAt >= SELECTED_CARD_REFRESH_MS) _refreshSelectedCard(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _refreshSelectedCard(force) {
|
||||
const entry = _selectedKey ? _vehicles.get(_selectedKey) : null;
|
||||
if (!entry) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - _selectedCardAt < SELECTED_CARD_REFRESH_MS) return;
|
||||
_selectedCardAt = now;
|
||||
const feed = _activeFeeds.get(entry.feedId) || TRANSIT_FEED_REGISTRY.find((f) => f.id === entry.feedId);
|
||||
if (!feed) return;
|
||||
const copy = buildTransitSelectionCopy(feed, entry.record, entry.mode, now);
|
||||
const card = createTransitSelectedOverlayEntry(entry.key, Cesium.Cartesian3.clone(entry.point.position), copy, entry.mode);
|
||||
if (card) _overlayHost.setEntries(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, [card], TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS);
|
||||
}
|
||||
|
||||
function _clearSelection() {
|
||||
const entry = _selectedKey ? _vehicles.get(_selectedKey) : null;
|
||||
if (entry?.point) {
|
||||
entry.point.pixelSize = POINT_PIXEL_SIZE;
|
||||
entry.point.outlineColor = OUTLINE_COLOR;
|
||||
entry.point.outlineWidth = 1;
|
||||
}
|
||||
_selectedKey = null;
|
||||
_overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
function _selectVehicle(key) {
|
||||
_clearSelection();
|
||||
const entry = _vehicles.get(key);
|
||||
if (!entry?.point) return;
|
||||
_selectedKey = key;
|
||||
entry.point.pixelSize = SELECTED_PIXEL_SIZE;
|
||||
entry.point.outlineColor = SELECTED_OUTLINE_COLOR;
|
||||
entry.point.outlineWidth = 2;
|
||||
_refreshSelectedCard(true);
|
||||
governorRequestRender('transit-select');
|
||||
}
|
||||
|
||||
function _onKeyDown(event) {
|
||||
if (event.key === 'Escape' && _selectedKey) _clearSelection();
|
||||
}
|
||||
|
||||
function _installClickHandler(viewer) {
|
||||
if (_clickHandler) return;
|
||||
_clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
if (picked) {
|
||||
const primitiveId = picked.primitive?.id;
|
||||
if (typeof primitiveId === 'string' && _vehicles.has(primitiveId)) {
|
||||
_selectVehicle(primitiveId);
|
||||
return;
|
||||
}
|
||||
if (typeof picked.id === 'string' && _vehicles.has(picked.id)) {
|
||||
_selectVehicle(picked.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_selectedKey) _clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
document.addEventListener('keydown', _onKeyDown);
|
||||
}
|
||||
|
||||
function _removeClickHandler() {
|
||||
if (_clickHandler) {
|
||||
_clickHandler.destroy();
|
||||
_clickHandler = null;
|
||||
}
|
||||
document.removeEventListener('keydown', _onKeyDown);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const transitLayer = {
|
||||
id: 'transit',
|
||||
name: 'Transit',
|
||||
icon: '🚌',
|
||||
source: 'GTFS-RT',
|
||||
updateInterval: TRANSIT_POLL_MS,
|
||||
|
||||
/**
|
||||
* Create the point collection. Called once at bootstrap.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
init(viewer) {
|
||||
_viewer = viewer;
|
||||
_points = new Cesium.PointPrimitiveCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT });
|
||||
viewer.scene.primitives.add(_points);
|
||||
registerSpriteCollection('transit', _points);
|
||||
_points.show = false;
|
||||
_enabled = false;
|
||||
_activeFeeds = new Map();
|
||||
_feedStatus = new Map();
|
||||
_inFlight = new Map();
|
||||
_vehicles = new Map();
|
||||
_heightCache = new Map();
|
||||
_selectedKey = null;
|
||||
_lastUpdate = null;
|
||||
_error = null;
|
||||
_limitWarned = false;
|
||||
_altitudeGateOpen = false;
|
||||
_overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
restoreSpriteOrder(viewer);
|
||||
console.log(`[Data:Transit] Initialized with ${TRANSIT_FEED_REGISTRY.length} GTFS-RT feeds`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show vehicles, watch the camera, and poll every feed in range.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
enable(viewer) {
|
||||
_enabled = true;
|
||||
_generation += 1;
|
||||
_error = null;
|
||||
_points.show = true;
|
||||
_overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
_installClickHandler(viewer);
|
||||
registerPickOwner('transit', (pickedId) => _vehicles.has(pickedId));
|
||||
if (!_cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(onCameraChanged);
|
||||
viewer.camera.percentageChanged = Math.min(viewer.camera.percentageChanged || 1, 0.05);
|
||||
_cameraChangedAttached = true;
|
||||
}
|
||||
if (!_preRenderRemove) _preRenderRemove = viewer.scene.preRender.addEventListener(onPreRender);
|
||||
runProximityCheck();
|
||||
restoreSpriteOrder(viewer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide everything, stop polling, drop all vehicles.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
_enabled = false;
|
||||
_generation += 1;
|
||||
clearTimeout(_cameraDebounceTimer);
|
||||
_cameraDebounceTimer = null;
|
||||
_clearSelection();
|
||||
_overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
_removeClickHandler();
|
||||
unregisterPickOwner('transit');
|
||||
if (_cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(onCameraChanged);
|
||||
_cameraChangedAttached = false;
|
||||
}
|
||||
if (_preRenderRemove) {
|
||||
_preRenderRemove();
|
||||
_preRenderRemove = null;
|
||||
}
|
||||
abortAllInFlight();
|
||||
_activeFeeds.clear();
|
||||
_feedStatus.clear();
|
||||
_vehicles.clear();
|
||||
_points?.removeAll();
|
||||
_points.show = false;
|
||||
_altitudeGateOpen = false;
|
||||
syncRenderHold();
|
||||
},
|
||||
|
||||
/**
|
||||
* Manager tick (every TRANSIT_POLL_MS): re-poll every active feed.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async update() {
|
||||
if (!_enabled || _activeFeeds.size === 0) return;
|
||||
const generation = _generation;
|
||||
await Promise.all([..._activeFeeds.values()].map((feed) => pollFeed(feed, generation)));
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const active = [..._activeFeeds.values()];
|
||||
const statuses = active.map((feed) => feedStatus(feed.id));
|
||||
const loading = statuses.some((s) => s.loading);
|
||||
const stale = statuses.length > 0 && statuses.every((s) => s.stale);
|
||||
const anyError = statuses.find((s) => s.error)?.error || null;
|
||||
const count = _vehicles.size;
|
||||
if (_enabled && active.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
coverage: _altitudeGateOpen
|
||||
? `No feed here yet · ${TRANSIT_FEED_REGISTRY.length} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString()} km to a covered region`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: count === 0 ? anyError : null,
|
||||
degraded: count > 0 && Boolean(anyError),
|
||||
loading: loading && count === 0,
|
||||
loadingLabel: loading && count === 0 ? `Loading ${active.map((f) => f.name).join(', ')}` : undefined,
|
||||
stale,
|
||||
source: 'GTFS-RT',
|
||||
coverage: active.map((f) => `${f.name} ${feedStatus(f.id).count}`).join(' · '),
|
||||
feeds: active.map((f) => f.id),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep a manager handle so proximity polls can repaint the panel row when
|
||||
* their data lands between ticks.
|
||||
* @param {object} dataManager DataLayerManager instance.
|
||||
*/
|
||||
attachDataManager(dataManager) {
|
||||
_dataManager = dataManager;
|
||||
},
|
||||
|
||||
/**
|
||||
* Tear down the collection entirely.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
if (_enabled) this.disable(viewer);
|
||||
if (_points) {
|
||||
viewer.scene.primitives.remove(_points);
|
||||
_points = null;
|
||||
}
|
||||
_overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
_heightCache.clear();
|
||||
_viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: swap the shared overlay host. */
|
||||
export function _setTransitOverlayHostForTest(host) {
|
||||
_overlayHost = host || DEFAULT_OVERLAY_HOST;
|
||||
}
|
||||
|
||||
export default transitLayer;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import transitLayer, {
|
||||
TRANSIT_MODE_COLORS,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
buildTransitSelectionCopy,
|
||||
createTransitSelectedOverlayEntry,
|
||||
interpolatedVehiclePosition,
|
||||
isStaleVehicleFix,
|
||||
transitVehicleKey,
|
||||
} from './transit.js';
|
||||
import { TRANSIT_MODES, getTransitFeed } from './transitFeeds.js';
|
||||
import { LAYER_STATE_REGISTRY } from './layerState.js';
|
||||
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
|
||||
|
||||
test('layer module declares the manager contract', () => {
|
||||
assert.equal(transitLayer.id, 'transit');
|
||||
assert.equal(typeof transitLayer.name, 'string');
|
||||
assert.equal(typeof transitLayer.icon, 'string');
|
||||
assert.equal(transitLayer.updateInterval, TRANSIT_POLL_MS);
|
||||
for (const method of ['init', 'enable', 'disable', 'update', 'getStats', 'destroy']) {
|
||||
assert.equal(typeof transitLayer[method], 'function', `${method} is implemented`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layer is registered for share links and sprite stacking', () => {
|
||||
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'transit');
|
||||
assert.ok(entry, 'transit has a share-link token');
|
||||
assert.equal(entry.disposition, 'enabled-only');
|
||||
assert.equal(LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length, 1, 'token is unique');
|
||||
const index = SPRITE_LAYER_ORDER.indexOf('transit');
|
||||
assert.ok(index > SPRITE_LAYER_ORDER.indexOf('bikeshare'), 'vehicles draw above bikeshare stations');
|
||||
assert.ok(index < SPRITE_LAYER_ORDER.indexOf('flights'), 'aircraft stay on top');
|
||||
});
|
||||
|
||||
test('every transit mode has a colour and the selected card uses it as accent', () => {
|
||||
for (const mode of TRANSIT_MODES) {
|
||||
assert.match(TRANSIT_MODE_COLORS[mode], /^#[0-9a-f]{6}$/i, `${mode} has a colour`);
|
||||
}
|
||||
const position = Cesium.Cartesian3.fromDegrees(-71.06, 42.36, 3);
|
||||
const card = createTransitSelectedOverlayEntry('mbta:1', position, { title: 'T', details: ['d'] }, 'subway');
|
||||
assert.equal(card.accent, TRANSIT_MODE_COLORS.subway);
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.protected, true);
|
||||
assert.equal(card.position, position);
|
||||
assert.equal(createTransitSelectedOverlayEntry('', position, { title: 'T', details: [] }, 'bus'), null);
|
||||
assert.equal(createTransitSelectedOverlayEntry('k', null, { title: 'T', details: [] }, 'bus'), null);
|
||||
assert.equal(TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS.moving, true, 'the card follows a moving vehicle');
|
||||
});
|
||||
|
||||
test('a vehicle glides linearly from its drawn position to the new fix over one poll', () => {
|
||||
const entry = { from: { lat: 0, lon: 0 }, to: { lat: 1, lon: 2 }, tStart: 1000, tEnd: 1000 + TRANSIT_POLL_MS };
|
||||
assert.deepEqual(interpolatedVehiclePosition(entry, 500), { lat: 0, lon: 0, settled: false });
|
||||
const mid = interpolatedVehiclePosition(entry, 1000 + TRANSIT_POLL_MS / 2);
|
||||
assert.ok(Math.abs(mid.lat - 0.5) < 1e-9 && Math.abs(mid.lon - 1) < 1e-9);
|
||||
assert.equal(mid.settled, false);
|
||||
assert.deepEqual(interpolatedVehiclePosition(entry, 1000 + TRANSIT_POLL_MS), { lat: 1, lon: 2, settled: true });
|
||||
// A brand-new vehicle (no `from`) sits exactly on its fix.
|
||||
assert.deepEqual(interpolatedVehiclePosition({ from: null, to: { lat: 5, lon: 6 }, tStart: 0, tEnd: 0 }, 10), { lat: 5, lon: 6, settled: true });
|
||||
});
|
||||
|
||||
test('fixes older than ten minutes are stale; feeds without timestamps are trusted', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 30 }, now), false);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: now / 1000 - 601 }, now), true);
|
||||
assert.equal(isStaleVehicleFix({ timestamp: null }, now), false);
|
||||
assert.equal(isStaleVehicleFix({}, now), false);
|
||||
});
|
||||
|
||||
test('selection copy reads like a transit card and never leaks nulls', () => {
|
||||
const feed = getTransitFeed('metrotransit-msp');
|
||||
const now = 1_788_936_960_000;
|
||||
const full = buildTransitSelectionCopy(feed, {
|
||||
id: '1557', label: '1557', routeId: '17', lat: 44.9, lon: -93.4, bearing: 248, speedMps: 11.2,
|
||||
timestamp: 1_788_936_945, stopId: '57458', status: 'STOPPED_AT', occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
}, 'bus', now);
|
||||
assert.equal(full.title, '🚌 Route 17');
|
||||
assert.deepEqual(full.details, [
|
||||
'Metro Transit · Minneapolis–St Paul, MN',
|
||||
'40 km/h · hdg 248°',
|
||||
'Stopped at stop 57458 · few seats available',
|
||||
'Vehicle 1557',
|
||||
'Reported 15 s ago',
|
||||
]);
|
||||
const sparse = buildTransitSelectionCopy(feed, { id: 'abc', lat: 1, lon: 1 }, 'rail', now);
|
||||
assert.equal(sparse.title, '🚆 Vehicle abc');
|
||||
assert.deepEqual(sparse.details, ['Metro Transit · Minneapolis–St Paul, MN']);
|
||||
for (const line of [...full.details, ...sparse.details]) assert.doesNotMatch(line, /null|undefined|NaN/);
|
||||
assert.equal(transitVehicleKey('mbta', '17'), 'mbta:17');
|
||||
});
|
||||
|
||||
test('the layer accepts a manager handle for out-of-tick panel repaints', () => {
|
||||
assert.equal(typeof transitLayer.attachDataManager, 'function');
|
||||
transitLayer.attachDataManager({ refreshLayerStats() {} });
|
||||
transitLayer.attachDataManager(null);
|
||||
});
|
||||
|
||||
test('stats before enable are an honest zero, not a fake feed state', () => {
|
||||
const stats = transitLayer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.source, 'GTFS-RT');
|
||||
assert.equal(stats.error, null);
|
||||
});
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* @module transitFeeds
|
||||
* @description Registry of keyless, openly licensed GTFS-Realtime
|
||||
* VehiclePositions feeds the Transit layer can show.
|
||||
*
|
||||
* Every entry here is a URL the SERVER fetches — the browser only ever asks
|
||||
* `/api/transit/vehicles/<id>` for a registered id (see SECURITY.md: proxies
|
||||
* never fetch client-supplied URLs). Adding a feed means adding a row here,
|
||||
* a DATA_SOURCES.md row with its license, and a credit in dataCredits.js.
|
||||
*
|
||||
* Admission rules for a feed:
|
||||
* - No key, token, or registration required (identify-yourself headers are
|
||||
* fine — Entur asks for `ET-Client-Name`, OVapi for a User-Agent).
|
||||
* - An open license that permits display with attribution.
|
||||
* - Real coordinates in VehiclePosition.position — NYCT subway, for example,
|
||||
* publishes stop-relative positions only and is deliberately absent.
|
||||
*
|
||||
* Pure data + pure helpers: imported by the browser layer, the Vite proxy, and
|
||||
* node:test. No Cesium, no Node built-ins.
|
||||
*/
|
||||
|
||||
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
|
||||
export const TRANSIT_MODES = Object.freeze(['bus', 'tram', 'subway', 'rail', 'ferry', 'unknown']);
|
||||
|
||||
/** Per-mode icon used in labels and the selection card. */
|
||||
export const TRANSIT_MODE_ICON = Object.freeze({
|
||||
bus: '🚌',
|
||||
tram: '🚊',
|
||||
subway: '🚇',
|
||||
rail: '🚆',
|
||||
ferry: '⛴️',
|
||||
unknown: '🚏',
|
||||
});
|
||||
|
||||
/**
|
||||
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
|
||||
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
|
||||
* numeric (or `SL`/`CT` express families).
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function mbtaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^(Red|Orange|Blue)\b/.test(routeId)) return 'subway';
|
||||
if (/^(Green|Mattapan)/.test(routeId)) return 'tram';
|
||||
if (/^CR-/.test(routeId)) return 'rail';
|
||||
if (/^Boat-/.test(routeId)) return 'ferry';
|
||||
if (/^Shuttle/i.test(routeId)) return 'bus';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entur (Norway) route ids are `<codespace>:Line:<local-id>`; the codespace
|
||||
* tells the operator, not the mode, but a few are single-mode operators.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function enturRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const codespace = routeId.split(':')[0];
|
||||
if (codespace === 'VYG' || codespace === 'GJB' || codespace === 'SJN' || codespace === 'FLT' || codespace === 'GOA' || codespace === 'NSB' || codespace === 'VYT') return 'rail';
|
||||
if (codespace === 'FLB') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL route ids start with a four-digit code whose first digit is the mode
|
||||
* family in HSL's numbering: 1xxx/2xxx… are trams (1001–1010) for 4-digit ids
|
||||
* beginning with `10`, metro routes are `31M…`, ferries `1019`.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function hslRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^31M/.test(routeId)) return 'subway';
|
||||
if (/^10(0[1-9]|10|15)/.test(routeId)) return 'tram';
|
||||
if (/^1019/.test(routeId)) return 'ferry';
|
||||
if (/^300[0-9A-Z]/.test(routeId)) return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Metro Transit (Minneapolis–St Paul): light rail is the Blue/Green line
|
||||
* (route ids 901/902), Northstar commuter rail is 888.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function metroTransitRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (routeId === '901' || routeId === '902') return 'tram';
|
||||
if (routeId === '888') return 'rail';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
* @type {ReadonlyArray<Readonly<{
|
||||
* id: string, name: string, operator: string, region: string,
|
||||
* center: {lat: number, lon: number}, loadRadiusKm: number,
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* }>>}
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'mbta',
|
||||
name: 'MBTA',
|
||||
operator: 'Massachusetts Bay Transportation Authority',
|
||||
region: 'Boston, MA',
|
||||
center: Object.freeze({ lat: 42.3601, lon: -71.0589 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||||
license: 'MassDOT Developers License Agreement',
|
||||
licenseUrl: 'https://www.mbta.com/developers/gtfs-realtime',
|
||||
attribution: 'MBTA / MassDOT',
|
||||
defaultMode: 'bus',
|
||||
routeMode: mbtaRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'capmetro-austin',
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metropolitan Transportation Authority',
|
||||
region: 'Austin, TX',
|
||||
center: Object.freeze({ lat: 30.2672, lon: -97.7431 }),
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream',
|
||||
license: 'Texas Open Data Portal terms of use',
|
||||
licenseUrl: 'https://data.texas.gov/Transportation/CapMetro-Vehicle-Positions-PB-File/eiei-9rpf',
|
||||
attribution: 'Capital Metropolitan Transportation Authority — data.texas.gov',
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'metrotransit-msp',
|
||||
name: 'Metro Transit',
|
||||
operator: 'Metro Transit (Metropolitan Council)',
|
||||
region: 'Minneapolis–St Paul, MN',
|
||||
center: Object.freeze({ lat: 44.9778, lon: -93.265 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb',
|
||||
license: 'Public domain (Minnesota Government Data Practices Act)',
|
||||
licenseUrl: 'https://svc.metrotransit.org/',
|
||||
attribution: 'Metro Transit — Metropolitan Council',
|
||||
defaultMode: 'bus',
|
||||
routeMode: metroTransitRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsl-helsinki',
|
||||
name: 'HSL',
|
||||
operator: 'Helsinki Region Transport (HSL)',
|
||||
region: 'Helsinki, Finland',
|
||||
center: Object.freeze({ lat: 60.1699, lon: 24.9384 }),
|
||||
loadRadiusKm: 70,
|
||||
url: 'https://realtime.hsl.fi/realtime/vehicle-positions/v2/hsl',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://www.hsl.fi/en/hsl/open-data',
|
||||
attribution: 'HSL (Helsinki Region Transport)',
|
||||
defaultMode: 'bus',
|
||||
routeMode: hslRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ovapi-nl',
|
||||
name: 'OVapi',
|
||||
operator: 'Stichting OpenGeo (NDOV data)',
|
||||
region: 'Netherlands',
|
||||
center: Object.freeze({ lat: 52.2, lon: 5.3 }),
|
||||
loadRadiusKm: 220,
|
||||
url: 'https://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||||
license: 'CC0 (OVapi README: free to use, best effort)',
|
||||
licenseUrl: 'https://gtfs.ovapi.nl/README',
|
||||
attribution: 'OVapi / Stichting OpenGeo — Dutch integrated real-time transit data',
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'entur-norway',
|
||||
name: 'Entur',
|
||||
operator: 'Entur AS (Norwegian national transit data)',
|
||||
region: 'Norway',
|
||||
// Circle chosen to hold Oslo, Bergen, Bodø and Tromsø while leaving
|
||||
// Helsinki (≈820 km) out — a national feed must not poll from next door.
|
||||
center: Object.freeze({ lat: 64.0, lon: 11.5 }),
|
||||
loadRadiusKm: 720,
|
||||
url: 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions',
|
||||
headers: Object.freeze({ 'ET-Client-Name': 'gods-eye-view-transit' }),
|
||||
license: 'Norwegian Licence for Open Government Data (NLOD)',
|
||||
licenseUrl: 'https://developer.entur.org/pages-intro-authentication',
|
||||
attribution: 'Entur — data under NLOD',
|
||||
defaultMode: 'bus',
|
||||
routeMode: enturRouteMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'translink-seq',
|
||||
name: 'TransLink',
|
||||
operator: 'TransLink (Queensland Government)',
|
||||
region: 'South East Queensland, Australia',
|
||||
center: Object.freeze({ lat: -27.4698, lon: 153.0251 }),
|
||||
loadRadiusKm: 150,
|
||||
url: 'https://gtfsrt.api.translink.com.au/api/realtime/seq/VehiclePositions',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://translink.com.au/about-translink/open-data',
|
||||
attribution: 'TransLink — Queensland Government (CC BY 4.0)',
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
]);
|
||||
|
||||
const FEED_BY_ID = new Map(TRANSIT_FEED_REGISTRY.map((feed) => [feed.id, feed]));
|
||||
|
||||
/** Feed ids are path segments: lowercase letters, digits, hyphens only. */
|
||||
export const TRANSIT_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
||||
|
||||
/**
|
||||
* Look up a registered feed by id. Unknown or malformed ids return null —
|
||||
* this is the only door from a request path to an upstream URL.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getTransitFeed(id) {
|
||||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||||
return FEED_BY_ID.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in km.
|
||||
* @param {number} aLat
|
||||
* @param {number} aLon
|
||||
* @param {number} bLat
|
||||
* @param {number} bLon
|
||||
* @returns {number}
|
||||
*/
|
||||
export function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const h = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds whose coverage circle contains the point, nearest first.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} [slackKm=0] Extra radius (hysteresis) so a feed at the edge
|
||||
* of coverage does not flap on small camera moves.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return [];
|
||||
return TRANSIT_FEED_REGISTRY
|
||||
.map((feed) => ({ feed, km: haversineKm(lat, lon, feed.center.lat, feed.center.lon) }))
|
||||
.filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm))
|
||||
.sort((a, b) => a.km - b.km)
|
||||
.map(({ feed }) => feed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's 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.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted = typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
const mode = hinted && hinted !== 'unknown' ? hinted : (feed?.defaultMode || 'unknown');
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function publicTransitCatalog() {
|
||||
return TRANSIT_FEED_REGISTRY.map((feed) => ({
|
||||
id: feed.id,
|
||||
name: feed.name,
|
||||
operator: feed.operator,
|
||||
region: feed.region,
|
||||
center: { ...feed.center },
|
||||
loadRadiusKm: feed.loadRadiusKm,
|
||||
license: feed.license,
|
||||
licenseUrl: feed.licenseUrl,
|
||||
attribution: feed.attribution,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
TRANSIT_MODES,
|
||||
getTransitFeed,
|
||||
haversineKm,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
const ids = new Set();
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
assert.match(feed.id, TRANSIT_FEED_ID_PATTERN, `${feed.id} is a valid path segment`);
|
||||
assert.equal(ids.has(feed.id), false, `${feed.id} is unique`);
|
||||
ids.add(feed.id);
|
||||
const url = new URL(feed.url);
|
||||
assert.equal(url.protocol, 'https:', `${feed.id} fetches over https`);
|
||||
assert.equal(url.search.includes('key='), false, `${feed.id} carries no key in its URL`);
|
||||
assert.ok(feed.license && feed.licenseUrl && feed.attribution, `${feed.id} names its license`);
|
||||
assert.ok(feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000, `${feed.id} radius is sane`);
|
||||
assert.ok(Math.abs(feed.center.lat) <= 90 && Math.abs(feed.center.lon) <= 180);
|
||||
assert.ok(TRANSIT_MODES.includes(feed.defaultMode), `${feed.id} default mode is known`);
|
||||
assert.ok(Object.isFrozen(feed), `${feed.id} is immutable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTransitFeed is the only door to an upstream URL and refuses anything unregistered', () => {
|
||||
assert.equal(getTransitFeed('mbta')?.url, 'https://cdn.mbta.com/realtime/VehiclePositions.pb');
|
||||
assert.equal(getTransitFeed('MBTA'), null);
|
||||
assert.equal(getTransitFeed('../etc/passwd'), null);
|
||||
assert.equal(getTransitFeed('https://evil.example'), null);
|
||||
assert.equal(getTransitFeed(''), null);
|
||||
assert.equal(getTransitFeed(null), null);
|
||||
assert.equal(getTransitFeed(42), null);
|
||||
});
|
||||
|
||||
test('haversine matches known city distances', () => {
|
||||
const bostonToNyc = haversineKm(42.3601, -71.0589, 40.7128, -74.006);
|
||||
assert.ok(Math.abs(bostonToNyc - 306) < 5, `Boston–NYC ≈ 306 km, got ${bostonToNyc}`);
|
||||
assert.equal(haversineKm(0, 0, 0, 0), 0);
|
||||
});
|
||||
|
||||
test('feeds in range are nearest-first and honor slack as hysteresis', () => {
|
||||
// Camera over Cambridge, MA → MBTA only.
|
||||
const boston = transitFeedsInRange(42.37, -71.11);
|
||||
assert.deepEqual(boston.map((f) => f.id), ['mbta']);
|
||||
// Mid-Atlantic: nothing.
|
||||
assert.deepEqual(transitFeedsInRange(40, -40), []);
|
||||
// Just outside MBTA's 70 km circle (≈ 80 km south) — out without slack, in with 20 km slack.
|
||||
const farLat = 42.3601 - 80 / 111;
|
||||
assert.deepEqual(transitFeedsInRange(farLat, -71.0589), []);
|
||||
assert.deepEqual(transitFeedsInRange(farLat, -71.0589, 20).map((f) => f.id), ['mbta']);
|
||||
// Bad input never throws.
|
||||
assert.deepEqual(transitFeedsInRange(NaN, 1), []);
|
||||
// A national feed covers its own cities and not the neighbour's capital.
|
||||
for (const [city, lat, lon] of [['Oslo', 59.91, 10.75], ['Bergen', 60.39, 5.32], ['Tromsø', 69.65, 18.96]]) {
|
||||
assert.ok(transitFeedsInRange(lat, lon).some((f) => f.id === 'entur-norway'), `${city} is covered by Entur`);
|
||||
}
|
||||
assert.deepEqual(transitFeedsInRange(60.17, 24.94).map((f) => f.id), ['hsl-helsinki'], 'Helsinki polls HSL only');
|
||||
});
|
||||
|
||||
test('route hints refine a feed default and never escape the known modes', () => {
|
||||
const mbta = getTransitFeed('mbta');
|
||||
assert.equal(transitModeFor(mbta, 'Red'), 'subway');
|
||||
assert.equal(transitModeFor(mbta, 'Green-B'), 'tram');
|
||||
assert.equal(transitModeFor(mbta, 'CR-Fitchburg'), 'rail');
|
||||
assert.equal(transitModeFor(mbta, 'Boat-F1'), 'ferry');
|
||||
assert.equal(transitModeFor(mbta, '66'), 'bus');
|
||||
assert.equal(transitModeFor(mbta, null), 'bus');
|
||||
const hsl = getTransitFeed('hsl-helsinki');
|
||||
assert.equal(transitModeFor(hsl, '31M1'), 'subway');
|
||||
assert.equal(transitModeFor(hsl, '1006'), 'tram');
|
||||
assert.equal(transitModeFor(hsl, '9982'), 'bus');
|
||||
const msp = getTransitFeed('metrotransit-msp');
|
||||
assert.equal(transitModeFor(msp, '901'), 'tram');
|
||||
assert.equal(transitModeFor(msp, '17'), 'bus');
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
assert.equal(transitModeFor(entur, 'VYG:Line:R10'), 'rail');
|
||||
assert.equal(transitModeFor(entur, 'TRO:Line:1_310'), 'bus');
|
||||
assert.equal(transitModeFor({ defaultMode: 'spaceship' }, 'x'), 'unknown');
|
||||
assert.equal(transitModeFor(null, 'x'), 'unknown');
|
||||
});
|
||||
|
||||
test('the public catalog exposes coverage and credit, never the upstream URL or headers', () => {
|
||||
const catalog = publicTransitCatalog();
|
||||
assert.equal(catalog.length, TRANSIT_FEED_REGISTRY.length);
|
||||
for (const entry of catalog) {
|
||||
assert.equal('url' in entry, false);
|
||||
assert.equal('headers' in entry, false);
|
||||
assert.ok(entry.id && entry.name && entry.region && entry.attribution);
|
||||
assert.ok(Number.isFinite(entry.center.lat) && Number.isFinite(entry.loadRadiusKm));
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* @module transitProxy
|
||||
* @description Pure server-side mechanics for the `/api/transit` proxy.
|
||||
*
|
||||
* Kept free of Vite/Node middleware state (the terrainHeightsProxy pattern)
|
||||
* so path resolution, snapshot shaping, and the cache/stale policy can be
|
||||
* exercised by the offline node:test suite. The middleware in vite.config.js
|
||||
* only does I/O: fetch the registered upstream, hand the bytes here, send.
|
||||
*/
|
||||
|
||||
import { decodeVehiclePositions } from './gtfsRealtime.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
/** Fresh window: a snapshot younger than this is served without refetching. */
|
||||
export const TRANSIT_PROXY_TTL_MS = 15_000;
|
||||
/** Serve-stale window: after an upstream failure, a snapshot this old still ships (marked stale). */
|
||||
export const TRANSIT_PROXY_STALE_MAX_MS = 10 * 60_000;
|
||||
/** Upstream fetch timeout. National feeds (Entur ≈ 1.4 MB) need headroom. */
|
||||
export const TRANSIT_PROXY_TIMEOUT_MS = 15_000;
|
||||
/** Hard cap on upstream bytes: the largest known feed is ~1.4 MB. */
|
||||
export const TRANSIT_PROXY_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Resolve `/vehicles/<feedId>` (the path after the `/api/transit` mount) to a
|
||||
* registered feed. Anything else — a different route, an unknown id, path
|
||||
* tricks, a query string — resolves to null and the caller 404s.
|
||||
* @param {string} url Request URL relative to the mount point.
|
||||
* @returns {{ route: 'feeds' } | { route: 'vehicles', feed: object } | null}
|
||||
*/
|
||||
export function resolveTransitRoute(url) {
|
||||
const pathname = String(url || '').split('?')[0];
|
||||
if (pathname === '/feeds' || pathname === '/feeds/') return { route: 'feeds' };
|
||||
const match = /^\/vehicles\/([^/]+)\/?$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
let id;
|
||||
try {
|
||||
id = decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const feed = getTransitFeed(id);
|
||||
return feed ? { route: 'vehicles', feed } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers for one upstream fetch. Feeds that ask consumers to
|
||||
* identify themselves (Entur, OVapi) get their header from the registry.
|
||||
* @param {object} feed Registry entry.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitUpstreamHeaders(feed) {
|
||||
return {
|
||||
'User-Agent': 'gods-eye-view-transit-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1',
|
||||
...(feed?.headers || {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only https upstreams are fetched, and a redirect must land on https too —
|
||||
* the registry is server-owned, so following redirects is safe from SSRF, but
|
||||
* a downgrade to plain http is still refused.
|
||||
* @param {string} url Final response URL (after redirects) or the request URL.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAcceptableTransitUpstreamUrl(url) {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode upstream bytes into the JSON snapshot the browser consumes.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage.
|
||||
* @param {number} [now=Date.now()] Fetch time (ms epoch).
|
||||
* @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null,
|
||||
* version: string|null, entityCount: number, count: number, vehicles: object[] }}
|
||||
*/
|
||||
export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
const decoded = decodeVehiclePositions(bytes);
|
||||
return {
|
||||
feedId: feed.id,
|
||||
name: feed.name,
|
||||
fetchedAt: now,
|
||||
feedTimestamp: decoded.timestamp,
|
||||
version: decoded.version,
|
||||
entityCount: decoded.entityCount,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: decoded.vehicles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < 0) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies
|
||||
* (HIT / MISS / INFLIGHT / STALE-ERROR) so the layer can surface staleness.
|
||||
* @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState
|
||||
* @param {string} [upstreamHost]
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function transitResponseHeaders(cacheState, upstreamHost = '') {
|
||||
return {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': cacheState === 'STALE-ERROR' ? 'no-store' : `public, max-age=${Math.floor(TRANSIT_PROXY_TTL_MS / 1000)}`,
|
||||
'X-GEV-Cache': cacheState,
|
||||
...(upstreamHost ? { 'X-Transit-Upstream': upstreamHost } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
TRANSIT_PROXY_STALE_MAX_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
buildTransitSnapshot,
|
||||
isAcceptableTransitUpstreamUrl,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
} from './transitProxy.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
test('route resolution admits only the catalog and registered feed ids', () => {
|
||||
assert.deepEqual(resolveTransitRoute('/feeds'), { route: 'feeds' });
|
||||
assert.deepEqual(resolveTransitRoute('/feeds/?x=1'), { route: 'feeds' });
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta?trip=1')?.feed?.id, 'mbta');
|
||||
assert.equal(resolveTransitRoute('/vehicles/nope'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/mbta/extra'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/..%2F..%2Fetc'), null);
|
||||
assert.equal(resolveTransitRoute('/vehicles/%E0%A4%A'), null); // malformed escape never throws
|
||||
assert.equal(resolveTransitRoute('/'), null);
|
||||
assert.equal(resolveTransitRoute(''), null);
|
||||
assert.equal(resolveTransitRoute(undefined), null);
|
||||
});
|
||||
|
||||
test('upstream headers identify the proxy and carry feed-specific identification', () => {
|
||||
const plain = transitUpstreamHeaders(getTransitFeed('mbta'));
|
||||
assert.match(plain['User-Agent'], /gods-eye-view-transit-proxy/);
|
||||
assert.match(plain.Accept, /x-protobuf/);
|
||||
const entur = transitUpstreamHeaders(getTransitFeed('entur-norway'));
|
||||
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-transit');
|
||||
assert.ok(transitUpstreamHeaders(null)['User-Agent']);
|
||||
});
|
||||
|
||||
test('only https upstreams are acceptable, including after a redirect', () => {
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('https://cdn.mbta.com/x.pb'), true);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('http://gtfs.ovapi.nl/nl/vehiclePositions.pb'), false);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('ftp://x'), false);
|
||||
assert.equal(isAcceptableTransitUpstreamUrl('not a url'), false);
|
||||
});
|
||||
|
||||
test('snapshot shape carries provenance the layer displays', () => {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(3, 1_700_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
const snapshot = buildTransitSnapshot(getTransitFeed('hsl-helsinki'), writer.finish(), 12345);
|
||||
assert.equal(snapshot.feedId, 'hsl-helsinki');
|
||||
assert.equal(snapshot.name, 'HSL');
|
||||
assert.equal(snapshot.fetchedAt, 12345);
|
||||
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
|
||||
assert.equal(snapshot.version, '2.0');
|
||||
assert.equal(snapshot.count, 0);
|
||||
assert.deepEqual(snapshot.vehicles, []);
|
||||
});
|
||||
|
||||
test('cache policy: fresh within TTL, stale until the serve-stale window, expired after', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(transitCacheState(null, now), 'none');
|
||||
assert.equal(transitCacheState({ at: Number.NaN }, now), 'none');
|
||||
assert.equal(transitCacheState({ at: now }, now), 'fresh');
|
||||
assert.equal(transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS + 1 }, now), 'fresh');
|
||||
assert.equal(transitCacheState({ at: now - TRANSIT_PROXY_TTL_MS }, now), 'stale');
|
||||
assert.equal(transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS + 1 }, now), 'stale');
|
||||
assert.equal(transitCacheState({ at: now - TRANSIT_PROXY_STALE_MAX_MS }, now), 'expired');
|
||||
assert.equal(transitCacheState({ at: now + 5000 }, now), 'fresh'); // clock skew never expires a fresh fetch
|
||||
});
|
||||
|
||||
test('response headers mark cache state and never let a stale-error response be cached downstream', () => {
|
||||
assert.equal(transitResponseHeaders('HIT', 'cdn.mbta.com')['X-Transit-Upstream'], 'cdn.mbta.com');
|
||||
assert.equal(transitResponseHeaders('HIT')['Cache-Control'], 'public, max-age=15');
|
||||
assert.equal(transitResponseHeaders('STALE-ERROR')['Cache-Control'], 'no-store');
|
||||
assert.equal(transitResponseHeaders('MISS')['X-GEV-Cache'], 'MISS');
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import trafficLayer from './data/traffic.js';
|
|||
import cctvLayer from './data/cctv.js';
|
||||
import radioLayer from './data/radio.js';
|
||||
import bikeshareLayer from './data/bikeshare.js';
|
||||
import transitLayer from './data/transit.js';
|
||||
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
|
||||
import militaryInstallationsLayer from './data/militaryInstallations.js';
|
||||
import militaryAwarenessLayer from './data/militaryAwareness.js';
|
||||
|
|
@ -217,6 +218,8 @@ async function init() {
|
|||
dataManager.register(cctvLayer);
|
||||
dataManager.register(radioLayer);
|
||||
dataManager.register(bikeshareLayer);
|
||||
dataManager.register(transitLayer);
|
||||
transitLayer.attachDataManager(dataManager);
|
||||
dataManager.register(aisLiveVesselsLayer);
|
||||
dataManager.register(militaryInstallationsLayer);
|
||||
dataManager.register(militaryAwarenessLayer);
|
||||
|
|
|
|||
160
vite.config.js
160
vite.config.js
|
|
@ -56,6 +56,17 @@ import { normalizeAdsbLolPointResponse } from './src/data/adsbLolFallback.js';
|
|||
import { createAisStreamAdapter, isRecognizedAisEnvelope } from './src/data/aisStreamAdapter.js';
|
||||
import { parseSilenceTimeoutEnv } from './src/data/aisWatchdog.js';
|
||||
import { keylessHudSummaryResponse } from './src/hudSummaryResponse.js';
|
||||
import { publicTransitCatalog } from './src/data/transitFeeds.js';
|
||||
import {
|
||||
TRANSIT_PROXY_MAX_BODY_BYTES,
|
||||
TRANSIT_PROXY_TIMEOUT_MS,
|
||||
buildTransitSnapshot,
|
||||
isAcceptableTransitUpstreamUrl,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
} from './src/data/transitProxy.js';
|
||||
import { parseEnv as parseDotenvText } from 'node:util';
|
||||
import { readEnvironmentSource as readPinokioEnvironmentSource } from './scripts/pinokio-environment.mjs';
|
||||
import {
|
||||
|
|
@ -774,6 +785,46 @@ export async function readResponseJsonCapped(response, maxBytes) {
|
|||
return JSON.parse(await readResponseTextCapped(response, maxBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fetch() Response body as bytes with the same hard cap as
|
||||
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes) {
|
||||
const tooLarge = () => {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
return err;
|
||||
};
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) throw tooLarge();
|
||||
return bytes;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try { await reader.cancel(); } catch { /* no-op */ }
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the existing promise for a cache key, or create one and remove it
|
||||
* only when that exact promise settles.
|
||||
|
|
@ -1316,6 +1367,114 @@ function radioBrowserProxy() {
|
|||
};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// GTFS-Realtime transit proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vite plugin: GTFS-Realtime VehiclePositions proxy for the Transit layer.
|
||||
*
|
||||
* GET /api/transit/feeds → public catalog (coverage + credit)
|
||||
* GET /api/transit/vehicles/<feedId> → decoded snapshot as JSON
|
||||
*
|
||||
* Only URLs in `src/data/transitFeeds.js` are ever fetched — the browser
|
||||
* names a registered id, never a URL (SECURITY.md). Redirects are followed
|
||||
* (the registry is server-owned) but must land on https. Bytes are capped at
|
||||
* TRANSIT_PROXY_MAX_BODY_BYTES and decoded server-side, so the browser never
|
||||
* parses protobuf. Per feed: 15 s memory cache, single-flight refresh, and
|
||||
* serve-stale-on-failure for up to 10 minutes (the launch-library pattern).
|
||||
* No disk cache — transit positions are worthless after a few minutes.
|
||||
*/
|
||||
function transitProxy() {
|
||||
/** @type {Map<string, {at:number, body:string, host:string}>} feedId → snapshot */
|
||||
const cache = new Map();
|
||||
const inFlight = new Map();
|
||||
|
||||
function send(res, status, body, headers) {
|
||||
res.writeHead(status, headers);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function refresh(feed) {
|
||||
const upstream = await fetch(feed.url, {
|
||||
signal: AbortSignal.timeout(TRANSIT_PROXY_TIMEOUT_MS),
|
||||
headers: transitUpstreamHeaders(feed),
|
||||
redirect: 'follow',
|
||||
});
|
||||
const finalUrl = upstream.url || feed.url;
|
||||
if (!isAcceptableTransitUpstreamUrl(finalUrl)) {
|
||||
throw new Error('upstream redirected off https');
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
const error = new Error(`upstream HTTP ${upstream.status}`);
|
||||
error.upstreamStatus = upstream.status;
|
||||
throw error;
|
||||
}
|
||||
const bytes = await readResponseBytesCapped(upstream, TRANSIT_PROXY_MAX_BODY_BYTES);
|
||||
const snapshot = buildTransitSnapshot(feed, bytes, Date.now());
|
||||
const entry = { at: snapshot.fetchedAt, body: JSON.stringify(snapshot), host: new URL(finalUrl).hostname };
|
||||
cache.set(feed.id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/transit', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
send(res, 405, JSON.stringify({ error: 'Method Not Allowed' }), transitResponseHeaders('NONE'));
|
||||
return;
|
||||
}
|
||||
const route = resolveTransitRoute(req.url);
|
||||
if (!route) {
|
||||
send(res, 404, JSON.stringify({ error: 'Unknown transit feed' }), { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
return;
|
||||
}
|
||||
if (route.route === 'feeds') {
|
||||
send(res, 200, JSON.stringify({ feeds: publicTransitCatalog() }), {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { feed } = route;
|
||||
const now = Date.now();
|
||||
const cached = cache.get(feed.id);
|
||||
const state = transitCacheState(cached, now);
|
||||
if (state === 'fresh') {
|
||||
send(res, 200, cached.body, transitResponseHeaders('HIT', cached.host));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(inFlight, feed.id, () => refresh(feed));
|
||||
try {
|
||||
const fresh = await request.promise;
|
||||
send(res, 200, fresh.body, transitResponseHeaders(request.shared ? 'INFLIGHT' : 'MISS', fresh.host));
|
||||
} catch (error) {
|
||||
if (state === 'stale' && cached) {
|
||||
if (!request.shared) console.warn(`[transit-proxy] ${feed.id} refresh failed (${error?.message || error}) — serving stale snapshot`);
|
||||
send(res, 200, cached.body, transitResponseHeaders('STALE-ERROR', cached.host));
|
||||
return;
|
||||
}
|
||||
if (!request.shared) console.warn(`[transit-proxy] ${feed.id} unavailable: ${error?.message || error}`);
|
||||
send(
|
||||
res,
|
||||
error?.code === 'RESPONSE_TOO_LARGE' ? 502 : (Number.isInteger(error?.upstreamStatus) ? 502 : 504),
|
||||
JSON.stringify({ error: 'Transit feed unavailable', feedId: feed.id }),
|
||||
{ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-GEV-Cache': 'NONE' },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'transit-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// GBFS (General Bikeshare Feed Specification) proxy constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Upstream fetch timeout for GBFS requests (ms). */
|
||||
|
|
@ -7754,6 +7913,7 @@ export default defineConfig(({ mode }) => {
|
|||
cctvProxy(),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
transitProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue