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

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

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

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

No files matched your search

+33
View File
@@ -25,6 +25,7 @@ import {
vesselModelSpec,
} from './fleetModels.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { TRANSIT_MODES } from './transitFeeds.js';
import {
centrelineSegments,
distanceToSurface,
@@ -444,6 +445,38 @@ test('aids to navigation are the buoy of the mark their type names', () => {
}
});
test('every transit mode but unknown draws its own model, and a route type a finer one', () => {
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
assert.ok(types.transit.modes[mode], `${mode} has a model`);
assert.equal(
resolveTransit(types, { mode }).model,
types.transit.modes[mode][0],
);
}
for (const [mode, model] of [
['trolleybus', 'trolleybus'],
['cable_tram', 'cable_tram'],
['aerial_lift', 'gondola'],
['funicular', 'funicular'],
['monorail', 'monorail'],
])
assert.equal(resolveTransit(types, { mode }).model, model, mode);
// A route type outranks the mode: a coach and a high-speed set are still
// the bus and rail modes.
for (const [routeType, mode, model] of [
[200, 'bus', 'coach'],
[101, 'rail', 'high_speed'],
[106, 'rail', 'regional_dmu'],
[710, 'bus', 'double_decker'],
])
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
// A type the table lacks falls back to the mode.
assert.equal(
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
'commuter_emu',
);
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity',
+236 -17
View File
@@ -29,12 +29,21 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
* node:test. No Cesium, no Node built-ins.
*/
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
/**
* Transit modes the layer colors: the GTFS basic route types, by name. A
* vehicle's GTFS `route_type` settles its mode when its record carries one;
* otherwise `routeMode` hints refine a feed's default.
*/
export const TRANSIT_MODES = Object.freeze([
'bus',
'trolleybus',
'tram',
'cable_tram',
'subway',
'monorail',
'rail',
'funicular',
'aerial_lift',
'ferry',
'unknown',
]);
@@ -42,13 +51,81 @@ export const TRANSIT_MODES = Object.freeze([
/** Per-mode icon used in labels and the selection card. */
export const TRANSIT_MODE_ICON = Object.freeze({
bus: '🚌',
trolleybus: '🚎',
tram: '🚊',
cable_tram: '🚋',
subway: '🚇',
monorail: '🚝',
rail: '🚆',
funicular: '🚞',
aerial_lift: '🚡',
ferry: '⛴️',
unknown: '🚏',
});
/** The basic GTFS route types (0–7, 11, 12) by mode. */
const BASIC_ROUTE_TYPE_MODES = Object.freeze({
0: 'tram',
1: 'subway',
2: 'rail',
3: 'bus',
4: 'ferry',
5: 'cable_tram',
6: 'aerial_lift',
7: 'funicular',
11: 'trolleybus',
12: 'monorail',
});
/**
* A GTFS `route_type` as an integer: a basic type (0–12) or an extended one
* (100–1799), as a number or its decimal text. Null for anything else.
* @param {unknown} value
* @returns {number|null}
*/
export function transitRouteType(value) {
const n =
typeof value === 'number'
? value
: typeof value === 'string' && /^\d{1,4}$/.test(value.trim())
? Number(value.trim())
: NaN;
return Number.isInteger(n) && n >= 0 && n < 1800 ? n : null;
}
/**
* The mode of a GTFS `route_type`: the basic types, and the extended ones
* mapped to the basic mode they are a kind of (a coach, 200, is a bus; a
* high-speed train, 101, is rail; 405 is a monorail). Air, taxi and other
* services, and a value GTFS does not define, are `unknown`.
* @param {unknown} routeType
* @returns {string} One of TRANSIT_MODES.
*/
export function transitModeOfRouteType(routeType) {
const n = transitRouteType(routeType);
if (n === null) return 'unknown';
if (n < 100) return BASIC_ROUTE_TYPE_MODES[n] ?? 'unknown';
if (n === 405) return 'monorail';
const family = Math.floor(n / 100);
return (
{
1: 'rail', // railway
2: 'bus', // coach
3: 'rail', // suburban railway
4: 'subway', // urban railway
5: 'subway', // metro
6: 'subway', // underground
7: 'bus',
8: 'trolleybus',
9: 'tram',
10: 'ferry', // water transport
12: 'ferry', // ferry
13: 'aerial_lift',
14: 'funicular',
}[family] ?? 'unknown'
);
}
/**
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
@@ -443,7 +520,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
function liaRouteMode(routeId) {
if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'rail';
if (line === 'FUNI') return 'funicular';
return /^[AB]$/.test(line) ? 'tram' : 'bus';
}
@@ -474,6 +551,7 @@ function astuceRouteLabel(routeId) {
* Prague PID route ids are `L` plus a number whose range says what runs it,
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
* trolleybus routes 232 and 275 among them, GTFS route type 11; the
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
@@ -487,10 +565,11 @@ function pidRoute(routeId) {
if (!match) return { mode: 'unknown', label: routeId };
const n = Number(match[1]);
// i18n-ignore: line code, as signed
if (n === 49) return { mode: 'rail', label: 'LD' };
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
if (n === 49) return { mode: 'funicular', label: 'LD' };
if (n >= 50 && n <= 59) return { mode: 'trolleybus', label: String(n) };
if (n < 100) return { mode: 'tram', label: String(n) };
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
if (n === 232 || n === 275) return { mode: 'trolleybus', label: String(n) };
if (n < 991) return { mode: 'bus', label: String(n) };
// i18n-ignore: metro line letters, as signed
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
@@ -512,6 +591,102 @@ function pidRouteLabel(routeId) {
return pidRoute(routeId).label;
}
/**
* A feed's route hint from its static GTFS routes.txt: the route ids of
* every mode but `bus`, which is everything else.
* @param {Record<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
* 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>,
* license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string,
* routeCorrections?: Readonly<Record<string, string>>,
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
* creditDated?: boolean,
* }>>}
@@ -579,6 +755,9 @@ function transitFeed(fields) {
* stretches the proxy's cache for an upstream that rate-limits below one
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
* to the credit for licences that ask for them (`transitFeedCredit`).
* `routeCorrections` names the routes whose `route_type` the operator's own
* routes.txt gets wrong, `route_id` to the mode they really are; a correction
* outranks the route type, which would otherwise outrank the route hint.
*/
export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({
@@ -1264,6 +1443,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
routeMode: tpbiRouteMode,
}),
transitFeed({
id: 'roma-mobilita',
@@ -1296,6 +1476,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
},
defaultMode: 'bus',
routeMode: gttRouteMode,
routeLabel: gttRouteLabel,
}),
transitFeed({
@@ -1401,6 +1582,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
routeMode: ctpClujRouteMode,
}),
transitFeed({
id: 'ctp-iasi',
@@ -1567,6 +1749,8 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: liaRouteLabel,
routeMode: liaRouteMode,
// LiA's routes.txt types the funicular (`8-FUNI`) as a bus (3).
routeCorrections: Object.freeze({ '8-FUNI': 'funicular' }),
}),
transitFeed({
id: 'filbleu-tours',
@@ -1664,54 +1848,89 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
}
/**
* The feed's own hint for a vehicle: its fleet number when the feed maps
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
* What settles a vehicle's mode, most specific first: the feed's fleet hint
* (`vehicleMode`, which knows a bus standing in on a tram route), else the
* feed's correction for a route its routes.txt mistypes (`routeCorrections`),
* else the GTFS `route_type` of its route when the record carries one, else
* the feed's route hint (`routeMode`). Null when none of them knows.
* @param {object} feed Registry entry.
* @param {string|null} routeId
* @param {string|null} vehicleId
* @param {unknown} routeType GTFS `route_type`, when the record has one.
* @returns {string|null}
*/
function hintedMode(feed, routeId, vehicleId) {
function hintedMode(feed, routeId, vehicleId, routeType) {
const byVehicle =
typeof feed?.vehicleMode === 'function'
? feed.vehicleMode(vehicleId)
: null;
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
const corrections = feed?.routeCorrections;
if (routeId && corrections && Object.hasOwn(corrections, routeId))
return corrections[routeId];
const byType = transitModeOfRouteType(routeType);
if (byType !== 'unknown') return byType;
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
}
/**
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
* its default.
* Mode for a vehicle: its fleet hint, route type or route hint when one
* knows ({@link hintedMode}), else its feed's default.
* @param {object} feed Registry entry.
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
* @param {string|null} [vehicleId] GTFS-RT vehicle id.
* @param {unknown} [routeType] GTFS `route_type` of the route, when known.
* @returns {string} One of TRANSIT_MODES.
*/
export function transitModeFor(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
export function transitModeFor(
feed,
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
const mode =
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
}
/**
* Whether the mode came from the route id rather than the feed's default. A
* default is a guess about a fleet — TransLink's is "bus" while it publishes
* rail in the same feed — so a consumer judging physical plausibility must
* not hold a defaulted vehicle to a bus's limits.
* Whether the mode came from the vehicle, its route type or its route id
* rather than the feed's default. A default is a guess about a fleet —
* TransLink's is "bus" while it publishes rail in the same feed — so a
* consumer judging physical plausibility must not hold a defaulted vehicle
* to a bus's limits.
* @param {object} feed Registry entry.
* @param {string|null} routeId
* @param {string|null} [vehicleId]
* @param {unknown} [routeType]
* @returns {boolean}
*/
export function transitModeResolved(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
export function transitModeResolved(
feed,
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
return (
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
);
}
/**
* The route type a vehicle's model is chosen by: its record's, when that type
* is a kind of the mode it was given. A fleet hint can overrule the route (a
* bus on a tram route), and then the route's type does not describe it.
* @param {string} mode The vehicle's mode ({@link transitModeFor}).
* @param {unknown} routeType
* @returns {number|null}
*/
export function transitModelRouteType(mode, routeType) {
const n = transitRouteType(routeType);
return n !== null && transitModeOfRouteType(n) === mode ? n : null;
}
/**
* The route as a rider would say it. Most feeds publish that already, so this
* is identity for them; a feed that publishes internal keys supplies its own
+119 -3
View File
@@ -11,9 +11,13 @@ import {
haversineKm,
publicTransitCatalog,
transitFeedsInRange,
TRANSIT_MODE_ICON,
transitModeFor,
transitModeOfRouteType,
transitModeResolved,
transitModelRouteType,
transitRouteLabel,
transitRouteType,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
@@ -729,8 +733,9 @@ test('Prague route-id ranges give metro, trains, ferries and trolleybuses their
transitRouteLabel(feed, routeId),
];
assert.deepEqual(read('L22'), ['tram', '22']);
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L58'), ['trolleybus', '58']);
assert.deepEqual(read('L275'), ['trolleybus', '275'], 'route type 11');
assert.deepEqual(read('L49'), ['funicular', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']);
@@ -767,7 +772,7 @@ test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () =>
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
const lia = getTransitFeed('lia-le-havre');
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
assert.equal(transitModeFor(lia, '8-FUNI'), 'funicular');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
@@ -778,3 +783,114 @@ test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
}
});
test('a GTFS route type names its mode, basic and extended alike', () => {
for (const [routeType, mode] of [
[0, 'tram'],
[1, 'subway'],
[2, 'rail'],
[3, 'bus'],
[4, 'ferry'],
[5, 'cable_tram'],
[6, 'aerial_lift'],
[7, 'funicular'],
[11, 'trolleybus'],
[12, 'monorail'],
[101, 'rail'],
[106, 'rail'],
[200, 'bus'],
[300, 'rail'],
[401, 'subway'],
[405, 'monorail'],
[702, 'bus'],
[800, 'trolleybus'],
[900, 'tram'],
[1000, 'ferry'],
[1200, 'ferry'],
[1300, 'aerial_lift'],
[1400, 'funicular'],
['11', 'trolleybus'],
])
assert.equal(transitModeOfRouteType(routeType), mode, String(routeType));
// Air, taxi and other services, and values GTFS does not define.
for (const other of [8, 1100, 1500, 1700, 1800, -1, 2.5, '', 'tram', null])
assert.equal(transitModeOfRouteType(other), 'unknown', String(other));
assert.equal(transitRouteType(' 200 '), 200);
assert.equal(transitRouteType('2e2'), null);
});
test('every mode has an icon, and every mode a route type names is a mode', () => {
for (const mode of TRANSIT_MODES) assert.ok(TRANSIT_MODE_ICON[mode], mode);
for (let routeType = 0; routeType < 1800; routeType++)
assert.ok(TRANSIT_MODES.includes(transitModeOfRouteType(routeType)));
});
test('a route type settles the mode before a route hint or the default', () => {
const mbta = getTransitFeed('mbta');
// The hint calls every numbered MBTA route a bus; a route type is finer.
assert.equal(transitModeFor(mbta, '71'), 'bus');
assert.equal(transitModeFor(mbta, '71', 'y1', 11), 'trolleybus');
assert.equal(transitModeResolved(mbta, '71', 'y1', 11), true);
// A default-only feed is resolved by a route type alone.
const durham = getTransitFeed('durham-region');
assert.equal(transitModeResolved(durham, '915', 'b1'), false);
assert.equal(transitModeFor(durham, '915', 'b1', 200), 'bus');
assert.equal(transitModeResolved(durham, '915', 'b1', 200), true);
// A type that names no mode leaves the hints in charge.
assert.equal(transitModeFor(mbta, 'Red', null, 1700), 'subway');
assert.equal(transitModeFor(mbta, 'Red', null, 'x'), 'subway');
});
test('a fleet hint outranks the route type, and then the type picks no model', () => {
const ttc = getTransitFeed('ttc-toronto');
// A bus standing in on the 501 streetcar route (route type 0).
const mode = transitModeFor(ttc, '501', '8400', 0);
assert.equal(mode, 'bus');
assert.equal(transitModelRouteType(mode, 0), null);
assert.equal(transitModelRouteType('tram', 0), 0);
assert.equal(transitModelRouteType('bus', 200), 200, 'a coach is a bus');
assert.equal(transitModelRouteType('bus', null), null);
});
test('a route correction outranks a mistyped route type, and only for its route', () => {
const lia = getTransitFeed('lia-le-havre');
// LiA's routes.txt types the funicular as a bus (3).
assert.equal(transitModeFor(lia, '8-FUNI', null, 3), 'funicular');
assert.equal(transitModeResolved(lia, '8-FUNI', null, 3), true);
// A correction is a mode, so the bus type picks no model for it.
assert.equal(transitModelRouteType('funicular', 3), null);
// Every other route still takes its route type.
assert.equal(transitModeFor(lia, '8-A', null, 0), 'tram');
assert.equal(transitModeFor(lia, '8-C3', null, 3), 'bus');
assert.equal(transitModeFor(lia, null, null, 3), 'bus');
// Corrections are narrow: a few named routes, each to a known mode.
for (const feed of TRANSIT_FEED_REGISTRY) {
if (!feed.routeCorrections) continue;
assert.ok(Object.isFrozen(feed.routeCorrections), feed.id);
for (const [route, mode] of Object.entries(feed.routeCorrections)) {
assert.ok(TRANSIT_MODES.includes(mode), `${feed.id} ${route}`);
assert.notEqual(mode, 'unknown', `${feed.id} ${route}`);
}
}
// An inherited key is not a correction.
assert.equal(transitModeFor(lia, 'toString', null, 3), 'bus');
});
// Route ids as each realtime feed and its operator's routes.txt carried them
// on 2026-11-14.
test('Bucharest, Cluj and Turin route ids give trams, trolleybuses and a funicular', () => {
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
assert.equal(mode('tpbi-bucuresti', '85'), 'trolleybus', 'line 74');
assert.equal(mode('tpbi-bucuresti', '1013'), 'tram', 'line 4');
assert.equal(mode('tpbi-bucuresti', '968'), 'subway', 'M1');
assert.equal(mode('tpbi-bucuresti', '828'), 'bus');
assert.equal(mode('tpbi-bucuresti', 'PV1_407'), 'bus');
assert.equal(mode('ctp-cluj', '1'), 'trolleybus');
assert.equal(mode('ctp-cluj', '2'), 'tram', 'line 100');
assert.equal(mode('ctp-cluj', '6'), 'bus', 'line 19');
assert.equal(mode('gtt-torino', '79U'), 'funicular', 'Sassi–Superga');
assert.equal(mode('gtt-torino', '16CDU'), 'tram');
assert.equal(mode('gtt-torino', 'METROU'), 'subway');
assert.equal(mode('gtt-torino', '6U'), 'bus');
assert.equal(mode('gtt-torino', null), 'bus', 'no route: the default');
});
+80 -1
View File
@@ -21,7 +21,9 @@ import {
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
* short and blunt, a tram is long and slender with articulation joints, a metro
* car is squared off, a mainline train has a tapered nose, and a ferry is a
* hull with a bow.
* hull with a bow. A trolleybus trails its two poles, a cable car is a short
* single car, a monorail the slimmest bullet, a funicular hangs off its rope,
* and a gondola is a cabin on its cable.
*
* Interior detail is drawn with the same dark hairline as the edge — windscreen
* lines, articulation joints, a deck line — because a solid dark panel would
@@ -103,6 +105,49 @@ const HEADING_BODIES = {
fill="white" ${STROKE}/>
<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
// unclassified vehicle never pretends to be a mode it is not.
unknown: `
@@ -146,6 +191,40 @@ const NEUTRAL_BODIES = {
fill="white" ${STROKE}/>
<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: `
<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"
+8 -2
View File
@@ -62,7 +62,7 @@ test('the raster cache is bounded by kind, size and halo — never by call count
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
});
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 } =
await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) {
@@ -97,7 +97,8 @@ test('all 72 variants retain the specified final halo at both display sizes', as
}
for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
assert.equal(transitIconCacheSize(), 72);
// Every kind at two sizes, three profiles, with and without a course.
assert.equal(transitIconCacheSize(), TRANSIT_ICON_KINDS.length * 12);
});
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -197,6 +198,11 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
subway: 11.5,
rail: 10.5,
ferry: 12.5,
trolleybus: 15,
cable_tram: 11,
monorail: 8.5,
funicular: 11,
aerial_lift: 12,
unknown: 10,
};
for (const selected of [false, true]) {
+11 -1
View File
@@ -60,9 +60,14 @@ const SPRITE_STYLE = Object.freeze({
outlinePx: 1.25,
rgbaByMode: Object.freeze({
bus: Object.freeze([0, 255, 102, 1]),
trolleybus: Object.freeze([170, 255, 0, 1]),
tram: Object.freeze([255, 179, 0, 1]),
cable_tram: Object.freeze([255, 120, 0, 1]),
subway: Object.freeze([255, 59, 48, 1]),
monorail: Object.freeze([255, 64, 170, 1]),
rail: Object.freeze([215, 139, 255, 1]),
funicular: Object.freeze([255, 214, 140, 1]),
aerial_lift: Object.freeze([90, 140, 255, 1]),
ferry: Object.freeze([58, 208, 255, 1]),
unknown: Object.freeze([255, 255, 255, 1]),
}),
@@ -72,9 +77,14 @@ const SPRITE_STYLE = Object.freeze({
/** Modes with a bracket tier of their own. */
const TIER_MODES = Object.freeze([
'bus',
'trolleybus',
'tram',
'cable_tram',
'subway',
'monorail',
'rail',
'funicular',
'aerial_lift',
'ferry',
'unknown',
]);
@@ -128,7 +138,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) {
/**
* Detection-overlay tier key for a transit mode. Every theme's `tiers`
* table carries these six, so the bracket colour is per mode in every
* table carries one per mode, so the bracket colour is per mode in every
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds
* are keyless, and there is no separate keyless experience to keep
* byte-identical.
+1
View File
@@ -19,6 +19,7 @@ import {
decodeVehiclePositions,
} from './gtfsRealtime.js';
import { getTransitFeed } from './transitFeeds.js';
export { transitModeFor, transitRouteType } from './transitFeeds.js';
/** Fresh window: a snapshot younger than this is served without refetching. */
export const TRANSIT_PROXY_TTL_MS = 15_000;
+6 -1
View File
@@ -3962,5 +3962,10 @@
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض"
"Reset view": "إعادة ضبط العرض",
"Trolleybus": "ترولي باص",
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك"
}
+6 -1
View File
@@ -3614,5 +3614,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden",
"Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen"
"Reset view": "Ansicht zurücksetzen",
"Trolleybus": "Oberleitungsbus",
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn"
}
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar",
"Retry": "Reintentar",
"Reset view": "Restablecer la vista"
"Reset view": "Restablecer la vista",
"Trolleybus": "Trolebús",
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
"Gondola": "Góndola"
}
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger",
"Retry": "Réessayer",
"Reset view": "Réinitialiser la vue"
"Reset view": "Réinitialiser la vue",
"Trolleybus": "Trolleybus",
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
"Gondola": "Télécabine"
}
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש",
"Retry": "נסה שוב",
"Reset view": "איפוס תצוגה"
"Reset view": "איפוס תצוגה",
"Trolleybus": "טרוליבוס",
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
"Gondola": "רכבל"
}
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica",
"Retry": "Riprova",
"Reset view": "Ripristina vista"
"Reset view": "Ripristina vista",
"Trolleybus": "Filobus",
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
"Gondola": "Cabinovia"
}
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み",
"Retry": "再試行",
"Reset view": "ビューをリセット"
"Reset view": "ビューをリセット",
"Trolleybus": "トロリーバス",
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ"
}
+6 -1
View File
@@ -3701,5 +3701,10 @@
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar",
"Retry": "Tentar novamente",
"Reset view": "Redefinir vista"
"Reset view": "Redefinir vista",
"Trolleybus": "Trólebus",
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
"Gondola": "Teleférico"
}
+6 -1
View File
@@ -3788,5 +3788,10 @@
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить",
"Retry": "Повторить",
"Reset view": "Сбросить вид"
"Reset view": "Сбросить вид",
"Trolleybus": "Троллейбус",
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога"
}
+6 -1
View File
@@ -3614,5 +3614,10 @@
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om",
"Retry": "Försök igen",
"Reset view": "Återställ vy"
"Reset view": "Återställ vy",
"Trolleybus": "Trådbuss",
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
"Gondola": "Kabinbana"
}
+6 -1
View File
@@ -3788,5 +3788,10 @@
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити",
"Retry": "Повторити",
"Reset view": "Скинути вигляд"
"Reset view": "Скинути вигляд",
"Trolleybus": "Тролейбус",
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога"
}
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载",
"Retry": "重试",
"Reset view": "重置视图"
"Reset view": "重置视图",
"Trolleybus": "无轨电车",
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
"Gondola": "缆车"
}
+6 -1
View File
@@ -3527,5 +3527,10 @@
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載",
"Retry": "重試",
"Reset view": "重置視圖"
"Reset view": "重置視圖",
"Trolleybus": "無軌電車",
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
"Gondola": "纜車"
}
+4
View File
@@ -291,6 +291,7 @@
"FOV {value}°",
"FT",
"Folk",
"Funicular",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
@@ -501,6 +502,7 @@
"MINI",
"MISSION",
"MM",
"Monorail",
"MSG/S",
"Metal",
"Minimal",
@@ -571,6 +573,7 @@
"TAGS · {tags}",
"TEMP",
"TLE",
"Trolleybus",
"TYPE",
"Tallinn",
"Techno",
@@ -950,6 +953,7 @@
"FOV {value}°",
"FT",
"Folk",
"Funicular",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
+12 -1
View File
@@ -16,6 +16,7 @@ import {
import {
transitModeFor,
transitModeResolved,
transitModelRouteType,
} from '../../data/transitFeeds.js';
import {
attachFloorToPlace,
@@ -222,12 +223,19 @@ export function createIngestion({ state, services, parts, source }) {
for (const record of records) {
if (isStaleVehicleFix(record, now, fetchedAt)) continue;
const key = transitVehicleKey(feed.id, record.id);
const mode = transitModeFor(feed, record.routeId, record.id);
const mode = transitModeFor(
feed,
record.routeId,
record.id,
record.routeType,
);
const modeInferred = !transitModeResolved(
feed,
record.routeId,
record.id,
record.routeType,
);
const routeType = transitModelRouteType(mode, record.routeType);
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
const stamped = vehicleReportTimeMs(record, fetchedAt);
const reportAt =
@@ -267,6 +275,7 @@ export function createIngestion({ state, services, parts, source }) {
continue;
}
if (entry.mode !== mode) parts.rendering.paintMode(entry, mode);
entry.routeType = routeType;
entry.modeInferred = modeInferred;
updatePlayback(entry, now, monoNow);
parts.rendering.schedulePlayback(entry);
@@ -320,6 +329,8 @@ export function createIngestion({ state, services, parts, source }) {
feedId: feed.id,
marker: null,
mode,
// The GTFS route type its model is chosen by, when it has one.
routeType,
modeInferred,
heightM: prior === null ? 0 : prior,
heightCell: height.cell,
+5
View File
@@ -11,9 +11,14 @@ import {
} from '../../data/contactPlayback.js';
const MODE_MAX_SPEED_MPS = Object.freeze({
bus: 36, // ~130 km/h — a coach on a motorway
trolleybus: 28, // ~100 km/h — wired to the street
tram: 28, // ~100 km/h — interurban light rail
cable_tram: 12, // ~43 km/h — the cable runs at a walking-pace multiple
subway: 42, // ~150 km/h
monorail: 42, // ~150 km/h
rail: 111, // ~400 km/h — high-speed services exist on some feeds
funicular: 16, // ~58 km/h — the fastest lines
aerial_lift: 15, // ~54 km/h — the fastest gondolas and trams
ferry: 25, // ~90 km/h — fast catamarans
unknown: 56, // ~200 km/h
});
+15
View File
@@ -189,9 +189,14 @@ export const SELECTED_CARD_REFRESH_MS = 250;
/** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */
export const TRANSIT_MODE_COLORS = Object.freeze({
bus: '#5EF08A',
trolleybus: '#B6F04A',
tram: '#FFC24A',
cable_tram: '#FF8F4A',
subway: '#FF4538',
monorail: '#FF6FB8',
rail: '#D9A6FF',
funicular: '#E8C98A',
aerial_lift: '#7FA8FF',
ferry: '#5FD6FF',
unknown: '#D8DDE5',
});
@@ -526,9 +531,14 @@ const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */
const TRANSIT_MODE_WORD = Object.freeze({
bus: msg('Bus'),
trolleybus: msg('Trolleybus'),
tram: msg('Tram'),
cable_tram: msg('Cable car'),
subway: msg('Subway'),
monorail: msg('Monorail'),
rail: msg('Train'),
funicular: msg('Funicular'),
aerial_lift: msg('Gondola'),
ferry: msg('Ferry'),
unknown: msg('Transit vehicle'),
});
@@ -536,9 +546,14 @@ const TRANSIT_MODE_WORD = Object.freeze({
/** Short, fixed-width mode word for the detection card's class field. */
const TRANSIT_MODE_ABBR = Object.freeze({
bus: 'BUS',
trolleybus: 'TROLLEY',
tram: 'TRAM',
cable_tram: 'CABLE',
subway: 'METRO',
monorail: 'MONO',
rail: 'RAIL',
funicular: 'FUNI',
aerial_lift: 'GONDOLA',
ferry: 'FERRY',
unknown: 'TRANSIT',
});
+8 -2
View File
@@ -531,9 +531,15 @@ export function createRendering({ state, services, parts }) {
groundLiftM,
};
}
if (item.mode !== entry.mode) {
if (item.mode !== entry.mode || item.routeType !== entry.routeType) {
item.mode = entry.mode;
item.spec = transitModelSpec(set, { mode: entry.mode });
item.routeType = entry.routeType;
// The route type picks the finer model (a coach, a high-speed set);
// the mode is the fallback for a type the model table lacks.
item.spec = transitModelSpec(set, {
routeType: entry.routeType,
mode: entry.mode,
});
item.tint =
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
}
+2 -9
View File
@@ -8,16 +8,9 @@ import {
} from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js';
import { TRANSIT_MODE_COLORS } from './policy.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
bus: '#5EF08A',
tram: '#FFC24A',
subway: '#FF4538',
rail: '#D9A6FF',
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
// Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640;
@@ -41,7 +34,7 @@ export function createTrails({ state, services, parts, source }) {
renderer?.setStyle(
transitStyleProfile(state._stylePreset) === 'mono'
? '#FFFFFF'
: MODE_COLOR[selected?.mode] || MODE_COLOR.unknown,
: TRANSIT_MODE_COLORS[selected?.mode] || TRANSIT_MODE_COLORS.unknown,
);
}
function releaseSelectionGeometry(entry) {
+20
View File
@@ -139,9 +139,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#00ff66',
veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5',
},
@@ -169,9 +174,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#45d8ff',
veh_nodata: '#dcdcdc',
transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5',
},
@@ -199,9 +209,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71',
veh_nodata: '#d8d8d8',
transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5',
},
@@ -229,9 +244,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71',
veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5',
},
+7 -6
View File
@@ -1,3 +1,4 @@
import { transitModeFor } from '../data/transitFeeds.js';
import { validTransitIdentifier } from './transitHistory.js';
export { validTransitIdentifier } from './transitHistory.js';
/** Bounded, process-local operator position history. No upstream or scene access. */
@@ -328,12 +329,12 @@ export function createTransitHistory({
t > now + 10000
)
continue;
const byVehicle = feed.vehicleMode?.(record.id);
const mode =
(byVehicle !== 'unknown' && byVehicle) ||
feed.routeMode?.(record.routeId) ||
feed.defaultMode ||
'unknown';
const mode = transitModeFor(
feed,
record.routeId,
record.id,
record.routeType,
);
const context = contextOf(record, mode);
if (!context) continue;
const key = `${feed.id}\u0000${record.id}`;
+12 -1
View File
@@ -302,7 +302,18 @@ const layers = [
},
{
id: 'transit',
aliases: ['buses', 'trains', 'public transit', 'trams', 'subway'],
aliases: [
'buses',
'trains',
'public transit',
'trams',
'subway',
'trolleybus',
'gondola',
'cable car',
'funicular',
'monorail',
],
context: false,
query: {
fields: {