Draw transit vehicles as trolleybuses, cable cars, funiculars, gondolas and monorails

The transit layer knew five modes, so a trolleybus drew as a bus, a
funicular as a train and a coach as a city bus. The GTFS basic route types
are now modes of their own (trolleybus, cable_tram, monorail, funicular,
aerial_lift), each with an icon, colour, detection tier, card word, voice
name, speed ceiling and fleet model.

A vehicle record may carry its route's GTFS route_type as routeType. It
settles the mode after the feed's fleet hint and before its route hint, and
picks the finer model (a coach for 200, a high-speed set for 101) when the
type is a kind of the vehicle's mode. Retained history classifies epochs
through transitModeFor too, so trails join the live vehicle.

Route hints from each operator's routes.txt: Prague's trolleybus lines
(51-59, 232, 275) and Petrin funicular, and the trams, trolleybuses and
metro lines of Bucharest, Cluj-Napoca and Turin. A feed may also name
route corrections that outrank the route type: Le Havre's routes.txt types
its funicular (8-FUNI) as a bus, so it is corrected to a funicular.
This commit is contained in:
Sameh Khamis committed 2026-11-14 12:03:12 -08:00
1 parent 250f0e20c3
commit 39c89d563b
32 files changed
+708 -61

No files matched your search

@@ -0,0 +1,10 @@
- Draw transit vehicles as their kind, not one of five: trolleybuses, cable
cars, funiculars, gondolas and monorails are modes of their own, with their
own icon, colour, card word and fleet model. A vehicle record may carry its
route's GTFS `route_type` (`routeType`), which settles its mode before the
feed's route hints and picks the finer model (a coach for 200, a high-speed
set for 101). Prague's trolleybuses and Petřín funicular, and Bucharest,
Cluj and Turin's trams, trolleybuses and metro lines, now read as such from
the route ids in each operator's routes.txt. Le Havre's funicular, which its
routes.txt types as a bus, is a named correction that outranks the route
type (samehkhamis, #0000).
+25 -5
View File
@@ -5868,7 +5868,26 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
Ontario — Toronto (TTC surface vehicles; the subway publishes no Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes Burlington, Barrie and Kingston. Mode silhouettes
identify buses, trams, subways, trains, ferries and unclassified vehicles. identify buses, trolleybuses, trams, cable cars, subways, monorails, trains,
funiculars, gondolas (aerial lifts), ferries and unclassified vehicles.
A vehicle's mode comes from, in order: the feed's fleet-number hint
(`vehicleMode`, which knows a bus standing in on a streetcar route); the feed's
correction for a route its own routes.txt mistypes (`routeCorrections`: Le
Havre's funicular `8-FUNI`, which LiA's routes.txt types as a bus); the GTFS
`route_type` of its route, when its record carries one as `routeType` (basic
0–12 or extended 100–1799, each extended type taken as the basic mode it is a
kind of: 200 coach is a bus, 101 high-speed is rail, 405 is a monorail); the
feed's route-id hint (`routeMode`); and the feed's default (`transitModeFor`,
`src/data/transitFeeds.js`). The GTFS-Realtime decoder carries no route type, so
a record from the bundled proxy is classified by the hints alone. Route hints
read from each operator's routes.txt cover Prague's trolleybus lines and Petřín
funicular, and Bucharest, Cluj-Napoca and Turin's trams, trolleybuses and metro
lines (and Turin's Sassi–Superga rack tramway, typed a funicular in its
routes.txt). Le Havre's funicular is a correction, not read from routes.txt: its
route hint and its correction both say funicular. Vilnius publishes no route ids in its positions, so
its trolleybuses read as buses. Retained history classifies its epochs the same
way, so a trail joins the live vehicle.
Retained transit history uses the same outlier quarantine as live observations and Retained transit history uses the same outlier quarantine as live observations and
preserves the active live bracket. Rejected reports do not publish route or mode preserves the active live bracket. Rejected reports do not publish route or mode
@@ -5934,9 +5953,10 @@ External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors
final unpadded display size. Each mode has an outline with a front (a nose, a final unpadded display size. Each mode has an outline with a front (a nose, a
bow or a chamfered cab) for a vehicle with a known course and a symmetric bow or a chamfered cab) for a vehicle with a known course and a symmetric
outline for one without, which would otherwise sit at rotation 0 and point outline for one without, which would otherwise sit at rotation 0 and point
north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three north; a vehicle switches to the pointed glyph when a course arrives. Eleven modes, two sizes, three
profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram profiles and the two outlines cap the raster cache at 132 variants. Normal mode colours are bus `#5EF08A`,
`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown trolleybus `#B6F04A`, tram `#FFC24A`, cable car `#FF8F4A`, subway `#FF4538`, monorail `#FF6FB8`, rail
`#D9A6FF`, funicular `#E8C98A`, gondola `#7FA8FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact `#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact
outer path, filled opaque white without interior panel lines, with an opaque outer path, filled opaque white without interior panel lines, with an opaque
`#05080C` halo. Only the external padding grows; the unpadded silhouette remains `#05080C` halo. Only the external padding grows; the unpadded silhouette remains
@@ -7430,7 +7450,7 @@ pins and share links already supply complete boxes.
30-second backoff ahead of the current clock are ignored. A smaller rollback 30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds. can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. - Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its route's GTFS `route_type` when its record carries one and that type is a kind of the vehicle's mode (`types.json` `transit.route_types`: 200 a coach, 101 a high-speed set, 106 a regional DMU, 710 a double-decker), else its layer mode (`transit.modes`: bus, trolleybus, tram, cable car, subway, monorail, rail, funicular, gondola or ferry; a vehicle of `unknown` mode keeps its icon); a fleet hint that overrules the route (a bus on a tram route) leaves the type unused. A monorail and a gondola are raised by their `transit.elevation` lifts. A train's model follows its rail category and mode (rail, metro or tram). The bundled GTFS-Realtime proxy carries no route type, so its vehicles draw their mode's model; a source that adds `routeType` reaches the finer ones. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
+22
View File
@@ -202,3 +202,25 @@ test('a feed fleet-number mode outranks its route mode in the epoch context', (t
assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram');
assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram');
}); });
test('the epoch mode is the live mode: a route type, else hints, else the default', (t) => {
const h = history(t);
const pid = {
id: 'pid',
historyRetention: true,
defaultMode: 'bus',
routeMode: (route) => (route === 'L22' ? 'tram' : 'unknown'),
};
h.ingest(
pid,
[
row(100000, 'a', { routeId: 'L58', routeType: 11 }),
row(100000, 'b', { routeId: 'L22' }),
row(100000, 'c', { routeId: 'L9999' }),
],
100000,
);
assert.equal(h.get('pid', 'a', 100000).epochs[0].mode, 'trolleybus');
assert.equal(h.get('pid', 'b', 100000).epochs[0].mode, 'tram');
// A hint that knows nothing leaves the default, as the live layer does.
assert.equal(h.get('pid', 'c', 100000).epochs[0].mode, 'bus');
});
+33
View File
@@ -25,6 +25,7 @@ import {
vesselModelSpec, vesselModelSpec,
} from './fleetModels.js'; } from './fleetModels.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { TRANSIT_MODES } from './transitFeeds.js';
import { import {
centrelineSegments, centrelineSegments,
distanceToSurface, distanceToSurface,
@@ -444,6 +445,38 @@ test('aids to navigation are the buoy of the mark their type names', () => {
} }
}); });
test('every transit mode but unknown draws its own model, and a route type a finer one', () => {
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
assert.ok(types.transit.modes[mode], `${mode} has a model`);
assert.equal(
resolveTransit(types, { mode }).model,
types.transit.modes[mode][0],
);
}
for (const [mode, model] of [
['trolleybus', 'trolleybus'],
['cable_tram', 'cable_tram'],
['aerial_lift', 'gondola'],
['funicular', 'funicular'],
['monorail', 'monorail'],
])
assert.equal(resolveTransit(types, { mode }).model, model, mode);
// A route type outranks the mode: a coach and a high-speed set are still
// the bus and rail modes.
for (const [routeType, mode, model] of [
[200, 'bus', 'coach'],
[101, 'rail', 'high_speed'],
[106, 'rail', 'regional_dmu'],
[710, 'bus', 'double_decker'],
])
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
// A type the table lacks falls back to the mode.
assert.equal(
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
'commuter_emu',
);
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => { test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity', model: 'intercity',
+236 -17
View File
@@ -29,12 +29,21 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
* node:test. No Cesium, no Node built-ins. * node:test. No Cesium, no Node built-ins.
*/ */
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */ /**
* Transit modes the layer colors: the GTFS basic route types, by name. A
* vehicle's GTFS `route_type` settles its mode when its record carries one;
* otherwise `routeMode` hints refine a feed's default.
*/
export const TRANSIT_MODES = Object.freeze([ export const TRANSIT_MODES = Object.freeze([
'bus', 'bus',
'trolleybus',
'tram', 'tram',
'cable_tram',
'subway', 'subway',
'monorail',
'rail', 'rail',
'funicular',
'aerial_lift',
'ferry', 'ferry',
'unknown', 'unknown',
]); ]);
@@ -42,13 +51,81 @@ export const TRANSIT_MODES = Object.freeze([
/** Per-mode icon used in labels and the selection card. */ /** Per-mode icon used in labels and the selection card. */
export const TRANSIT_MODE_ICON = Object.freeze({ export const TRANSIT_MODE_ICON = Object.freeze({
bus: '🚌', bus: '🚌',
trolleybus: '🚎',
tram: '🚊', tram: '🚊',
cable_tram: '🚋',
subway: '🚇', subway: '🚇',
monorail: '🚝',
rail: '🚆', rail: '🚆',
funicular: '🚞',
aerial_lift: '🚡',
ferry: '⛴️', ferry: '⛴️',
unknown: '🚏', unknown: '🚏',
}); });
/** The basic GTFS route types (0–7, 11, 12) by mode. */
const BASIC_ROUTE_TYPE_MODES = Object.freeze({
0: 'tram',
1: 'subway',
2: 'rail',
3: 'bus',
4: 'ferry',
5: 'cable_tram',
6: 'aerial_lift',
7: 'funicular',
11: 'trolleybus',
12: 'monorail',
});
/**
* A GTFS `route_type` as an integer: a basic type (0–12) or an extended one
* (100–1799), as a number or its decimal text. Null for anything else.
* @param {unknown} value
* @returns {number|null}
*/
export function transitRouteType(value) {
const n =
typeof value === 'number'
? value
: typeof value === 'string' && /^\d{1,4}$/.test(value.trim())
? Number(value.trim())
: NaN;
return Number.isInteger(n) && n >= 0 && n < 1800 ? n : null;
}
/**
* The mode of a GTFS `route_type`: the basic types, and the extended ones
* mapped to the basic mode they are a kind of (a coach, 200, is a bus; a
* high-speed train, 101, is rail; 405 is a monorail). Air, taxi and other
* services, and a value GTFS does not define, are `unknown`.
* @param {unknown} routeType
* @returns {string} One of TRANSIT_MODES.
*/
export function transitModeOfRouteType(routeType) {
const n = transitRouteType(routeType);
if (n === null) return 'unknown';
if (n < 100) return BASIC_ROUTE_TYPE_MODES[n] ?? 'unknown';
if (n === 405) return 'monorail';
const family = Math.floor(n / 100);
return (
{
1: 'rail', // railway
2: 'bus', // coach
3: 'rail', // suburban railway
4: 'subway', // urban railway
5: 'subway', // metro
6: 'subway', // underground
7: 'bus',
8: 'trolleybus',
9: 'tram',
10: 'ferry', // water transport
12: 'ferry', // ferry
13: 'aerial_lift',
14: 'funicular',
}[family] ?? 'unknown'
);
}
/** /**
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry * MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are * colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
@@ -443,7 +520,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
function liaRouteMode(routeId) { function liaRouteMode(routeId) {
if (!routeId) return 'unknown'; if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId); const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'rail'; if (line === 'FUNI') return 'funicular';
return /^[AB]$/.test(line) ? 'tram' : 'bus'; return /^[AB]$/.test(line) ? 'tram' : 'bus';
} }
@@ -474,6 +551,7 @@ function astuceRouteLabel(routeId) {
* Prague PID route ids are `L` plus a number whose range says what runs it, * Prague PID route ids are `L` plus a number whose range says what runs it,
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams * per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the * (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
* trolleybus routes 232 and 275 among them, GTFS route type 11; the
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C, * 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest * 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from * of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
@@ -487,10 +565,11 @@ function pidRoute(routeId) {
if (!match) return { mode: 'unknown', label: routeId }; if (!match) return { mode: 'unknown', label: routeId };
const n = Number(match[1]); const n = Number(match[1]);
// i18n-ignore: line code, as signed // i18n-ignore: line code, as signed
if (n === 49) return { mode: 'rail', label: 'LD' }; if (n === 49) return { mode: 'funicular', label: 'LD' };
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) }; if (n >= 50 && n <= 59) return { mode: 'trolleybus', label: String(n) };
if (n < 100) return { mode: 'tram', label: String(n) }; if (n < 100) return { mode: 'tram', label: String(n) };
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` }; if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
if (n === 232 || n === 275) return { mode: 'trolleybus', label: String(n) };
if (n < 991) return { mode: 'bus', label: String(n) }; if (n < 991) return { mode: 'bus', label: String(n) };
// i18n-ignore: metro line letters, as signed // i18n-ignore: metro line letters, as signed
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] }; if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
@@ -512,6 +591,102 @@ function pidRouteLabel(routeId) {
return pidRoute(routeId).label; return pidRoute(routeId).label;
} }
/**
* A feed's route hint from its static GTFS routes.txt: the route ids of
* every mode but `bus`, which is everything else.
* @param {Record<string, string[]>} idsByMode
* @returns {(routeId: string|null) => string}
*/
function routeModeTable(idsByMode) {
const modes = new Map(
Object.entries(idsByMode).flatMap(([mode, ids]) =>
ids.map((id) => [id, mode]),
),
);
return (routeId) => (routeId ? (modes.get(routeId) ?? 'bus') : 'unknown');
}
/**
* Bucharest TPBI by route id, from its routes.txt (checked 2026-11-14): the
* trams (route type 0), metro lines M1–M5 (1) and trolleybuses (11); every
* other route is a bus. Realtime route ids are the static ones.
*/
const tpbiRouteMode = routeModeTable({
tram: [
'54',
'55',
'57',
'61',
'63',
'65',
'66',
'67',
'68',
'69',
'70',
'814',
'915',
'1013',
],
subway: ['968', '969', '970', '971', '999'],
trolleybus: [
'72',
'73',
'74',
'75',
'76',
'78',
'81',
'83',
'84',
'85',
'86',
'87',
'247',
'910',
'916',
'924',
'925',
],
});
/**
* Cluj-Napoca CTP by route id, from its routes.txt (checked 2026-11-14): the
* trams 100–102L (route type 0) and the trolleybuses (11).
*/
const ctpClujRouteMode = routeModeTable({
tram: ['2', '3', '4', '5'],
trolleybus: [
'1',
'10',
'14',
'15',
'22',
'36',
'49',
'53',
'54',
'55',
'117',
'119',
'120',
'153',
'166',
'187',
],
});
/**
* Turin GTT by route id, from its routes.txt (checked 2026-11-14): the trams
* (route type 0), metro M1, and the Sassi–Superga rack tramway `79U`, which
* the file types as a funicular (7).
*/
const gttRouteMode = routeModeTable({
tram: ['3U', '4U', '7U', '9U', '10U', '13U', '15U', '16CDU', '16CSU'],
subway: ['METROU'],
funicular: ['79U'],
});
/** /**
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS * Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters. * routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
@@ -571,6 +746,7 @@ function transitFeed(fields) {
* url: string, headers?: Record<string, string>, * url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string, * license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string, * defaultMode: string, routeMode?: (routeId: string|null) => string,
* routeCorrections?: Readonly<Record<string, string>>,
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string, * speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
* creditDated?: boolean, * creditDated?: boolean,
* }>>} * }>>}
@@ -579,6 +755,9 @@ function transitFeed(fields) {
* stretches the proxy's cache for an upstream that rate-limits below one * stretches the proxy's cache for an upstream that rate-limits below one
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date * request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
* to the credit for licences that ask for them (`transitFeedCredit`). * to the credit for licences that ask for them (`transitFeedCredit`).
* `routeCorrections` names the routes whose `route_type` the operator's own
* routes.txt gets wrong, `route_id` to the mode they really are; a correction
* outranks the route type, which would otherwise outrank the route hint.
*/ */
export const TRANSIT_FEED_REGISTRY = Object.freeze([ export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({ Object.freeze({
@@ -1264,6 +1443,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.', note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: tpbiRouteMode,
}), }),
transitFeed({ transitFeed({
id: 'roma-mobilita', id: 'roma-mobilita',
@@ -1296,6 +1476,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.', note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: gttRouteMode,
routeLabel: gttRouteLabel, routeLabel: gttRouteLabel,
}), }),
transitFeed({ transitFeed({
@@ -1401,6 +1582,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: ctpClujRouteMode,
}), }),
transitFeed({ transitFeed({
id: 'ctp-iasi', id: 'ctp-iasi',
@@ -1567,6 +1749,8 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus', defaultMode: 'bus',
routeLabel: liaRouteLabel, routeLabel: liaRouteLabel,
routeMode: liaRouteMode, routeMode: liaRouteMode,
// LiA's routes.txt types the funicular (`8-FUNI`) as a bus (3).
routeCorrections: Object.freeze({ '8-FUNI': 'funicular' }),
}), }),
transitFeed({ transitFeed({
id: 'filbleu-tours', id: 'filbleu-tours',
@@ -1664,54 +1848,89 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
} }
/** /**
* The feed's own hint for a vehicle: its fleet number when the feed maps * What settles a vehicle's mode, most specific first: the feed's fleet hint
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows. * (`vehicleMode`, which knows a bus standing in on a tram route), else the
* feed's correction for a route its routes.txt mistypes (`routeCorrections`),
* else the GTFS `route_type` of its route when the record carries one, else
* the feed's route hint (`routeMode`). Null when none of them knows.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId * @param {string|null} routeId
* @param {string|null} vehicleId * @param {string|null} vehicleId
* @param {unknown} routeType GTFS `route_type`, when the record has one.
* @returns {string|null} * @returns {string|null}
*/ */
function hintedMode(feed, routeId, vehicleId) { function hintedMode(feed, routeId, vehicleId, routeType) {
const byVehicle = const byVehicle =
typeof feed?.vehicleMode === 'function' typeof feed?.vehicleMode === 'function'
? feed.vehicleMode(vehicleId) ? feed.vehicleMode(vehicleId)
: null; : null;
if (byVehicle && byVehicle !== 'unknown') return byVehicle; if (byVehicle && byVehicle !== 'unknown') return byVehicle;
const corrections = feed?.routeCorrections;
if (routeId && corrections && Object.hasOwn(corrections, routeId))
return corrections[routeId];
const byType = transitModeOfRouteType(routeType);
if (byType !== 'unknown') return byType;
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
} }
/** /**
* Mode for a vehicle: the feed's fleet or route hint when it has one, else * Mode for a vehicle: its fleet hint, route type or route hint when one
* its default. * knows ({@link hintedMode}), else its feed's default.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId GTFS route_id from the vehicle's trip. * @param {string|null} routeId GTFS route_id from the vehicle's trip.
* @param {string|null} [vehicleId] GTFS-RT vehicle id. * @param {string|null} [vehicleId] GTFS-RT vehicle id.
* @param {unknown} [routeType] GTFS `route_type` of the route, when known.
* @returns {string} One of TRANSIT_MODES. * @returns {string} One of TRANSIT_MODES.
*/ */
export function transitModeFor(feed, routeId, vehicleId = null) { export function transitModeFor(
const hinted = hintedMode(feed, routeId, vehicleId); feed,
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
const mode = const mode =
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown'; hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
return TRANSIT_MODES.includes(mode) ? mode : 'unknown'; return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
} }
/** /**
* Whether the mode came from the route id rather than the feed's default. A * Whether the mode came from the vehicle, its route type or its route id
* default is a guess about a fleet — TransLink's is "bus" while it publishes * rather than the feed's default. A default is a guess about a fleet —
* rail in the same feed — so a consumer judging physical plausibility must * TransLink's is "bus" while it publishes rail in the same feed — so a
* not hold a defaulted vehicle to a bus's limits. * consumer judging physical plausibility must not hold a defaulted vehicle
* to a bus's limits.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId * @param {string|null} routeId
* @param {string|null} [vehicleId] * @param {string|null} [vehicleId]
* @param {unknown} [routeType]
* @returns {boolean} * @returns {boolean}
*/ */
export function transitModeResolved(feed, routeId, vehicleId = null) { export function transitModeResolved(
const hinted = hintedMode(feed, routeId, vehicleId); feed,
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
return ( return (
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted) Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
); );
} }
/**
* The route type a vehicle's model is chosen by: its record's, when that type
* is a kind of the mode it was given. A fleet hint can overrule the route (a
* bus on a tram route), and then the route's type does not describe it.
* @param {string} mode The vehicle's mode ({@link transitModeFor}).
* @param {unknown} routeType
* @returns {number|null}
*/
export function transitModelRouteType(mode, routeType) {
const n = transitRouteType(routeType);
return n !== null && transitModeOfRouteType(n) === mode ? n : null;
}
/** /**
* The route as a rider would say it. Most feeds publish that already, so this * The route as a rider would say it. Most feeds publish that already, so this
* is identity for them; a feed that publishes internal keys supplies its own * is identity for them; a feed that publishes internal keys supplies its own
+119 -3
View File
@@ -11,9 +11,13 @@ import {
haversineKm, haversineKm,
publicTransitCatalog, publicTransitCatalog,
transitFeedsInRange, transitFeedsInRange,
TRANSIT_MODE_ICON,
transitModeFor, transitModeFor,
transitModeOfRouteType,
transitModeResolved, transitModeResolved,
transitModelRouteType,
transitRouteLabel, transitRouteLabel,
transitRouteType,
} from './transitFeeds.js'; } from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
@@ -729,8 +733,9 @@ test('Prague route-id ranges give metro, trains, ferries and trolleybuses their
transitRouteLabel(feed, routeId), transitRouteLabel(feed, routeId),
]; ];
assert.deepEqual(read('L22'), ['tram', '22']); assert.deepEqual(read('L22'), ['tram', '22']);
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus'); assert.deepEqual(read('L58'), ['trolleybus', '58']);
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular'); assert.deepEqual(read('L275'), ['trolleybus', '275'], 'route type 11');
assert.deepEqual(read('L49'), ['funicular', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']); assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']); assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']); assert.deepEqual(read('L991'), ['subway', 'A']);
@@ -767,7 +772,7 @@ test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () =>
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus'); assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F'); assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
const lia = getTransitFeed('lia-le-havre'); const lia = getTransitFeed('lia-le-havre');
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail'); assert.equal(transitModeFor(lia, '8-FUNI'), 'funicular');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3'); assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9'); assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A'); assert.equal(transitRouteLabel(lia, '8-A'), 'A');
@@ -778,3 +783,114 @@ test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id); assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
} }
}); });
test('a GTFS route type names its mode, basic and extended alike', () => {
for (const [routeType, mode] of [
[0, 'tram'],
[1, 'subway'],
[2, 'rail'],
[3, 'bus'],
[4, 'ferry'],
[5, 'cable_tram'],
[6, 'aerial_lift'],
[7, 'funicular'],
[11, 'trolleybus'],
[12, 'monorail'],
[101, 'rail'],
[106, 'rail'],
[200, 'bus'],
[300, 'rail'],
[401, 'subway'],
[405, 'monorail'],
[702, 'bus'],
[800, 'trolleybus'],
[900, 'tram'],
[1000, 'ferry'],
[1200, 'ferry'],
[1300, 'aerial_lift'],
[1400, 'funicular'],
['11', 'trolleybus'],
])
assert.equal(transitModeOfRouteType(routeType), mode, String(routeType));
// Air, taxi and other services, and values GTFS does not define.
for (const other of [8, 1100, 1500, 1700, 1800, -1, 2.5, '', 'tram', null])
assert.equal(transitModeOfRouteType(other), 'unknown', String(other));
assert.equal(transitRouteType(' 200 '), 200);
assert.equal(transitRouteType('2e2'), null);
});
test('every mode has an icon, and every mode a route type names is a mode', () => {
for (const mode of TRANSIT_MODES) assert.ok(TRANSIT_MODE_ICON[mode], mode);
for (let routeType = 0; routeType < 1800; routeType++)
assert.ok(TRANSIT_MODES.includes(transitModeOfRouteType(routeType)));
});
test('a route type settles the mode before a route hint or the default', () => {
const mbta = getTransitFeed('mbta');
// The hint calls every numbered MBTA route a bus; a route type is finer.
assert.equal(transitModeFor(mbta, '71'), 'bus');
assert.equal(transitModeFor(mbta, '71', 'y1', 11), 'trolleybus');
assert.equal(transitModeResolved(mbta, '71', 'y1', 11), true);
// A default-only feed is resolved by a route type alone.
const durham = getTransitFeed('durham-region');
assert.equal(transitModeResolved(durham, '915', 'b1'), false);
assert.equal(transitModeFor(durham, '915', 'b1', 200), 'bus');
assert.equal(transitModeResolved(durham, '915', 'b1', 200), true);
// A type that names no mode leaves the hints in charge.
assert.equal(transitModeFor(mbta, 'Red', null, 1700), 'subway');
assert.equal(transitModeFor(mbta, 'Red', null, 'x'), 'subway');
});
test('a fleet hint outranks the route type, and then the type picks no model', () => {
const ttc = getTransitFeed('ttc-toronto');
// A bus standing in on the 501 streetcar route (route type 0).
const mode = transitModeFor(ttc, '501', '8400', 0);
assert.equal(mode, 'bus');
assert.equal(transitModelRouteType(mode, 0), null);
assert.equal(transitModelRouteType('tram', 0), 0);
assert.equal(transitModelRouteType('bus', 200), 200, 'a coach is a bus');
assert.equal(transitModelRouteType('bus', null), null);
});
test('a route correction outranks a mistyped route type, and only for its route', () => {
const lia = getTransitFeed('lia-le-havre');
// LiA's routes.txt types the funicular as a bus (3).
assert.equal(transitModeFor(lia, '8-FUNI', null, 3), 'funicular');
assert.equal(transitModeResolved(lia, '8-FUNI', null, 3), true);
// A correction is a mode, so the bus type picks no model for it.
assert.equal(transitModelRouteType('funicular', 3), null);
// Every other route still takes its route type.
assert.equal(transitModeFor(lia, '8-A', null, 0), 'tram');
assert.equal(transitModeFor(lia, '8-C3', null, 3), 'bus');
assert.equal(transitModeFor(lia, null, null, 3), 'bus');
// Corrections are narrow: a few named routes, each to a known mode.
for (const feed of TRANSIT_FEED_REGISTRY) {
if (!feed.routeCorrections) continue;
assert.ok(Object.isFrozen(feed.routeCorrections), feed.id);
for (const [route, mode] of Object.entries(feed.routeCorrections)) {
assert.ok(TRANSIT_MODES.includes(mode), `${feed.id} ${route}`);
assert.notEqual(mode, 'unknown', `${feed.id} ${route}`);
}
}
// An inherited key is not a correction.
assert.equal(transitModeFor(lia, 'toString', null, 3), 'bus');
});
// Route ids as each realtime feed and its operator's routes.txt carried them
// on 2026-11-14.
test('Bucharest, Cluj and Turin route ids give trams, trolleybuses and a funicular', () => {
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
assert.equal(mode('tpbi-bucuresti', '85'), 'trolleybus', 'line 74');
assert.equal(mode('tpbi-bucuresti', '1013'), 'tram', 'line 4');
assert.equal(mode('tpbi-bucuresti', '968'), 'subway', 'M1');
assert.equal(mode('tpbi-bucuresti', '828'), 'bus');
assert.equal(mode('tpbi-bucuresti', 'PV1_407'), 'bus');
assert.equal(mode('ctp-cluj', '1'), 'trolleybus');
assert.equal(mode('ctp-cluj', '2'), 'tram', 'line 100');
assert.equal(mode('ctp-cluj', '6'), 'bus', 'line 19');
assert.equal(mode('gtt-torino', '79U'), 'funicular', 'Sassi–Superga');
assert.equal(mode('gtt-torino', '16CDU'), 'tram');
assert.equal(mode('gtt-torino', 'METROU'), 'subway');
assert.equal(mode('gtt-torino', '6U'), 'bus');
assert.equal(mode('gtt-torino', null), 'bus', 'no route: the default');
});
+80 -1
View File
@@ -21,7 +21,9 @@ import {
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is * at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
* short and blunt, a tram is long and slender with articulation joints, a metro * short and blunt, a tram is long and slender with articulation joints, a metro
* car is squared off, a mainline train has a tapered nose, and a ferry is a * car is squared off, a mainline train has a tapered nose, and a ferry is a
* hull with a bow. * hull with a bow. A trolleybus trails its two poles, a cable car is a short
* single car, a monorail the slimmest bullet, a funicular hangs off its rope,
* and a gondola is a cabin on its cable.
* *
* Interior detail is drawn with the same dark hairline as the edge — windscreen * Interior detail is drawn with the same dark hairline as the edge — windscreen
* lines, articulation joints, a deck line — because a solid dark panel would * lines, articulation joints, a deck line — because a solid dark panel would
@@ -103,6 +105,49 @@ const HEADING_BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`, <path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
// ── Trolleybus: a bus a little narrower, trailing the two trolley poles
// that reach back to its wires. Body and poles are one outline, so no
// edge is drawn across the body.
trolleybus: `
<path d="M0,-41 L15,-26 L15,25 C15,28.5 13,30 9,30 L7.5,30 L7.5,42 L4,42
L4,30 L-4,30 L-4,42 L-7.5,42 L-7.5,30 L-9,30 C-13,30 -15,28.5 -15,25
L-15,-26 Z"
fill="white" ${STROKE}/>
<path d="M-10,-21 L10,-21" ${DETAIL}/>`,
// ── Cable car (street cable railway): a short single car, under half a
// tram, with open platforms at both ends.
cable_tram: `
<path d="M0,-30 L11,-21 L11,26 C11,28 9,29 0,29 C-9,29 -11,28 -11,26
L-11,-21 Z"
fill="white" ${STROKE}/>
<path d="M-11,-12 L11,-12" ${DETAIL}/>
<path d="M-11,18 L11,18" ${DETAIL}/>`,
// ── Monorail: the slimmest train in the set, a rounded bullet nose over
// its beam.
monorail: `
<path d="M0,-45 C3,-45 8.5,-36 8.5,-22 L8.5,42 L-8.5,42 L-8.5,-22
C-8.5,-36 -3,-45 0,-45 Z"
fill="white" ${STROKE}/>
<path d="M0,-18 L0,38" ${DETAIL}/>`,
// ── Funicular: a squat car hauled by the rope at its upper end.
funicular: `
<path d="M-6,-30 L6,-30 L11,-24 L11,36 L-11,36 L-11,-24 Z
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z"
fill="white" ${STROKE}/>
<path d="M-11,-8 L11,-8" ${DETAIL}/>
<path d="M-11,14 L11,14" ${DETAIL}/>`,
// ── Gondola (aerial lift): a cabin hung on the cable that runs through
// it; the cabin's nose is its front.
aerial_lift: `
<path d="M0,-18 L12,-7 L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14
L-12,-7 Z
M-2.5,-40 L2.5,-40 L2.5,46 L-2.5,46 Z"
fill="white" ${STROKE}/>`,
// ── Unknown: a plain marker with a nose and nothing else, so an // ── Unknown: a plain marker with a nose and nothing else, so an
// unclassified vehicle never pretends to be a mode it is not. // unclassified vehicle never pretends to be a mode it is not.
unknown: ` unknown: `
@@ -146,6 +191,40 @@ const NEUTRAL_BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`, <path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
trolleybus: `
<path d="M-15,-26 C-15,-30 -13,-32 -9,-32 L-7.5,-32 L-7.5,-42 L-4,-42
L-4,-32 L4,-32 L4,-42 L7.5,-42 L7.5,-32 L9,-32 C13,-32 15,-30 15,-26
L15,26 C15,30 13,32 9,32 L7.5,32 L7.5,42 L4,42 L4,32 L-4,32 L-4,42
L-7.5,42 L-7.5,32 L-9,32 C-13,32 -15,30 -15,26 Z"
fill="white" ${STROKE}/>`,
cable_tram: `
<path d="M-11,-25 C-11,-27 -9,-28 0,-28 C9,-28 11,-27 11,-25
L11,25 C11,27 9,28 0,28 C-9,28 -11,27 -11,25 Z"
fill="white" ${STROKE}/>
<path d="M-11,-17 L11,-17" ${DETAIL}/>
<path d="M-11,17 L11,17" ${DETAIL}/>`,
monorail: `
<path d="M0,-44 C5,-44 8.5,-38 8.5,-28 L8.5,28 C8.5,38 5,44 0,44
C-5,44 -8.5,38 -8.5,28 L-8.5,-28 C-8.5,-38 -5,-44 0,-44 Z"
fill="white" ${STROKE}/>
<path d="M0,-24 L0,24" ${DETAIL}/>`,
funicular: `
<path d="M-11,-30 L11,-30 L11,30 L-11,30 Z
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z
M-1.75,30 L1.75,30 L1.75,46 L-1.75,46 Z"
fill="white" ${STROKE}/>
<path d="M-11,-10 L11,-10" ${DETAIL}/>
<path d="M-11,10 L11,10" ${DETAIL}/>`,
aerial_lift: `
<path d="M-12,-14 C-12,-16 -10,-17 0,-17 C10,-17 12,-16 12,-14
L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14 Z
M-2.5,-46 L2.5,-46 L2.5,46 L-2.5,46 Z"
fill="white" ${STROKE}/>`,
unknown: ` unknown: `
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26 <path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z" L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
+8 -2
View File
@@ -62,7 +62,7 @@ test('the raster cache is bounded by kind, size and halo — never by call count
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length); assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
}); });
test('all 72 variants retain the specified final halo at both display sizes', async () => { test('every variant retains the specified final halo at both display sizes', async () => {
const { presetSpriteScale, presetSpriteOutlinePx } = const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js'); await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) { for (const kind of TRANSIT_ICON_KINDS) {
@@ -97,7 +97,8 @@ test('all 72 variants retain the specified final halo at both display sizes', as
} }
for (let i = 0; i < 100; i++) for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i }); transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
assert.equal(transitIconCacheSize(), 72); // Every kind at two sizes, three profiles, with and without a course.
assert.equal(transitIconCacheSize(), TRANSIT_ICON_KINDS.length * 12);
}); });
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -197,6 +198,11 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
subway: 11.5, subway: 11.5,
rail: 10.5, rail: 10.5,
ferry: 12.5, ferry: 12.5,
trolleybus: 15,
cable_tram: 11,
monorail: 8.5,
funicular: 11,
aerial_lift: 12,
unknown: 10, unknown: 10,
}; };
for (const selected of [false, true]) { for (const selected of [false, true]) {
+11 -1
View File
@@ -60,9 +60,14 @@ const SPRITE_STYLE = Object.freeze({
outlinePx: 1.25, outlinePx: 1.25,
rgbaByMode: Object.freeze({ rgbaByMode: Object.freeze({
bus: Object.freeze([0, 255, 102, 1]), bus: Object.freeze([0, 255, 102, 1]),
trolleybus: Object.freeze([170, 255, 0, 1]),
tram: Object.freeze([255, 179, 0, 1]), tram: Object.freeze([255, 179, 0, 1]),
cable_tram: Object.freeze([255, 120, 0, 1]),
subway: Object.freeze([255, 59, 48, 1]), subway: Object.freeze([255, 59, 48, 1]),
monorail: Object.freeze([255, 64, 170, 1]),
rail: Object.freeze([215, 139, 255, 1]), rail: Object.freeze([215, 139, 255, 1]),
funicular: Object.freeze([255, 214, 140, 1]),
aerial_lift: Object.freeze([90, 140, 255, 1]),
ferry: Object.freeze([58, 208, 255, 1]), ferry: Object.freeze([58, 208, 255, 1]),
unknown: Object.freeze([255, 255, 255, 1]), unknown: Object.freeze([255, 255, 255, 1]),
}), }),
@@ -72,9 +77,14 @@ const SPRITE_STYLE = Object.freeze({
/** Modes with a bracket tier of their own. */ /** Modes with a bracket tier of their own. */
const TIER_MODES = Object.freeze([ const TIER_MODES = Object.freeze([
'bus', 'bus',
'trolleybus',
'tram', 'tram',
'cable_tram',
'subway', 'subway',
'monorail',
'rail', 'rail',
'funicular',
'aerial_lift',
'ferry', 'ferry',
'unknown', 'unknown',
]); ]);
@@ -128,7 +138,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) {
/** /**
* Detection-overlay tier key for a transit mode. Every theme's `tiers` * Detection-overlay tier key for a transit mode. Every theme's `tiers`
* table carries these six, so the bracket colour is per mode in every * table carries one per mode, so the bracket colour is per mode in every
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds * preset. Unlike traffic, transit sets its tier keyless as well: the feeds
* are keyless, and there is no separate keyless experience to keep * are keyless, and there is no separate keyless experience to keep
* byte-identical. * byte-identical.
+1
View File
@@ -19,6 +19,7 @@ import {
decodeVehiclePositions, decodeVehiclePositions,
} from './gtfsRealtime.js'; } from './gtfsRealtime.js';
import { getTransitFeed } from './transitFeeds.js'; import { getTransitFeed } from './transitFeeds.js';
export { transitModeFor, transitRouteType } from './transitFeeds.js';
/** Fresh window: a snapshot younger than this is served without refetching. */ /** Fresh window: a snapshot younger than this is served without refetching. */
export const TRANSIT_PROXY_TTL_MS = 15_000; export const TRANSIT_PROXY_TTL_MS = 15_000;
+6 -1
View File
@@ -3962,5 +3962,10 @@
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل", "Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة", "Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض" "Reset view": "إعادة ضبط العرض",
"Trolleybus": "ترولي باص",
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك"
} }
+6 -1
View File
@@ -3614,5 +3614,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden", "Reload": "Neu laden",
"Retry": "Erneut versuchen", "Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen" "Reset view": "Ansicht zurücksetzen",
"Trolleybus": "Oberleitungsbus",
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn"
} }
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar", "Reload": "Recargar",
"Retry": "Reintentar", "Retry": "Reintentar",
"Reset view": "Restablecer la vista" "Reset view": "Restablecer la vista",
"Trolleybus": "Trolebús",
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
"Gondola": "Góndola"
} }
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger", "Reload": "Recharger",
"Retry": "Réessayer", "Retry": "Réessayer",
"Reset view": "Réinitialiser la vue" "Reset view": "Réinitialiser la vue",
"Trolleybus": "Trolleybus",
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
"Gondola": "Télécabine"
} }
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש", "Reload": "טען מחדש",
"Retry": "נסה שוב", "Retry": "נסה שוב",
"Reset view": "איפוס תצוגה" "Reset view": "איפוס תצוגה",
"Trolleybus": "טרוליבוס",
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
"Gondola": "רכבל"
} }
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica", "Reload": "Ricarica",
"Retry": "Riprova", "Retry": "Riprova",
"Reset view": "Ripristina vista" "Reset view": "Ripristina vista",
"Trolleybus": "Filobus",
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
"Gondola": "Cabinovia"
} }
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み", "Reload": "再読み込み",
"Retry": "再試行", "Retry": "再試行",
"Reset view": "ビューをリセット" "Reset view": "ビューをリセット",
"Trolleybus": "トロリーバス",
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ"
} }
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar", "Reload": "Recarregar",
"Retry": "Tentar novamente", "Retry": "Tentar novamente",
"Reset view": "Redefinir vista" "Reset view": "Redefinir vista",
"Trolleybus": "Trólebus",
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
"Gondola": "Teleférico"
} }
+6 -1
View File
@@ -3788,5 +3788,10 @@
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить", "Reload": "Перезагрузить",
"Retry": "Повторить", "Retry": "Повторить",
"Reset view": "Сбросить вид" "Reset view": "Сбросить вид",
"Trolleybus": "Троллейбус",
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога"
} }
+6 -1
View File
@@ -3614,5 +3614,10 @@
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om", "Reload": "Ladda om",
"Retry": "Försök igen", "Retry": "Försök igen",
"Reset view": "Återställ vy" "Reset view": "Återställ vy",
"Trolleybus": "Trådbuss",
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
"Gondola": "Kabinbana"
} }
+6 -1
View File
@@ -3788,5 +3788,10 @@
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити", "Reload": "Перезавантажити",
"Retry": "Повторити", "Retry": "Повторити",
"Reset view": "Скинути вигляд" "Reset view": "Скинути вигляд",
"Trolleybus": "Тролейбус",
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога"
} }
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载", "Reload": "重新加载",
"Retry": "重试", "Retry": "重试",
"Reset view": "重置视图" "Reset view": "重置视图",
"Trolleybus": "无轨电车",
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
"Gondola": "缆车"
} }
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載", "Reload": "重新加載",
"Retry": "重試", "Retry": "重試",
"Reset view": "重置視圖" "Reset view": "重置視圖",
"Trolleybus": "無軌電車",
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
"Gondola": "纜車"
} }
+4
View File
@@ -291,6 +291,7 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
@@ -501,6 +502,7 @@
"MINI", "MINI",
"MISSION", "MISSION",
"MM", "MM",
"Monorail",
"MSG/S", "MSG/S",
"Metal", "Metal",
"Minimal", "Minimal",
@@ -571,6 +573,7 @@
"TAGS · {tags}", "TAGS · {tags}",
"TEMP", "TEMP",
"TLE", "TLE",
"Trolleybus",
"TYPE", "TYPE",
"Tallinn", "Tallinn",
"Techno", "Techno",
@@ -950,6 +953,7 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
+12 -1
View File
@@ -16,6 +16,7 @@ import {
import { import {
transitModeFor, transitModeFor,
transitModeResolved, transitModeResolved,
transitModelRouteType,
} from '../../data/transitFeeds.js'; } from '../../data/transitFeeds.js';
import { import {
attachFloorToPlace, attachFloorToPlace,
@@ -222,12 +223,19 @@ export function createIngestion({ state, services, parts, source }) {
for (const record of records) { for (const record of records) {
if (isStaleVehicleFix(record, now, fetchedAt)) continue; if (isStaleVehicleFix(record, now, fetchedAt)) continue;
const key = transitVehicleKey(feed.id, record.id); const key = transitVehicleKey(feed.id, record.id);
const mode = transitModeFor(feed, record.routeId, record.id); const mode = transitModeFor(
feed,
record.routeId,
record.id,
record.routeType,
);
const modeInferred = !transitModeResolved( const modeInferred = !transitModeResolved(
feed, feed,
record.routeId, record.routeId,
record.id, record.id,
record.routeType,
); );
const routeType = transitModelRouteType(mode, record.routeType);
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it. // A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
const stamped = vehicleReportTimeMs(record, fetchedAt); const stamped = vehicleReportTimeMs(record, fetchedAt);
const reportAt = const reportAt =
@@ -267,6 +275,7 @@ export function createIngestion({ state, services, parts, source }) {
continue; continue;
} }
if (entry.mode !== mode) parts.rendering.paintMode(entry, mode); if (entry.mode !== mode) parts.rendering.paintMode(entry, mode);
entry.routeType = routeType;
entry.modeInferred = modeInferred; entry.modeInferred = modeInferred;
updatePlayback(entry, now, monoNow); updatePlayback(entry, now, monoNow);
parts.rendering.schedulePlayback(entry); parts.rendering.schedulePlayback(entry);
@@ -320,6 +329,8 @@ export function createIngestion({ state, services, parts, source }) {
feedId: feed.id, feedId: feed.id,
marker: null, marker: null,
mode, mode,
// The GTFS route type its model is chosen by, when it has one.
routeType,
modeInferred, modeInferred,
heightM: prior === null ? 0 : prior, heightM: prior === null ? 0 : prior,
heightCell: height.cell, heightCell: height.cell,
+5
View File
@@ -11,9 +11,14 @@ import {
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
const MODE_MAX_SPEED_MPS = Object.freeze({ const MODE_MAX_SPEED_MPS = Object.freeze({
bus: 36, // ~130 km/h — a coach on a motorway bus: 36, // ~130 km/h — a coach on a motorway
trolleybus: 28, // ~100 km/h — wired to the street
tram: 28, // ~100 km/h — interurban light rail tram: 28, // ~100 km/h — interurban light rail
cable_tram: 12, // ~43 km/h — the cable runs at a walking-pace multiple
subway: 42, // ~150 km/h subway: 42, // ~150 km/h
monorail: 42, // ~150 km/h
rail: 111, // ~400 km/h — high-speed services exist on some feeds rail: 111, // ~400 km/h — high-speed services exist on some feeds
funicular: 16, // ~58 km/h — the fastest lines
aerial_lift: 15, // ~54 km/h — the fastest gondolas and trams
ferry: 25, // ~90 km/h — fast catamarans ferry: 25, // ~90 km/h — fast catamarans
unknown: 56, // ~200 km/h unknown: 56, // ~200 km/h
}); });
+15
View File
@@ -189,9 +189,14 @@ export const SELECTED_CARD_REFRESH_MS = 250;
/** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */ /** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */
export const TRANSIT_MODE_COLORS = Object.freeze({ export const TRANSIT_MODE_COLORS = Object.freeze({
bus: '#5EF08A', bus: '#5EF08A',
trolleybus: '#B6F04A',
tram: '#FFC24A', tram: '#FFC24A',
cable_tram: '#FF8F4A',
subway: '#FF4538', subway: '#FF4538',
monorail: '#FF6FB8',
rail: '#D9A6FF', rail: '#D9A6FF',
funicular: '#E8C98A',
aerial_lift: '#7FA8FF',
ferry: '#5FD6FF', ferry: '#5FD6FF',
unknown: '#D8DDE5', unknown: '#D8DDE5',
}); });
@@ -526,9 +531,14 @@ const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */ /** The mode in plain words, for a card a person reads rather than scans. */
const TRANSIT_MODE_WORD = Object.freeze({ const TRANSIT_MODE_WORD = Object.freeze({
bus: msg('Bus'), bus: msg('Bus'),
trolleybus: msg('Trolleybus'),
tram: msg('Tram'), tram: msg('Tram'),
cable_tram: msg('Cable car'),
subway: msg('Subway'), subway: msg('Subway'),
monorail: msg('Monorail'),
rail: msg('Train'), rail: msg('Train'),
funicular: msg('Funicular'),
aerial_lift: msg('Gondola'),
ferry: msg('Ferry'), ferry: msg('Ferry'),
unknown: msg('Transit vehicle'), unknown: msg('Transit vehicle'),
}); });
@@ -536,9 +546,14 @@ const TRANSIT_MODE_WORD = Object.freeze({
/** Short, fixed-width mode word for the detection card's class field. */ /** Short, fixed-width mode word for the detection card's class field. */
const TRANSIT_MODE_ABBR = Object.freeze({ const TRANSIT_MODE_ABBR = Object.freeze({
bus: 'BUS', bus: 'BUS',
trolleybus: 'TROLLEY',
tram: 'TRAM', tram: 'TRAM',
cable_tram: 'CABLE',
subway: 'METRO', subway: 'METRO',
monorail: 'MONO',
rail: 'RAIL', rail: 'RAIL',
funicular: 'FUNI',
aerial_lift: 'GONDOLA',
ferry: 'FERRY', ferry: 'FERRY',
unknown: 'TRANSIT', unknown: 'TRANSIT',
}); });
+8 -2
View File
@@ -531,9 +531,15 @@ export function createRendering({ state, services, parts }) {
groundLiftM, groundLiftM,
}; };
} }
if (item.mode !== entry.mode) { if (item.mode !== entry.mode || item.routeType !== entry.routeType) {
item.mode = entry.mode; item.mode = entry.mode;
item.spec = transitModelSpec(set, { mode: entry.mode }); item.routeType = entry.routeType;
// The route type picks the finer model (a coach, a high-speed set);
// the mode is the fallback for a type the model table lacks.
item.spec = transitModelSpec(set, {
routeType: entry.routeType,
mode: entry.mode,
});
item.tint = item.tint =
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
} }
+2 -9
View File
@@ -8,16 +8,9 @@ import {
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js'; import { fixDistanceM } from './movement.js';
import { TRANSIT_MODE_COLORS } from './policy.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js'; import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
bus: '#5EF08A',
tram: '#FFC24A',
subway: '#FF4538',
rail: '#D9A6FF',
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
// Final Cesium batching uses 3,152 bytes per two-lane edge. // Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB. // The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640; export const TRAIL_VERTEX_LIMIT = 640;
@@ -41,7 +34,7 @@ export function createTrails({ state, services, parts, source }) {
renderer?.setStyle( renderer?.setStyle(
transitStyleProfile(state._stylePreset) === 'mono' transitStyleProfile(state._stylePreset) === 'mono'
? '#FFFFFF' ? '#FFFFFF'
: MODE_COLOR[selected?.mode] || MODE_COLOR.unknown, : TRANSIT_MODE_COLORS[selected?.mode] || TRANSIT_MODE_COLORS.unknown,
); );
} }
function releaseSelectionGeometry(entry) { function releaseSelectionGeometry(entry) {
+20
View File
@@ -139,9 +139,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#00ff66', veh_free: '#00ff66',
veh_nodata: '#c9c9c9', veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -169,9 +174,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#45d8ff', veh_free: '#45d8ff',
veh_nodata: '#dcdcdc', veh_nodata: '#dcdcdc',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -199,9 +209,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71', veh_free: '#2ecc71',
veh_nodata: '#d8d8d8', veh_nodata: '#d8d8d8',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -229,9 +244,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71', veh_free: '#2ecc71',
veh_nodata: '#c9c9c9', veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
+7 -6
View File
@@ -1,3 +1,4 @@
import { transitModeFor } from '../data/transitFeeds.js';
import { validTransitIdentifier } from './transitHistory.js'; import { validTransitIdentifier } from './transitHistory.js';
export { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js';
/** Bounded, process-local operator position history. No upstream or scene access. */ /** Bounded, process-local operator position history. No upstream or scene access. */
@@ -328,12 +329,12 @@ export function createTransitHistory({
t > now + 10000 t > now + 10000
) )
continue; continue;
const byVehicle = feed.vehicleMode?.(record.id); const mode = transitModeFor(
const mode = feed,
(byVehicle !== 'unknown' && byVehicle) || record.routeId,
feed.routeMode?.(record.routeId) || record.id,
feed.defaultMode || record.routeType,
'unknown'; );
const context = contextOf(record, mode); const context = contextOf(record, mode);
if (!context) continue; if (!context) continue;
const key = `${feed.id}\u0000${record.id}`; const key = `${feed.id}\u0000${record.id}`;
+12 -1
View File
@@ -302,7 +302,18 @@ const layers = [
}, },
{ {
id: 'transit', id: 'transit',
aliases: ['buses', 'trains', 'public transit', 'trams', 'subway'], aliases: [
'buses',
'trains',
'public transit',
'trams',
'subway',
'trolleybus',
'gondola',
'cable car',
'funicular',
'monorail',
],
context: false, context: false,
query: { query: {
fields: { fields: {