mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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:
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
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": "تلفريك"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "רכבל"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "ゴンドラ"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "Канатная дорога"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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": "Канатна дорога"
|
||||||
}
|
}
|
||||||
@@ -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": "缆车"
|
||||||
}
|
}
|
||||||
@@ -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": "纜車"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in new issue
Block a user