From 39c89d563b2beef8e707d1bedc94ca816fbc3dce Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 12:03:12 -0800 Subject: [PATCH] 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. --- changelog.d/0000-transit-route-type-models.md | 10 + docs/CURRENT-STATE.md | 30 ++- server/providers/transitHistory.test.mjs | 22 ++ src/data/fleetModels.test.mjs | 33 +++ src/data/transitFeeds.js | 253 ++++++++++++++++-- src/data/transitFeeds.test.mjs | 122 ++++++++- src/data/transitIcons.js | 81 +++++- src/data/transitIcons.test.mjs | 10 +- src/data/transitPresetStyle.js | 12 +- src/data/transitProxy.js | 1 + src/i18n/locales/ar.json | 7 +- src/i18n/locales/de.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/he.json | 7 +- src/i18n/locales/it.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/pt.json | 7 +- src/i18n/locales/ru.json | 7 +- src/i18n/locales/sv.json | 7 +- src/i18n/locales/uk.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/i18n/locales/zh-Hant.json | 7 +- src/i18n/same-as-english.json | 4 + src/layers/transit/ingestion.js | 13 +- src/layers/transit/movement.js | 5 + src/layers/transit/policy.js | 15 ++ src/layers/transit/rendering.js | 10 +- src/layers/transit/trails.js | 11 +- src/overlays/worldOverlayTokens.js | 20 ++ src/sources/transitHistoryStore.js | 13 +- src/voice/layerManifest.js | 13 +- 32 files changed, 708 insertions(+), 61 deletions(-) create mode 100644 changelog.d/0000-transit-route-type-models.md diff --git a/changelog.d/0000-transit-route-type-models.md b/changelog.d/0000-transit-route-type-models.md new file mode 100644 index 000000000..463820354 --- /dev/null +++ b/changelog.d/0000-transit-route-type-models.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fc00c5934..4b578d178 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5868,7 +5868,26 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes -identify buses, trams, subways, trains, ferries and unclassified vehicles. +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 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 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 -north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three -profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram -`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown +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 132 variants. Normal mode colours are bus `#5EF08A`, +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 outer path, filled opaque white without interior panel lines, with an opaque `#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 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. -- 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. - 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. diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 551fee7a7..aeed8308a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -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', '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'); +}); diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs index d48e744c1..315c53746 100644 --- a/src/data/fleetModels.test.mjs +++ b/src/data/fleetModels.test.mjs @@ -25,6 +25,7 @@ import { vesselModelSpec, } from './fleetModels.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; +import { TRANSIT_MODES } from './transitFeeds.js'; import { centrelineSegments, 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', () => { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { model: 'intercity', diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 42f399d77..3e9f9cebc 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -29,12 +29,21 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; * 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([ 'bus', + 'trolleybus', 'tram', + 'cable_tram', 'subway', + 'monorail', 'rail', + 'funicular', + 'aerial_lift', 'ferry', 'unknown', ]); @@ -42,13 +51,81 @@ export const TRANSIT_MODES = Object.freeze([ /** Per-mode icon used in labels and the selection card. */ export const TRANSIT_MODE_ICON = Object.freeze({ bus: '🚌', + trolleybus: '🚎', tram: '🚊', + cable_tram: '🚋', subway: '🚇', + monorail: '🚝', rail: '🚆', + funicular: '🚞', + aerial_lift: '🚡', ferry: '⛴️', 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 * colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are @@ -443,7 +520,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/); function liaRouteMode(routeId) { if (!routeId) return 'unknown'; const line = liaRouteLabel(routeId); - if (line === 'FUNI') return 'rail'; + if (line === 'FUNI') return 'funicular'; 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, * 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 + * 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, * 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 @@ -487,10 +565,11 @@ function pidRoute(routeId) { if (!match) return { mode: 'unknown', label: routeId }; const n = Number(match[1]); // i18n-ignore: line code, as signed - if (n === 49) return { mode: 'rail', label: 'LD' }; - if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) }; + if (n === 49) return { mode: 'funicular', label: 'LD' }; + if (n >= 50 && n <= 59) return { mode: 'trolleybus', 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 === 232 || n === 275) return { mode: 'trolleybus', label: String(n) }; if (n < 991) return { mode: 'bus', label: String(n) }; // i18n-ignore: metro line letters, as signed if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] }; @@ -512,6 +591,102 @@ function pidRouteLabel(routeId) { 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} 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 * routes.txt (checked 2026-11-09): the trams are numbered ids, not letters. @@ -571,6 +746,7 @@ function transitFeed(fields) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * routeCorrections?: Readonly>, * speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string, * creditDated?: boolean, * }>>} @@ -579,6 +755,9 @@ function transitFeed(fields) { * 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 * 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([ 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.', }, defaultMode: 'bus', + routeMode: tpbiRouteMode, }), transitFeed({ 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.', }, defaultMode: 'bus', + routeMode: gttRouteMode, routeLabel: gttRouteLabel, }), transitFeed({ @@ -1401,6 +1582,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', }, defaultMode: 'bus', + routeMode: ctpClujRouteMode, }), transitFeed({ id: 'ctp-iasi', @@ -1567,6 +1749,8 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: liaRouteLabel, routeMode: liaRouteMode, + // LiA's routes.txt types the funicular (`8-FUNI`) as a bus (3). + routeCorrections: Object.freeze({ '8-FUNI': 'funicular' }), }), transitFeed({ 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 - * those (`vehicleMode`), else its route (`routeMode`). Null when neither knows. + * What settles a vehicle's mode, most specific first: the feed's fleet hint + * (`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 {string|null} routeId * @param {string|null} vehicleId + * @param {unknown} routeType GTFS `route_type`, when the record has one. * @returns {string|null} */ -function hintedMode(feed, routeId, vehicleId) { +function hintedMode(feed, routeId, vehicleId, routeType) { const byVehicle = typeof feed?.vehicleMode === 'function' ? feed.vehicleMode(vehicleId) : null; 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; } /** - * Mode for a vehicle: the feed's fleet or route hint when it has one, else - * its default. + * Mode for a vehicle: its fleet hint, route type or route hint when one + * knows ({@link hintedMode}), else its feed's default. * @param {object} feed Registry entry. * @param {string|null} routeId GTFS route_id from the vehicle's trip. * @param {string|null} [vehicleId] GTFS-RT vehicle id. + * @param {unknown} [routeType] GTFS `route_type` of the route, when known. * @returns {string} One of TRANSIT_MODES. */ -export function transitModeFor(feed, routeId, vehicleId = null) { - const hinted = hintedMode(feed, routeId, vehicleId); +export function transitModeFor( + feed, + routeId, + vehicleId = null, + routeType = null, +) { + const hinted = hintedMode(feed, routeId, vehicleId, routeType); const mode = hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown'; return TRANSIT_MODES.includes(mode) ? mode : 'unknown'; } /** - * Whether the mode came from the route id rather than the feed's default. A - * default is a guess about a fleet — TransLink's is "bus" while it publishes - * rail in the same feed — so a consumer judging physical plausibility must - * not hold a defaulted vehicle to a bus's limits. + * Whether the mode came from the vehicle, its route type or its route id + * rather than the feed's default. A default is a guess about a fleet — + * TransLink's is "bus" while it publishes rail in the same feed — so a + * consumer judging physical plausibility must not hold a defaulted vehicle + * to a bus's limits. * @param {object} feed Registry entry. * @param {string|null} routeId * @param {string|null} [vehicleId] + * @param {unknown} [routeType] * @returns {boolean} */ -export function transitModeResolved(feed, routeId, vehicleId = null) { - const hinted = hintedMode(feed, routeId, vehicleId); +export function transitModeResolved( + feed, + routeId, + vehicleId = null, + routeType = null, +) { + const hinted = hintedMode(feed, routeId, vehicleId, routeType); return ( 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 * is identity for them; a feed that publishes internal keys supplies its own diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 75a902728..37ad82a56 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -11,9 +11,13 @@ import { haversineKm, publicTransitCatalog, transitFeedsInRange, + TRANSIT_MODE_ICON, transitModeFor, + transitModeOfRouteType, transitModeResolved, + transitModelRouteType, transitRouteLabel, + transitRouteType, } from './transitFeeds.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), ]; assert.deepEqual(read('L22'), ['tram', '22']); - assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus'); - assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular'); + assert.deepEqual(read('L58'), ['trolleybus', '58']); + 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('L803'), ['bus', 'X3']); 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(transitRouteLabel(feed, 'TAE:F'), 'F'); 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-09'), '9'); 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); } }); + +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'); +}); diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js index c6ad963e1..deb82656c 100644 --- a/src/data/transitIcons.js +++ b/src/data/transitIcons.js @@ -21,7 +21,9 @@ import { * 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 * 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 * lines, articulation joints, a deck line — because a solid dark panel would @@ -103,6 +105,49 @@ const HEADING_BODIES = { fill="white" ${STROKE}/> `, + // ── 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: ` + + `, + + // ── Cable car (street cable railway): a short single car, under half a + // tram, with open platforms at both ends. + cable_tram: ` + + + `, + + // ── Monorail: the slimmest train in the set, a rounded bullet nose over + // its beam. + monorail: ` + + `, + + // ── Funicular: a squat car hauled by the rope at its upper end. + funicular: ` + + + `, + + // ── Gondola (aerial lift): a cabin hung on the cable that runs through + // it; the cabin's nose is its front. + aerial_lift: ` + `, + // ── Unknown: a plain marker with a nose and nothing else, so an // unclassified vehicle never pretends to be a mode it is not. unknown: ` @@ -146,6 +191,40 @@ const NEUTRAL_BODIES = { fill="white" ${STROKE}/> `, + trolleybus: ` + `, + + cable_tram: ` + + + `, + + monorail: ` + + `, + + funicular: ` + + + `, + + aerial_lift: ` + `, + unknown: ` { +test('every variant retains the specified final halo at both display sizes', async () => { const { presetSpriteScale, presetSpriteOutlinePx } = await import('./transitPresetStyle.js'); 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++) 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 () => { @@ -197,6 +198,11 @@ test('sensor raster halo stays dark across its screen-space band on a white roof subway: 11.5, rail: 10.5, ferry: 12.5, + trolleybus: 15, + cable_tram: 11, + monorail: 8.5, + funicular: 11, + aerial_lift: 12, unknown: 10, }; for (const selected of [false, true]) { diff --git a/src/data/transitPresetStyle.js b/src/data/transitPresetStyle.js index 5996d4d97..cfb39053a 100644 --- a/src/data/transitPresetStyle.js +++ b/src/data/transitPresetStyle.js @@ -60,9 +60,14 @@ const SPRITE_STYLE = Object.freeze({ outlinePx: 1.25, rgbaByMode: Object.freeze({ bus: Object.freeze([0, 255, 102, 1]), + trolleybus: Object.freeze([170, 255, 0, 1]), tram: Object.freeze([255, 179, 0, 1]), + cable_tram: Object.freeze([255, 120, 0, 1]), subway: Object.freeze([255, 59, 48, 1]), + monorail: Object.freeze([255, 64, 170, 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]), unknown: Object.freeze([255, 255, 255, 1]), }), @@ -72,9 +77,14 @@ const SPRITE_STYLE = Object.freeze({ /** Modes with a bracket tier of their own. */ const TIER_MODES = Object.freeze([ 'bus', + 'trolleybus', 'tram', + 'cable_tram', 'subway', + 'monorail', 'rail', + 'funicular', + 'aerial_lift', 'ferry', 'unknown', ]); @@ -128,7 +138,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) { /** * 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 * are keyless, and there is no separate keyless experience to keep * byte-identical. diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 6b328e5a5..e601a592d 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -19,6 +19,7 @@ import { decodeVehiclePositions, } from './gtfsRealtime.js'; import { getTransitFeed } from './transitFeeds.js'; +export { transitModeFor, transitRouteType } from './transitFeeds.js'; /** Fresh window: a snapshot younger than this is served without refetching. */ export const TRANSIT_PROXY_TTL_MS = 15_000; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 44024e10f..ebdc0ef99 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3962,5 +3962,10 @@ "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", "Reload": "إعادة التحميل", "Retry": "إعادة المحاولة", - "Reset view": "إعادة ضبط العرض" + "Reset view": "إعادة ضبط العرض", + "Trolleybus": "ترولي باص", + "Cable car": "ترام كابلي", + "Monorail": "قطار أحادي السكة", + "Funicular": "قطار جبلي مائل", + "Gondola": "تلفريك" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index cdb669452..c1bf7dd2a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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.", "Reload": "Neu laden", "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b0d10ae06..1ba3bc810 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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.", "Reload": "Recargar", "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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ffe522ad9..cb82ee62b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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.", "Reload": "Recharger", "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" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index cfe612894..80943335a 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3701,5 +3701,10 @@ "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", "Reload": "טען מחדש", "Retry": "נסה שוב", - "Reset view": "איפוס תצוגה" + "Reset view": "איפוס תצוגה", + "Trolleybus": "טרוליבוס", + "Cable car": "חשמלית כבלים", + "Monorail": "מונורייל", + "Funicular": "פוניקולר", + "Gondola": "רכבל" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index e8d939278..d68693f31 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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.", "Reload": "Ricarica", "Retry": "Riprova", - "Reset view": "Ripristina vista" + "Reset view": "Ripristina vista", + "Trolleybus": "Filobus", + "Cable car": "Tram a fune", + "Monorail": "Monorotaia", + "Funicular": "Funicolare", + "Gondola": "Cabinovia" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8039831d8..6c36c71ce 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3527,5 +3527,10 @@ "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", "Reload": "再読み込み", "Retry": "再試行", - "Reset view": "ビューをリセット" + "Reset view": "ビューをリセット", + "Trolleybus": "トロリーバス", + "Cable car": "ケーブルトラム", + "Monorail": "モノレール", + "Funicular": "ケーブルカー", + "Gondola": "ゴンドラ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 18b6d1dd2..8864f6d22 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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.", "Reload": "Recarregar", "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b58d78d26..37c6db315 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3788,5 +3788,10 @@ "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", "Reload": "Перезагрузить", "Retry": "Повторить", - "Reset view": "Сбросить вид" + "Reset view": "Сбросить вид", + "Trolleybus": "Троллейбус", + "Cable car": "Канатный трамвай", + "Monorail": "Монорельс", + "Funicular": "Фуникулёр", + "Gondola": "Канатная дорога" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index bd1f25a7e..b1c9887e3 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -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.", "Reload": "Ladda om", "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" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 9506d0a31..f59df5624 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3788,5 +3788,10 @@ "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", "Reload": "Перезавантажити", "Retry": "Повторити", - "Reset view": "Скинути вигляд" + "Reset view": "Скинути вигляд", + "Trolleybus": "Тролейбус", + "Cable car": "Канатний трамвай", + "Monorail": "Монорейка", + "Funicular": "Фунікулер", + "Gondola": "Канатна дорога" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f34c36028..2c4b5e9df 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3527,5 +3527,10 @@ "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", "Reload": "重新加载", "Retry": "重试", - "Reset view": "重置视图" + "Reset view": "重置视图", + "Trolleybus": "无轨电车", + "Cable car": "缆索电车", + "Monorail": "单轨列车", + "Funicular": "缆索铁路", + "Gondola": "缆车" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 0e01c7773..c9f352964 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3527,5 +3527,10 @@ "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", "Reload": "重新加載", "Retry": "重試", - "Reset view": "重置視圖" + "Reset view": "重置視圖", + "Trolleybus": "無軌電車", + "Cable car": "纜索電車", + "Monorail": "單軌列車", + "Funicular": "纜索鐵路", + "Gondola": "纜車" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index b8a7eeb61..e95d5afc4 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -291,6 +291,7 @@ "FOV {value}°", "FT", "Folk", + "Funicular", "Funk", "GEO", "GOOGLE STREET VIEW", @@ -501,6 +502,7 @@ "MINI", "MISSION", "MM", + "Monorail", "MSG/S", "Metal", "Minimal", @@ -571,6 +573,7 @@ "TAGS · {tags}", "TEMP", "TLE", + "Trolleybus", "TYPE", "Tallinn", "Techno", @@ -950,6 +953,7 @@ "FOV {value}°", "FT", "Folk", + "Funicular", "Funk", "GEO", "GOOGLE STREET VIEW", diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 3f5669c65..33723fef9 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -16,6 +16,7 @@ import { import { transitModeFor, transitModeResolved, + transitModelRouteType, } from '../../data/transitFeeds.js'; import { attachFloorToPlace, @@ -222,12 +223,19 @@ export function createIngestion({ state, services, parts, source }) { for (const record of records) { if (isStaleVehicleFix(record, now, fetchedAt)) continue; 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( feed, record.routeId, 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. const stamped = vehicleReportTimeMs(record, fetchedAt); const reportAt = @@ -267,6 +275,7 @@ export function createIngestion({ state, services, parts, source }) { continue; } if (entry.mode !== mode) parts.rendering.paintMode(entry, mode); + entry.routeType = routeType; entry.modeInferred = modeInferred; updatePlayback(entry, now, monoNow); parts.rendering.schedulePlayback(entry); @@ -320,6 +329,8 @@ export function createIngestion({ state, services, parts, source }) { feedId: feed.id, marker: null, mode, + // The GTFS route type its model is chosen by, when it has one. + routeType, modeInferred, heightM: prior === null ? 0 : prior, heightCell: height.cell, diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 2c9a61dcd..8202d7a56 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -11,9 +11,14 @@ import { } from '../../data/contactPlayback.js'; const MODE_MAX_SPEED_MPS = Object.freeze({ 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 + cable_tram: 12, // ~43 km/h — the cable runs at a walking-pace multiple subway: 42, // ~150 km/h + monorail: 42, // ~150 km/h 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 unknown: 56, // ~200 km/h }); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index eabe5df4a..a74d1bc16 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -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. */ export const TRANSIT_MODE_COLORS = Object.freeze({ bus: '#5EF08A', + trolleybus: '#B6F04A', tram: '#FFC24A', + cable_tram: '#FF8F4A', subway: '#FF4538', + monorail: '#FF6FB8', rail: '#D9A6FF', + funicular: '#E8C98A', + aerial_lift: '#7FA8FF', ferry: '#5FD6FF', 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. */ const TRANSIT_MODE_WORD = Object.freeze({ bus: msg('Bus'), + trolleybus: msg('Trolleybus'), tram: msg('Tram'), + cable_tram: msg('Cable car'), subway: msg('Subway'), + monorail: msg('Monorail'), rail: msg('Train'), + funicular: msg('Funicular'), + aerial_lift: msg('Gondola'), ferry: msg('Ferry'), 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. */ const TRANSIT_MODE_ABBR = Object.freeze({ bus: 'BUS', + trolleybus: 'TROLLEY', tram: 'TRAM', + cable_tram: 'CABLE', subway: 'METRO', + monorail: 'MONO', rail: 'RAIL', + funicular: 'FUNI', + aerial_lift: 'GONDOLA', ferry: 'FERRY', unknown: 'TRANSIT', }); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 058aa3a5d..8a1b19635 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -531,9 +531,15 @@ export function createRendering({ state, services, parts }) { groundLiftM, }; } - if (item.mode !== entry.mode) { + if (item.mode !== entry.mode || item.routeType !== entry.routeType) { 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 = MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; } diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 4080766ee..315b2ece8 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -8,16 +8,9 @@ import { } from '../../data/contactPlayback.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { fixDistanceM } from './movement.js'; +import { TRANSIT_MODE_COLORS } from './policy.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. // The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB. export const TRAIL_VERTEX_LIMIT = 640; @@ -41,7 +34,7 @@ export function createTrails({ state, services, parts, source }) { renderer?.setStyle( transitStyleProfile(state._stylePreset) === 'mono' ? '#FFFFFF' - : MODE_COLOR[selected?.mode] || MODE_COLOR.unknown, + : TRANSIT_MODE_COLORS[selected?.mode] || TRANSIT_MODE_COLORS.unknown, ); } function releaseSelectionGeometry(entry) { diff --git a/src/overlays/worldOverlayTokens.js b/src/overlays/worldOverlayTokens.js index c03383148..895ba9ac7 100644 --- a/src/overlays/worldOverlayTokens.js +++ b/src/overlays/worldOverlayTokens.js @@ -139,9 +139,14 @@ export const DETECTION_THEME_MAP = Object.freeze({ veh_free: '#00ff66', veh_nodata: '#c9c9c9', transit_bus: '#5EF08A', + transit_trolleybus: '#B6F04A', transit_tram: '#FFC24A', + transit_cable_tram: '#FF8F4A', transit_subway: '#FF4538', + transit_monorail: '#FF6FB8', transit_rail: '#D9A6FF', + transit_funicular: '#E8C98A', + transit_aerial_lift: '#7FA8FF', transit_ferry: '#5FD6FF', transit_unknown: '#D8DDE5', }, @@ -169,9 +174,14 @@ export const DETECTION_THEME_MAP = Object.freeze({ veh_free: '#45d8ff', veh_nodata: '#dcdcdc', transit_bus: '#5EF08A', + transit_trolleybus: '#B6F04A', transit_tram: '#FFC24A', + transit_cable_tram: '#FF8F4A', transit_subway: '#FF4538', + transit_monorail: '#FF6FB8', transit_rail: '#D9A6FF', + transit_funicular: '#E8C98A', + transit_aerial_lift: '#7FA8FF', transit_ferry: '#5FD6FF', transit_unknown: '#D8DDE5', }, @@ -199,9 +209,14 @@ export const DETECTION_THEME_MAP = Object.freeze({ veh_free: '#2ecc71', veh_nodata: '#d8d8d8', transit_bus: '#5EF08A', + transit_trolleybus: '#B6F04A', transit_tram: '#FFC24A', + transit_cable_tram: '#FF8F4A', transit_subway: '#FF4538', + transit_monorail: '#FF6FB8', transit_rail: '#D9A6FF', + transit_funicular: '#E8C98A', + transit_aerial_lift: '#7FA8FF', transit_ferry: '#5FD6FF', transit_unknown: '#D8DDE5', }, @@ -229,9 +244,14 @@ export const DETECTION_THEME_MAP = Object.freeze({ veh_free: '#2ecc71', veh_nodata: '#c9c9c9', transit_bus: '#5EF08A', + transit_trolleybus: '#B6F04A', transit_tram: '#FFC24A', + transit_cable_tram: '#FF8F4A', transit_subway: '#FF4538', + transit_monorail: '#FF6FB8', transit_rail: '#D9A6FF', + transit_funicular: '#E8C98A', + transit_aerial_lift: '#7FA8FF', transit_ferry: '#5FD6FF', transit_unknown: '#D8DDE5', }, diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index cf82835c1..b53082e27 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -1,3 +1,4 @@ +import { transitModeFor } from '../data/transitFeeds.js'; import { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js'; /** Bounded, process-local operator position history. No upstream or scene access. */ @@ -328,12 +329,12 @@ export function createTransitHistory({ t > now + 10000 ) continue; - const byVehicle = feed.vehicleMode?.(record.id); - const mode = - (byVehicle !== 'unknown' && byVehicle) || - feed.routeMode?.(record.routeId) || - feed.defaultMode || - 'unknown'; + const mode = transitModeFor( + feed, + record.routeId, + record.id, + record.routeType, + ); const context = contextOf(record, mode); if (!context) continue; const key = `${feed.id}\u0000${record.id}`; diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 189ee8e44..60cda6f25 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -302,7 +302,18 @@ const layers = [ }, { id: 'transit', - aliases: ['buses', 'trains', 'public transit', 'trams', 'subway'], + aliases: [ + 'buses', + 'trains', + 'public transit', + 'trams', + 'subway', + 'trolleybus', + 'gondola', + 'cable car', + 'funicular', + 'monorail', + ], context: false, query: { fields: {