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