mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/gbfs-rate-limit
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
e201cc3667
17 files changed
+1898
-29
No files matched your search
+498
-7
@@ -115,6 +115,265 @@ function metroTransitRouteMode(routeId) {
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* TTC BusTime is the SURFACE feed: buses and streetcars, never the subway.
|
||||
* Streetcar routes are the 500-series, plus the four Blue Night routes that
|
||||
* run on streetcar trackage (301 Queen, 304 King, 306 Carlton, 310 Spadina);
|
||||
* the rest of the 300-series is night buses.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^5\d\d$/.test(routeId)) return 'tram';
|
||||
if (/^(301|304|306|310)$/.test(routeId)) return 'tram';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* The fleet number settles TTC's mode before the route does: buses replace
|
||||
* streetcars on 5xx routes during track work, and streetcars running out of
|
||||
* service report no route at all. Flexity streetcars are numbered 4400–4699;
|
||||
* every other TTC number is a bus.
|
||||
* @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number).
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcVehicleMode(vehicleId) {
|
||||
if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown';
|
||||
const fleet = Number(vehicleId);
|
||||
return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guelph Transit's realtime feed publishes the internal database key of a
|
||||
* route (`2991`), not the number on the front of the bus (`1`). Without this
|
||||
* table the detection label reads "Route 2991", which is not what anyone at
|
||||
* the stop calls it. Route ids and short names are from the City of Guelph's
|
||||
* own published GTFS `routes.txt` (see DATA_SOURCES.md); an id missing here
|
||||
* falls through unchanged, so a route added later degrades to its raw id
|
||||
* rather than disappearing.
|
||||
*/
|
||||
const GUELPH_ROUTE_NAMES = Object.freeze({
|
||||
2991: '1',
|
||||
2992: '10',
|
||||
2993: '11',
|
||||
2994: '12',
|
||||
2995: '13',
|
||||
2996: '14',
|
||||
2997: '15',
|
||||
2998: '16',
|
||||
2999: '17',
|
||||
3000: '18',
|
||||
3001: '19',
|
||||
3002: '2',
|
||||
3003: '20',
|
||||
3004: '3',
|
||||
3005: '4',
|
||||
3006: '5',
|
||||
3007: '50 U',
|
||||
3008: '52 U',
|
||||
3009: '56 U',
|
||||
3010: '58 U',
|
||||
3011: '6',
|
||||
3012: '7',
|
||||
3013: '8',
|
||||
3014: '9',
|
||||
3015: '98',
|
||||
3016: '99',
|
||||
3017: '99Lite',
|
||||
3018: 'BREW',
|
||||
3019: 'EdiCol',
|
||||
3023: 'IroHarv',
|
||||
3030: 'Sun LN',
|
||||
3032: 'WHanSco',
|
||||
3039: 'ZehCha',
|
||||
});
|
||||
|
||||
/**
|
||||
* Guelph route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function guelphRouteLabel(routeId) {
|
||||
return GUELPH_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamilton publishes an internal key for every route: not one of HSR's 80 route
|
||||
* ids equals the number on the bus, so an untranslated feed reads as
|
||||
* "Route 5783" city-wide. Two generations of id are live at once — `5688` and
|
||||
* `5780` are both the 2 BARTON — and both are listed, because both arrive.
|
||||
*
|
||||
* Numbers are unpadded to match the rest of the registry (`routes.txt` says
|
||||
* `02`; a rider says 2). The seasonal and event shuttles publish a name rather
|
||||
* than a number — `TC230`, `PEACH`, `ROCKTON` — and those are already what a
|
||||
* rider would say, so they survive as published. Ids and names are from the
|
||||
* City's own published GTFS `routes.txt`, under the same terms as the feed.
|
||||
*/
|
||||
const HAMILTON_ROUTE_NAMES = Object.freeze({
|
||||
5687: '1',
|
||||
5688: '2',
|
||||
5689: '3',
|
||||
5690: '4',
|
||||
5691: '5',
|
||||
5692: '6',
|
||||
5693: '7',
|
||||
5694: '8',
|
||||
5695: '9',
|
||||
5696: '10',
|
||||
5697: '11',
|
||||
5698: '12',
|
||||
5699: '16',
|
||||
5700: '18',
|
||||
5701: '20',
|
||||
5702: '21',
|
||||
5703: '22',
|
||||
5704: '23',
|
||||
5705: '24',
|
||||
5706: '25',
|
||||
5707: '26',
|
||||
5708: '27',
|
||||
5709: '33',
|
||||
5710: '34',
|
||||
5711: '35',
|
||||
5712: '41',
|
||||
5713: '42',
|
||||
5714: '43',
|
||||
5715: '44',
|
||||
5716: '51',
|
||||
5717: '52',
|
||||
5718: '55',
|
||||
5719: '56',
|
||||
5720: '99',
|
||||
5731: 'X-IND',
|
||||
5780: '2',
|
||||
5781: '3',
|
||||
5782: '4',
|
||||
5783: '5',
|
||||
5784: '6',
|
||||
5785: '7',
|
||||
5786: '8',
|
||||
5787: '9',
|
||||
5789: '11',
|
||||
5790: '12',
|
||||
5791: '16',
|
||||
5792: '18',
|
||||
5793: '20',
|
||||
5794: '21',
|
||||
5795: '22',
|
||||
5796: '23',
|
||||
5797: '24',
|
||||
5798: '25',
|
||||
5799: '26',
|
||||
5800: '27',
|
||||
5801: '33',
|
||||
5802: '34',
|
||||
5803: '35',
|
||||
5805: '42',
|
||||
5806: '43',
|
||||
5807: '44',
|
||||
5808: '51',
|
||||
5809: '52',
|
||||
5811: '56',
|
||||
5812: '99',
|
||||
5820: 'TC230',
|
||||
5823: 'X IND',
|
||||
5824: 'TC-700',
|
||||
5825: 'TC-300',
|
||||
5826: 'CAN',
|
||||
5827: 'PEACH',
|
||||
5828: '1',
|
||||
5829: '10',
|
||||
5830: '41',
|
||||
5831: '55',
|
||||
5832: 'TC300',
|
||||
5833: 'TC700',
|
||||
5834: 'TC730',
|
||||
5835: 'ANCFAIR',
|
||||
5836: 'ROCKTON',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hamilton route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hamiltonRouteLabel(routeId) {
|
||||
return HAMILTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* YRT publishes Viva bus rapid transit by route id (`601`), while every rider,
|
||||
* station sign and map calls it "Viva Blue". Plain local routes are already the
|
||||
* number on the bus and are left alone, unpadded: `105` reads better than the
|
||||
* `005` of `routes.txt`.
|
||||
*
|
||||
* Branches are the exception. YRT suffixes a two-digit branch onto the base
|
||||
* route — the scheme Viva's own `60102` follows — so a branch id reaches a
|
||||
* rider as "8301" unless it is translated. Names and colours are from York
|
||||
* Region's own published GTFS `routes.txt`; Viva Green (604) and Pink (606)
|
||||
* are not in current service and so are absent here too.
|
||||
*
|
||||
* Only branches observed live AND published in `routes.txt` are listed. `9101`
|
||||
* runs but is unpublished, so it is deliberately missing: the pattern suggests
|
||||
* "91A", and suggesting is not knowing.
|
||||
*/
|
||||
const YRT_ROUTE_NAMES = Object.freeze({
|
||||
601: 'Viva Blue',
|
||||
60102: 'Viva Blue B',
|
||||
603: 'Viva Purple',
|
||||
60301: 'Viva Purple A',
|
||||
605: 'Viva Orange',
|
||||
607: 'Viva Yellow',
|
||||
8301: '83A',
|
||||
9002: '90B',
|
||||
10702: '107B',
|
||||
});
|
||||
|
||||
/**
|
||||
* YRT route id to the name a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function yrtRouteLabel(routeId) {
|
||||
return YRT_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Burlington publishes the internal key of a route, and the keys are a trap:
|
||||
* `351` is route 1 while `3510` is route 10, so trimming a `35` prefix gets
|
||||
* the wrong answer on half the network. The join is the only correct route.
|
||||
* Ids and numbers are from the City's own published GTFS `routes.txt`, which
|
||||
* sits in the same directory as the realtime feed under the same terms.
|
||||
*/
|
||||
const BURLINGTON_ROUTE_NAMES = Object.freeze({
|
||||
351: '1',
|
||||
352: '2',
|
||||
353: '3',
|
||||
354: '4',
|
||||
356: '6',
|
||||
3510: '10',
|
||||
3511: '11',
|
||||
3512: '12',
|
||||
3525: '25',
|
||||
3548: '48',
|
||||
3550: '50',
|
||||
3551: '51',
|
||||
3552: '52',
|
||||
3580: '80',
|
||||
3581: '81',
|
||||
3587: '87',
|
||||
});
|
||||
|
||||
/**
|
||||
* Burlington route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function burlingtonRouteLabel(routeId) {
|
||||
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -270,6 +529,204 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ttc-toronto',
|
||||
name: 'TTC',
|
||||
operator: 'Toronto Transit Commission',
|
||||
region: 'Toronto, ON',
|
||||
center: Object.freeze({ lat: 43.6532, lon: -79.3832 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://bustime.ttc.ca/gtfsrt/vehicles',
|
||||
license: 'Open Government Licence \u2013 Toronto',
|
||||
licenseUrl: 'https://open.toronto.ca/open-data-licence/',
|
||||
attribution:
|
||||
'Toronto Transit Commission \u2014 City of Toronto Open Data (Open Government Licence \u2013 Toronto)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider.',
|
||||
note: 'The City publishes this endpoint from the TTC BusTime NVAS catalogue entry, whose own licence field reads \"License not specified\"; the quote above is the portal-wide Open Government Licence \u2013 Toronto the City applies to its open data, so the credit carries the licence name the way the Metro Transit entry carries its public-domain note. BusTime is the surface system: buses and streetcars report real positions, the subway reports none, so Lines 1/2/4 are simply absent rather than inferred. The endpoint sends no ETag or Last-Modified, so conditional requests fall through to the proxy snapshot TTL.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
// Toronto rides streetcars. The vehicles are the shared `tram` mode and
|
||||
// share its silhouette and speed limits; only the word a reader sees
|
||||
// changes, because "tram" is not what anyone at Queen and Spadina says.
|
||||
modeWords: Object.freeze({
|
||||
tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }),
|
||||
}),
|
||||
routeMode: ttcRouteMode,
|
||||
vehicleMode: ttcVehicleMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'miway-mississauga',
|
||||
name: 'MiWay',
|
||||
operator: 'MiWay (City of Mississauga)',
|
||||
region: 'Mississauga, ON',
|
||||
center: Object.freeze({ lat: 43.589, lon: -79.6441 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
license: 'City of Mississauga Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'http://www5.mississauga.ca/research_catalogue/CityofMississauga_TermsofUse.pdf',
|
||||
attribution: 'MiWay \u2014 City of Mississauga Open Data',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Mississauga (the City) now grants you a world-wide, royalty-free, non-exclusive, revocable licence to use, modify, and distribute the Datasets in all current and future media and formats for any lawful purpose.',
|
||||
note: 'Two conditions shape how this ships. The licence is REVOCABLE, and it asks that anyone who provides access to the data pass on the Terms of Use URL \u2014 so `licenseUrl` points at the terms themselves rather than at a catalogue page, and the attribution popover carries the link. Credit is optional here ("you are not required to credit the City"), and is given anyway as a courtesy, the way the other municipal feeds are.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsr-hamilton',
|
||||
name: 'Hamilton HSR',
|
||||
operator: 'Hamilton Street Railway (City of Hamilton)',
|
||||
region: 'Hamilton, ON',
|
||||
center: Object.freeze({ lat: 43.2557, lon: -79.8711 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Hamilton Open Data Licence',
|
||||
licenseUrl:
|
||||
'https://www.hamilton.ca/city-initiatives/strategies-actions/open-data-licence-terms-and-conditions',
|
||||
attribution:
|
||||
'Contains public sector Data made available under the City of Hamilton\u2019s Open Data Licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Hamilton grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Data, including for commercial purposes ... you must acknowledge the source of the Data by including the following attribution statement: \u201cContains public sector Data made available under the City of Hamilton\u2019s Open Data Licence\u201d.',
|
||||
note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: hamiltonRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'durham-region',
|
||||
name: 'Durham Region Transit',
|
||||
operator: 'Durham Region Transit (Regional Municipality of Durham)',
|
||||
region: 'Durham Region, ON',
|
||||
center: Object.freeze({ lat: 43.9496, lon: -78.9365 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
license: 'Region of Durham Open Data Licence v.1.0',
|
||||
licenseUrl: 'https://www.durham.ca/en/regional-government/open-data.aspx',
|
||||
attribution:
|
||||
"Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Durham Region grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information ... You are free to: Copy, publish, distribute and transmit the information ... Use the information commercially.',
|
||||
note: 'The licence link the open-data catalogue publishes for this feed (OpenDataLicenceAgreement.pdf) is dead after a site relaunch; `licenseUrl` points at the live Open Data page carrying the same licence v.1.0 text, which is where the quote above was read. Credit is optional and the suggested statement is used verbatim.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'guelph-transit',
|
||||
name: 'Guelph Transit',
|
||||
operator: 'Guelph Transit (City of Guelph)',
|
||||
region: 'Guelph, ON',
|
||||
center: Object.freeze({ lat: 43.5325, lon: -80.2482 }),
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://glphprdtmgtfs.glphtrpcloud.com/tmgtfsrealtimewebservice/vehicle/vehiclepositions.pb',
|
||||
license: 'Open Government Licence \u2013 City of Guelph (v2.0)',
|
||||
licenseUrl: 'https://explore.guelph.ca/pages/open-data-license',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Government Licence \u2013 City of Guelph',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information including for commercial purposes subject to the terms below. ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence.',
|
||||
note: 'The endpoint sits on a vendor host, so provenance matters: the City publishes this exact URL on its own open-data developer page, https://explore.guelph.ca/pages/transit-gtfs-data, under an open-data initiative heading (that page is JavaScript-rendered; its readable source is the ArcGIS item JSON behind it). The licence is a verbatim OGL-Canada 2.0 derivative and the attribution wording above is its prescribed fallback, so it is used as written. The transit datasets carry a BLANK licence field in the portal catalogue; the Terms of Use supply it by default ("Except where otherwise noted this is the Open Government Licence"), the same shape of gap the TTC entry records. The Terms also publish a traffic limit \u2014 no concurrent requests, at most 5 per second \u2014 which one poll every 15 s satisfies many times over. Crests and logos are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: guelphRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'kingston-transit',
|
||||
name: 'Kingston Transit',
|
||||
operator: 'Kingston Transit (The Corporation of the City of Kingston)',
|
||||
region: 'Kingston, ON',
|
||||
center: Object.freeze({ lat: 44.24, lon: -76.57 }),
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://api.cityofkingston.ca/gtfs-realtime/vehicleupdates.pb',
|
||||
license: 'City of Kingston Open Data Licence 1.0',
|
||||
licenseUrl:
|
||||
'https://www.cityofkingston.ca/media/wtpgpkb0/gis_license_opendata.pdf',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Data Licence \u2013 City of Kingston',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider, the City of Kingston, grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information provided for any purpose, including for commercial purposes, subject to the terms below. ... Acknowledge the source of the information by including any attribution statement specified by the City of Kingston and, where possible, provide a link to this licence.',
|
||||
note: 'The City catalogues this exact URL as an open-data dataset, so provenance is direct rather than inferred. `licenseUrl` is the canonical location of the licence PDF; the address the catalogue prints is a legacy path that currently redirects there, and pinning the redirect target is what survives the next site relaunch \u2014 the Durham entry records what happens when it does not. Names, crests and logos are excluded from the grant, so the credit stays text. Roughly one entity in ten carries no usable position (the position field absent, occasionally a momentary 0,0 GPS dropout); both are discarded before rendering.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'barrie-transit',
|
||||
name: 'Barrie Transit',
|
||||
operator: 'Barrie Transit (City of Barrie)',
|
||||
region: 'Barrie, ON',
|
||||
center: Object.freeze({ lat: 44.371, lon: -79.6733 }),
|
||||
loadRadiusKm: 10,
|
||||
url: 'https://www.myridebarrie.ca/gtfs/GTFS_VehiclePositions.pb',
|
||||
license: 'Barrie Transit Data licence',
|
||||
licenseUrl:
|
||||
'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs',
|
||||
attribution:
|
||||
'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.',
|
||||
note: 'The City offers this licence for acceptance behind a click-through checkbox, and the licence body also makes use itself acceptance: \u201cBy using Barrie Transit Data, you agree to be bound by these terms.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on; nothing gates the endpoint, which needs no key, token or cookie. Two oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'yrt-york',
|
||||
name: 'YRT/Viva',
|
||||
operator:
|
||||
'York Region Transit (YRT/Viva), The Regional Municipality of York',
|
||||
region: 'York Region, ON',
|
||||
center: Object.freeze({ lat: 44.025, lon: -79.436 }),
|
||||
loadRadiusKm: 38,
|
||||
url: 'https://rtu.york.ca/gtfsrealtime/VehiclePositions',
|
||||
license: 'YRT Open Data Licence v2.0 (UK OGL-derived)',
|
||||
licenseUrl:
|
||||
'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx',
|
||||
attribution:
|
||||
"Contains public transit Information made available under YRT's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.',
|
||||
note: 'The operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential), while the licence body says acceptance is by use: \u201cYour Use of Information indicates Your acceptance of the terms and conditions below.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on. Nothing gates the endpoint. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: yrtRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'burlington-transit',
|
||||
name: 'Burlington Transit',
|
||||
operator: 'Burlington Transit (City of Burlington)',
|
||||
region: 'Burlington, ON',
|
||||
center: Object.freeze({ lat: 43.3342, lon: -79.8089 }),
|
||||
loadRadiusKm: 12,
|
||||
url: 'https://opendata.burlington.ca/gtfs-rt/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Burlington Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'https://opendata.burlington.ca/opendata-terms-of-use/City%20of%20Burlington%20-%20Open%20Data%20Terms%20of%20Use.pdf',
|
||||
attribution:
|
||||
'Transit data \u00a9 City of Burlington (Burlington Transit), used under the City of Burlington Open Data Terms of Use',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Corporation of the City of Burlington (the City) now grants you a worldwide, royalty-free, non-exclusive, revocable licence to use, reproduce, modify, and distribute the datasets in all current and future media and formats for any lawful purpose. ... If you distribute or provide access to these datasets to any other person ... you agree to include a copy of, or this Uniform Resource Locator (URL) for, these Terms of Use.',
|
||||
note: 'Same 2011-era municipal template as Mississauga, and the same two consequences: the grant is REVOCABLE, and the terms have to travel with the data, so `licenseUrl` is the Terms document itself rather than a catalogue page and the attribution popover carries the link. Credit is optional here and given anyway. Crests, logos and marks are excluded and the terms forbid implying any association, so the credit stays plain text. The catalogue record for this data leads with a licence link that 404s; the URL above is the live one. Plain http:// has no listener at all \u2014 not a redirect, a hang \u2014 so the https:// URL is mandatory rather than preferred. The feed updates once a minute and answers conditional requests with an ETag.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: burlingtonRouteLabel,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -353,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||||
* 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.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} vehicleId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hintedMode(feed, routeId, vehicleId) {
|
||||
const byVehicle =
|
||||
typeof feed?.vehicleMode === 'function'
|
||||
? feed.vehicleMode(vehicleId)
|
||||
: null;
|
||||
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
|
||||
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.
|
||||
* @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.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeFor(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
@@ -373,16 +848,32 @@ export function transitModeFor(feed, routeId) {
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} [vehicleId]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeResolved(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `routeLabel`. Never invents a label: an id the feed does not know survives
|
||||
* unchanged, because a raw id a person can compare against the bus is more
|
||||
* use than a blank.
|
||||
* @param {object|null} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function transitRouteLabel(feed, routeId) {
|
||||
if (!routeId || typeof feed?.routeLabel !== 'function') return routeId;
|
||||
const named = feed.routeLabel(routeId);
|
||||
return typeof named === 'string' && named ? named : routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
transitRouteLabel,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
@@ -239,3 +240,245 @@ test('published transit history scope agrees with the catalog capability', () =>
|
||||
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the TTC feed is registered, routable, and covers Toronto', () => {
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(ttc?.url, 'https://bustime.ttc.ca/gtfsrt/vehicles');
|
||||
assert.equal(ttc.defaultEnabled, true);
|
||||
assert.match(ttc.attribution, /Toronto Transit Commission/);
|
||||
assert.equal(ttc.historyRetention, undefined, 'no proxy retention');
|
||||
assert.ok(
|
||||
transitFeedsInRange(43.6532, -79.3832).some(
|
||||
(feed) => feed.id === 'ttc-toronto',
|
||||
),
|
||||
'downtown Toronto is inside the load radius',
|
||||
);
|
||||
});
|
||||
|
||||
test('TTC route numbers tell a streetcar from a bus', () => {
|
||||
// BusTime is the surface feed: 500-series are streetcar routes and the
|
||||
// streetcar-operated Blue Night routes keep their own numbers. Everything
|
||||
// else on this feed is a bus, so the mode is established, not defaulted.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '512'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '304'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '36'), 'bus');
|
||||
// 52 Lawrence West is a bus. Only the three-digit 500-series is streetcar,
|
||||
// so the rule must not match on a leading 5 alone.
|
||||
assert.equal(transitModeFor(ttc, '52'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '59'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '900'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '352'), 'bus');
|
||||
assert.equal(transitModeResolved(ttc, '501'), true);
|
||||
assert.equal(transitModeResolved(ttc, null), false);
|
||||
assert.equal(
|
||||
transitModeFor(ttc, null),
|
||||
'bus',
|
||||
'an unrouted vehicle falls back to the feed default, unresolved',
|
||||
);
|
||||
});
|
||||
|
||||
test('a TTC fleet number outranks the route', () => {
|
||||
// Buses replace streetcars on 5xx routes during track work, and streetcars
|
||||
// out of service report no route; the Flexity fleet is 4400–4699.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '506', '8515'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '512', '3101'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, null, '4507'), 'tram');
|
||||
assert.equal(transitModeResolved(ttc, null, '4507'), true);
|
||||
assert.equal(transitModeFor(ttc, '504', '4400'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '29', '4699'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501', '4700'), 'bus');
|
||||
// A vehicle id that is not a fleet number leaves the route to decide.
|
||||
assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
});
|
||||
|
||||
test('the TTC row records that BusTime carries no subway', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const row = sources.split('\n').find((line) => line.startsWith('| **TTC**'));
|
||||
assert.ok(row, 'TTC has a DATA_SOURCES row');
|
||||
assert.match(row, /Toronto/);
|
||||
assert.match(sources, /surface vehicles \(buses and streetcars\) only/);
|
||||
assert.match(sources, /Open Government Licence – Toronto/);
|
||||
});
|
||||
|
||||
test('the Golden Horseshoe neighbours are registered, keyless and routable', () => {
|
||||
const expected = {
|
||||
'miway-mississauga':
|
||||
'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
'hsr-hamilton':
|
||||
'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
'durham-region':
|
||||
'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
};
|
||||
for (const [id, url] of Object.entries(expected)) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.ok(feed, `${id} is routable`);
|
||||
assert.equal(feed.url, url);
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.defaultMode, 'bus');
|
||||
assert.equal(feed.historyRetention, undefined, `${id} retains nothing`);
|
||||
assert.ok(feed.terms?.quote, `${id} records the terms it ships on`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each Golden Horseshoe feed covers its own city and not its neighbours', () => {
|
||||
const near = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
// Mississauga City Centre, downtown Hamilton, Oshawa.
|
||||
assert.ok(near(43.589, -79.6441).includes('miway-mississauga'));
|
||||
assert.ok(near(43.2557, -79.8711).includes('hsr-hamilton'));
|
||||
assert.ok(near(43.8971, -78.8658).includes('durham-region'));
|
||||
// Hamilton is 60 km from Toronto: far enough that the TTC feed is not polled
|
||||
// for it, which is the whole point of a per-feed radius.
|
||||
assert.equal(near(43.2557, -79.8711).includes('ttc-toronto'), false);
|
||||
});
|
||||
|
||||
test('a bus-only feed never claims a mode its route ids cannot establish', () => {
|
||||
for (const id of ['miway-mississauga', 'hsr-hamilton', 'durham-region']) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.equal(transitModeFor(feed, '1'), 'bus');
|
||||
assert.equal(
|
||||
transitModeResolved(feed, '1'),
|
||||
false,
|
||||
`${id} defaults rather than resolves`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every Golden Horseshoe feed is documented with its licence', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const name of ['MiWay', 'Hamilton HSR', 'Durham Region Transit']) {
|
||||
assert.ok(
|
||||
sources.split('\n').some((line) => line.startsWith(`| **${name}`)),
|
||||
`${name} has a DATA_SOURCES row`,
|
||||
);
|
||||
}
|
||||
assert.match(sources, /Durham Region Transit publishes no bearing/);
|
||||
});
|
||||
|
||||
test('a feed whose route ids are internal database keys can name its routes', () => {
|
||||
// Guelph's realtime feed emits `2991` where the rider sees route `1`. A feed
|
||||
// that knows the difference translates it; every other feed is unaffected.
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
assert.equal(transitRouteLabel(guelph, '2991'), '1');
|
||||
assert.equal(transitRouteLabel(guelph, '3016'), '99');
|
||||
assert.equal(transitRouteLabel(guelph, '3007'), '50 U');
|
||||
assert.equal(transitRouteLabel(guelph, '3017'), '99Lite');
|
||||
});
|
||||
|
||||
test('an unknown or absent route id survives translation unchanged', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
// A route added after this table was built must still render its raw id
|
||||
// rather than vanish or read as "undefined".
|
||||
assert.equal(transitRouteLabel(guelph, '4242'), '4242');
|
||||
assert.equal(transitRouteLabel(guelph, null), null);
|
||||
assert.equal(transitRouteLabel(guelph, ''), '');
|
||||
});
|
||||
|
||||
test('a feed with rider-facing route ids is left alone', () => {
|
||||
// TTC and Kingston publish the route a rider would say, so translation is
|
||||
// identity for them and no table is carried.
|
||||
assert.equal(transitRouteLabel(getTransitFeed('ttc-toronto'), '501'), '501');
|
||||
assert.equal(transitRouteLabel(getTransitFeed('mbta'), 'Red'), 'Red');
|
||||
assert.equal(transitRouteLabel(null, '7'), '7');
|
||||
});
|
||||
|
||||
test('the feeds whose licences are accepted by use are switched on', () => {
|
||||
// Barrie and YRT each ask for acceptance through a form, and each licence
|
||||
// also says that using the data is itself acceptance. That reading was
|
||||
// taken deliberately, so both are routable and both record the reasoning
|
||||
// in their terms note rather than leaving it to be re-derived later.
|
||||
for (const id of ['barrie-transit', 'yrt-york']) {
|
||||
assert.ok(getTransitFeed(id), `${id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((feed) => feed.id === id),
|
||||
`${id} is offered to the browser`,
|
||||
);
|
||||
assert.match(
|
||||
getRegisteredTransitFeed(id).terms.note,
|
||||
/acceptance is by use|use itself acceptance|took that clause as sufficient/,
|
||||
`${id} records why it ships on`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every registered feed now ships on, and the gate still decides that', () => {
|
||||
// The enabled set must stay a filter over the registry, not a copy of it:
|
||||
// flipping one feed off has to make it unreachable.
|
||||
assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva');
|
||||
assert.deepEqual(
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled !== true),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Viva bus rapid transit is named by its colour, not its route number', () => {
|
||||
// YRT publishes `601` where every rider, map and station sign says
|
||||
// "Viva Blue". The numeric local routes already read correctly and are
|
||||
// left alone.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue');
|
||||
assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A');
|
||||
assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow');
|
||||
assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number');
|
||||
assert.equal(transitRouteLabel(yrt, '9899'), '9899');
|
||||
});
|
||||
|
||||
test('YRT local branches read as the branch letter, not the padded id', () => {
|
||||
// YRT suffixes a two-digit branch onto the base route, the same scheme the
|
||||
// Viva table already relies on for `60102`. Unsuffixed, that leaks to a rider
|
||||
// as "8301". The letters are York Region's own published GTFS
|
||||
// `routes.txt` short names, unpadded to match the local routes beside them.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '10702'), '107B');
|
||||
assert.equal(transitRouteLabel(yrt, '8301'), '83A');
|
||||
assert.equal(transitRouteLabel(yrt, '9002'), '90B');
|
||||
// `9101` runs in the realtime feed but is absent from routes.txt, so there is
|
||||
// no published name to give it. Inventing "91A" from the pattern is exactly
|
||||
// the guess the registry refuses to make; it stays opaque until it is
|
||||
// published.
|
||||
assert.equal(transitRouteLabel(yrt, '9101'), '9101');
|
||||
});
|
||||
|
||||
test('Hamilton route ids are internal keys, read as the number on the bus', () => {
|
||||
// Not one of HSR's 80 route ids equals its published short name, so every
|
||||
// vehicle reads as "Route 5783" until the table translates it. Hamilton also
|
||||
// carries two generations of id for the same route — `5688` and `5780` are
|
||||
// both the 2 BARTON — so both must land on the same answer.
|
||||
const hsr = getTransitFeed('hsr-hamilton');
|
||||
assert.equal(transitRouteLabel(hsr, '5780'), '2');
|
||||
assert.equal(transitRouteLabel(hsr, '5688'), '2', 'the older id agrees');
|
||||
assert.equal(transitRouteLabel(hsr, '5687'), '1');
|
||||
assert.equal(transitRouteLabel(hsr, '5829'), '10', 'the B-Line');
|
||||
assert.equal(transitRouteLabel(hsr, '5793'), '20', 'the A-Line');
|
||||
// Published names that are not numbers are already what a rider would say,
|
||||
// so they survive as published rather than being forced into a number.
|
||||
assert.equal(transitRouteLabel(hsr, '5820'), 'TC230');
|
||||
assert.equal(transitRouteLabel(hsr, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington route ids are joined, never prefix-stripped', () => {
|
||||
// `351` is route 1 and `3510` is route 10, so no amount of trimming a `35`
|
||||
// prefix works — the table is the only correct answer.
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(transitRouteLabel(bur, '351'), '1');
|
||||
assert.equal(transitRouteLabel(bur, '3510'), '10');
|
||||
assert.equal(transitRouteLabel(bur, '3512'), '12');
|
||||
assert.equal(transitRouteLabel(bur, '3587'), '87');
|
||||
assert.equal(transitRouteLabel(bur, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington is live and carries its terms link, which its licence requires', () => {
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(bur.defaultEnabled, true);
|
||||
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
|
||||
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
|
||||
});
|
||||
@@ -1,4 +1,41 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
fixedHorizontalFovDeg,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
/**
|
||||
* Applies datasheet enrichment to a camera in place: when it names a known
|
||||
* hardware `model` (and, when published, a matching `manufacturer`), the spec
|
||||
* record is attached for the HUD and the datasheet's horizontal FOV range
|
||||
* rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware
|
||||
* fills the FOV, and only when none was given: an explicit source/seed
|
||||
* `fovDeg` or a curated pose is never overwritten. A PTZ/varifocal range says
|
||||
* what the lens CAN do, not where it is zoomed now.
|
||||
* Unknown models — the common case — change nothing.
|
||||
* @param {Object} camera - Camera being built (mutated).
|
||||
* @param {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware
|
||||
* - Hardware model/manufacturer from a source or seed, and whether that
|
||||
* source or seed stated its own `fovDeg`.
|
||||
* @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper.
|
||||
*/
|
||||
function applyModelEnrichment(camera, hardware, clamp) {
|
||||
const { model, manufacturer, explicitFov } = hardware;
|
||||
const spec = model ? lookupModelSpec(model, manufacturer) : null;
|
||||
if (!spec) return;
|
||||
const range = horizontalFovRangeDeg(spec);
|
||||
if (range) {
|
||||
camera.specFovMinDeg = range.minDeg;
|
||||
camera.specFovMaxDeg = range.maxDeg;
|
||||
}
|
||||
const fixedFov = fixedHorizontalFovDeg(spec);
|
||||
if (fixedFov && !explicitFov && camera.poseSource !== 'curated') {
|
||||
camera.fovDeg = clamp(fixedFov, 20, 125);
|
||||
camera.fovSource = 'datasheet';
|
||||
}
|
||||
camera.spec = spec;
|
||||
}
|
||||
|
||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
const { CITY_POIS } = services.locations;
|
||||
@@ -45,6 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
||||
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
|
||||
};
|
||||
applyModelEnrichment(
|
||||
camera,
|
||||
{ model: seed.model, explicitFov: seed.fovDeg != null },
|
||||
parts.model.clamp,
|
||||
);
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
@@ -207,6 +249,19 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
model: String(source.model || ''),
|
||||
manufacturer: String(source.manufacturer || ''),
|
||||
};
|
||||
applyModelEnrichment(
|
||||
camera,
|
||||
{
|
||||
model: source.model || seed?.model,
|
||||
manufacturer: source.model ? source.manufacturer : '',
|
||||
explicitFov:
|
||||
(source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) ||
|
||||
CAMERA_SEEDS.some(
|
||||
(entry) => entry.id === id && entry.fovDeg != null,
|
||||
),
|
||||
},
|
||||
parts.model.clamp,
|
||||
);
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* @module cctvModelSpecs
|
||||
*
|
||||
* Hardware-model spec enrichment for the CCTV layer.
|
||||
*
|
||||
* When a catalog camera names its hardware model (source packs may set
|
||||
* `model`; hand-authored seeds may too), the model resolves to a datasheet
|
||||
* record and the HUD meta line gains the model's real specs. A fixed-lens
|
||||
* model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or
|
||||
* multi-sensor range is carried as capability metadata beside the estimate,
|
||||
* because the lens's current zoom is unknown.
|
||||
* Cameras without an identified model are untouched — unknown never implies
|
||||
* anything.
|
||||
*
|
||||
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
|
||||
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
|
||||
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
|
||||
* publish other hardware models can grow it, or vendor the full extract.
|
||||
*
|
||||
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||
* night vision) — never endpoints, stream paths, or access/config data.
|
||||
*/
|
||||
|
||||
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'axis-q6135-le',
|
||||
brand: 'Axis',
|
||||
model: 'Q6135-LE',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6135-le/',
|
||||
aliases: ['01959-004'],
|
||||
fov_deg: '58.3-2.4 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'ir', range_m: 250 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'hanwha-xnp-6400rw',
|
||||
brand: 'Hanwha',
|
||||
model: 'XNP-6400RW',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/',
|
||||
aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'],
|
||||
fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'ir', range_m: 200 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'hikvision-ds-2cd2087g3-li2uy',
|
||||
brand: 'Hikvision',
|
||||
model: 'DS-2CD2087G3-LI2UY',
|
||||
type: 'bullet',
|
||||
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
|
||||
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
|
||||
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
|
||||
night_vision: { type: 'color', range_m: 40 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: false,
|
||||
},
|
||||
{
|
||||
id: 'axis-f4105-lre',
|
||||
brand: 'Axis',
|
||||
model: 'F4105-LRE',
|
||||
type: 'dome',
|
||||
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
|
||||
fov_deg: '110 horizontal, 60 vertical',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
night_vision: { type: 'ir', range_m: 10 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'axis-p3707-pe',
|
||||
brand: 'Axis',
|
||||
model: 'P3707-PE',
|
||||
type: 'panoramic',
|
||||
url: 'https://www.cctv-database.com/camera/axis-p3707-pe/',
|
||||
aliases: ['AXIS P3707-PE', 'P3707-PE'],
|
||||
fov_deg: '108-54 horizontal per sensor (4x 1080p)',
|
||||
resolution: {
|
||||
mp: 8,
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
label: '4x 2MP (8MP total; 4x 1080p)',
|
||||
},
|
||||
lens_count: 4,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6000-e-mk-ii',
|
||||
brand: 'Axis',
|
||||
model: 'Q6000-E Mk II',
|
||||
type: 'panoramic',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/',
|
||||
aliases: [
|
||||
'AXIS Q6000-E Mk II',
|
||||
'Q6000-E Mk II 50 Hz',
|
||||
'Q6000-E Mk II 60 Hz',
|
||||
],
|
||||
fov_deg: 'up to 360 combined; 113-152 horizontal per sensor',
|
||||
resolution: {
|
||||
mp: 8,
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
label: '4x 2MP (8MP total; 4x 1080p)',
|
||||
},
|
||||
lens_count: 4,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6054-e',
|
||||
brand: 'Axis',
|
||||
model: 'Q6054-E',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6054-e/',
|
||||
aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'],
|
||||
fov_deg: '62.9-2.2 horizontal',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6155-e',
|
||||
brand: 'Axis',
|
||||
model: 'Q6155-E',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6155-e/',
|
||||
aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'],
|
||||
fov_deg: '66.7-2.36 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-500i',
|
||||
brand: 'Bosch',
|
||||
model: 'AutoDome 500i Series (VG4)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/',
|
||||
aliases: [
|
||||
'VG4 AUTODOME',
|
||||
'VG4 AUTODOME H.264',
|
||||
'AutoDome 500i',
|
||||
'AutoDome 500i Series',
|
||||
'VG4-500i',
|
||||
'VG4-500',
|
||||
],
|
||||
fov_deg: '57.8-1.7 horizontal',
|
||||
resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-ip-dynamic-7000-hd',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP dynamic 7000 HD',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/',
|
||||
aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'],
|
||||
fov_deg: '65-2.3 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-ip-starlight-7000-hd',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP starlight 7000 HD',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/',
|
||||
aliases: [
|
||||
'AUTODOME IP starlight 7000 HD',
|
||||
'Starlight 7000HD',
|
||||
'VG5-7130-CPT4',
|
||||
'VG5-7130',
|
||||
],
|
||||
fov_deg: '59-2.1 horizontal',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-ndp-5502-30',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP 5000i (NDP-5502-Z30)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/',
|
||||
aliases: [
|
||||
'AUTODOME IP 5000i',
|
||||
'NDP-5502-Z30',
|
||||
'NDP-5502-Z30C',
|
||||
'NDP-5502-30',
|
||||
],
|
||||
fov_deg: '60.9-2.4 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-ndp-7512-z30',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/',
|
||||
aliases: [
|
||||
'AUTODOME IP starlight 7000i',
|
||||
'NDP-7512-Z30',
|
||||
'NDP-7512-Z30C',
|
||||
'NDP-7512-Z30K',
|
||||
],
|
||||
fov_deg: '64.7-2.3 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-vg5-836-ecev',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/',
|
||||
aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'],
|
||||
resolution: { mp: 2, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3920-idome',
|
||||
brand: 'Cohu',
|
||||
model: '3920 iDome',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3920-idome/',
|
||||
aliases: [
|
||||
'3920',
|
||||
'3920 Series',
|
||||
'3923',
|
||||
'3925',
|
||||
'3925 Dome',
|
||||
'iDome 3925',
|
||||
'Cohu iDome 3920 Series',
|
||||
],
|
||||
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3950-iview',
|
||||
brand: 'Cohu',
|
||||
model: '3950 iVIEW',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3950-iview/',
|
||||
aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'],
|
||||
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3960',
|
||||
brand: 'Cohu',
|
||||
model: '3960',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3960/',
|
||||
aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'],
|
||||
resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Lowercase + collapse whitespace, the shared normal form for matching. */
|
||||
function normalize(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace words of a name, lowercased, with surrounding punctuation trimmed
|
||||
* so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay:
|
||||
* "f4105-lre-unknown" is one word, never "f4105-lre" plus noise.
|
||||
* @param {string} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function words(value) {
|
||||
return normalize(value)
|
||||
.split(' ')
|
||||
.map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor words that never identify hardware: product-type nouns, codec
|
||||
* and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p").
|
||||
*/
|
||||
const GENERIC_WORDS = new Set([
|
||||
'network',
|
||||
'camera',
|
||||
'cam',
|
||||
'ip',
|
||||
'ptz',
|
||||
'dome',
|
||||
'bullet',
|
||||
'box',
|
||||
'hd',
|
||||
'ir',
|
||||
'outdoor',
|
||||
'indoor',
|
||||
'series',
|
||||
]);
|
||||
|
||||
function isGeneric(word) {
|
||||
return (
|
||||
GENERIC_WORDS.has(word) ||
|
||||
/^h\.?\d+$/.test(word) ||
|
||||
/^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word)
|
||||
);
|
||||
}
|
||||
|
||||
/** A model code: letters AND digits, and not a generic descriptor. */
|
||||
function isModelCode(word) {
|
||||
return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word);
|
||||
}
|
||||
|
||||
/** Hyphenated variant suffixes that do not change the hardware. */
|
||||
const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']);
|
||||
|
||||
/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */
|
||||
function stripVariantSuffix(word) {
|
||||
let out = word;
|
||||
for (;;) {
|
||||
const cut = out.lastIndexOf('-');
|
||||
if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out;
|
||||
out = out.slice(0, cut);
|
||||
}
|
||||
}
|
||||
|
||||
/** Manufacturer names (lowercased words) accepted for each record brand. */
|
||||
const BRAND_ALIASES = {
|
||||
Hanwha: ['wisenet', 'samsung'],
|
||||
Hikvision: ['hiwatch'],
|
||||
};
|
||||
|
||||
function brandWords(record) {
|
||||
return new Set([
|
||||
...words(record.brand),
|
||||
...(BRAND_ALIASES[record.brand] || []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per record name (model + aliases). `required` is every
|
||||
* non-generic word of the name — all must appear in a query, so a generation
|
||||
* or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks
|
||||
* names carrying a letter+digit model code; names without one ("3950",
|
||||
* "01959-004", "AUTODOME 800 Series") match only when the brand does.
|
||||
*/
|
||||
const NAME_INDEX = RECORDS.flatMap((record) => {
|
||||
const brand = brandWords(record);
|
||||
return [record.model, ...(record.aliases || [])].map((name) => {
|
||||
const nameWords = words(name);
|
||||
return {
|
||||
record,
|
||||
brand,
|
||||
allowed: new Set(nameWords),
|
||||
required: nameWords.filter((word) => !isGeneric(word)),
|
||||
hasCode: nameWords.some(isModelCode),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Looks up a spec record for a hardware-model string.
|
||||
*
|
||||
* A record name matches when every non-generic word of that name appears in
|
||||
* the query (case-insensitive, punctuation-trimmed) and every other query word
|
||||
* is generic or the record's brand. That lets a VERBOSE registry string
|
||||
* resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while
|
||||
* an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part
|
||||
* number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and
|
||||
* product-type words never identify hardware on their own. A name with no
|
||||
* letter+digit model code ("3950") needs the brand, from `manufacturer` or the
|
||||
* query itself. A `manufacturer` that is not the record's brand always
|
||||
* rejects. Matches on two different records are ambiguous → null (never
|
||||
* guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @param {string} [manufacturer] - The source's manufacturer, when published.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model, manufacturer) {
|
||||
const query = words(model).map(stripVariantSuffix);
|
||||
if (!query.length) return null;
|
||||
const maker = words(manufacturer);
|
||||
const found = new Set();
|
||||
for (const entry of NAME_INDEX) {
|
||||
const makerMatches = maker.some((word) => entry.brand.has(word));
|
||||
if (maker.length && !makerMatches) continue;
|
||||
if (
|
||||
!entry.hasCode &&
|
||||
!makerMatches &&
|
||||
!query.some((word) => entry.brand.has(word))
|
||||
)
|
||||
continue;
|
||||
if (!entry.required.length) continue;
|
||||
if (!entry.required.every((word) => query.includes(word))) continue;
|
||||
if (
|
||||
!query.every(
|
||||
(word) =>
|
||||
entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
found.add(entry.record);
|
||||
}
|
||||
return found.size === 1 ? [...found][0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datasheet horizontal FOV as the range the hardware can cover. Only
|
||||
* segments stating a horizontal figure count — vertical and "combined"
|
||||
* panoramic figures never do — and parenthesized lens/sensor notes are
|
||||
* ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list
|
||||
* ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {{minDeg:number, maxDeg:number}|null} Null when unstated.
|
||||
*/
|
||||
export function horizontalFovRangeDeg(spec) {
|
||||
const values = [];
|
||||
for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) {
|
||||
if (!/horizontal/i.test(segment)) continue;
|
||||
const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0];
|
||||
for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) {
|
||||
const value = Number(match[0]);
|
||||
if (Number.isFinite(value) && value > 0) values.push(value);
|
||||
}
|
||||
}
|
||||
if (!values.length) return null;
|
||||
return { minDeg: Math.min(...values), maxDeg: Math.max(...values) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The datasheet horizontal FOV as the camera's CURRENT optical state, which
|
||||
* holds only for fixed single-lens hardware stating one figure. PTZ,
|
||||
* varifocal and multi-sensor models publish a capability range whose in-use
|
||||
* value is unknown — known hardware capability is not known pose state — so
|
||||
* they return null and keep their estimated FOV.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {number|null} Fixed horizontal FOV in degrees, or null.
|
||||
*/
|
||||
export function fixedHorizontalFovDeg(spec) {
|
||||
if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1)
|
||||
return null;
|
||||
const range = horizontalFovRangeDeg(spec);
|
||||
return range && range.minDeg === range.maxDeg ? range.minDeg : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the camera's CURRENT FOV is the datasheet figure: it was filled
|
||||
* from a fixed-lens datasheet and no manual calibration offset has moved it.
|
||||
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDatasheetFov(camera) {
|
||||
return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD token for a datasheet FOV that describes capability rather than the
|
||||
* camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'.
|
||||
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
|
||||
* @returns {string|null} Null when the datasheet FOV already IS the camera's
|
||||
* FOV (fixed lens) or no datasheet range is known.
|
||||
*/
|
||||
export function specFovCapabilityToken(camera) {
|
||||
if (!camera || camera.fovSource === 'datasheet') return null;
|
||||
const { specFovMinDeg: min, specFovMaxDeg: max } = camera;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||
const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg)));
|
||||
const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`;
|
||||
return `SPEC FOV ${span}° (CAPABILITY)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line spec summary for HUD meta text.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null).
|
||||
*/
|
||||
export function specSummary(spec) {
|
||||
if (!spec) return '';
|
||||
const parts = [`${spec.brand} ${spec.model}`];
|
||||
if (spec.resolution?.label) parts.push(spec.resolution.label);
|
||||
if (spec.night_vision) {
|
||||
parts.push(
|
||||
`${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`,
|
||||
);
|
||||
}
|
||||
if (spec.ptz) parts.push('PTZ');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// src/layers/cctv/modelSpecs.test.mjs
|
||||
// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias
|
||||
// matching, unknown → null — never a guess), datasheet-FOV parsing across the
|
||||
// real fov_deg string shapes, and the HUD spec-summary line.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
fixedHorizontalFovDeg,
|
||||
specFovCapabilityToken,
|
||||
isDatasheetFov,
|
||||
specSummary,
|
||||
} from './modelSpecs.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
|
||||
test('lookupModelSpec matches case-insensitively and trims', () => {
|
||||
const spec = lookupModelSpec(' q6135-le ');
|
||||
assert.ok(spec);
|
||||
assert.equal(spec.brand, 'Axis');
|
||||
assert.equal(lookupModelSpec('Q6135-LE'), spec);
|
||||
});
|
||||
|
||||
test('lookupModelSpec matches aliases', () => {
|
||||
// A part number without letters needs the brand.
|
||||
const byAlias = lookupModelSpec('01959-004', 'Axis');
|
||||
assert.ok(byAlias);
|
||||
assert.equal(byAlias.model, 'Q6135-LE');
|
||||
});
|
||||
|
||||
test('vendored table covers the integrated catalogs published hardware', () => {
|
||||
// Models the live packs publish: King County (Cohu, AUTODOME IP 5000i),
|
||||
// Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis).
|
||||
// King County publishes "3950" with Manufacturer "Cohu".
|
||||
const cohu = lookupModelSpec('3950', 'Cohu');
|
||||
assert.equal(cohu?.brand, 'Cohu');
|
||||
const autodome = lookupModelSpec('AUTODOME IP 5000i');
|
||||
assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0);
|
||||
const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string
|
||||
assert.ok(vg4);
|
||||
const axis = lookupModelSpec('Q6155-E'); // Sioux Falls
|
||||
assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 });
|
||||
});
|
||||
|
||||
test('verbose registry strings resolve via distinctive-token fallback', () => {
|
||||
// Sioux Falls publishes the full product title, not the bare model.
|
||||
assert.equal(
|
||||
lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model,
|
||||
'Q6155-E',
|
||||
);
|
||||
assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II');
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id,
|
||||
'bosch-ndp-7512-z30',
|
||||
);
|
||||
// Punctuation around the model token doesn't defeat the match.
|
||||
assert.equal(
|
||||
lookupModelSpec('camera (P3707-PE), outdoor')?.model,
|
||||
'P3707-PE',
|
||||
);
|
||||
});
|
||||
|
||||
test('token fallback never matches on generic words or wrong models', () => {
|
||||
// Only generic descriptors — no distinctive hardware token — stays null.
|
||||
assert.equal(lookupModelSpec('PTZ Dome Camera'), null);
|
||||
assert.equal(lookupModelSpec('HD Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('Outdoor Dome Camera'), null);
|
||||
// A real-looking but unknown model resolves to nothing, not a near neighbour.
|
||||
assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null);
|
||||
// A generation token alone must not collide with a similar one: "7000"
|
||||
// belongs to two records (dynamic / starlight 7000 HD), so a bare
|
||||
// "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick.
|
||||
assert.equal(lookupModelSpec('AUTODOME 7000'), null);
|
||||
});
|
||||
|
||||
test('exact match still wins over the token fallback', () => {
|
||||
// "3965" is an alias of the 3960, not the 3950.
|
||||
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
|
||||
assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30');
|
||||
});
|
||||
|
||||
test('lookupModelSpec returns null for unknown or empty input — never guesses', () => {
|
||||
assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null);
|
||||
assert.equal(lookupModelSpec(''), null);
|
||||
assert.equal(lookupModelSpec(null), null);
|
||||
assert.equal(lookupModelSpec(undefined), null);
|
||||
});
|
||||
|
||||
test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => {
|
||||
const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model));
|
||||
// PTZ zoom: both ends, not just the wide one.
|
||||
assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 });
|
||||
// Vertical figures never count.
|
||||
assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 });
|
||||
// Lens options: parenthesized focal lengths are not FOV figures.
|
||||
assert.deepEqual(range('DS-2CD2087G3-LI2UY'), {
|
||||
minDeg: 93.3,
|
||||
maxDeg: 108.8,
|
||||
});
|
||||
assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 });
|
||||
// The "360 combined" panoramic figure is not a horizontal lens FOV.
|
||||
assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 });
|
||||
assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 });
|
||||
});
|
||||
|
||||
test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => {
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({}), null);
|
||||
assert.equal(horizontalFovRangeDeg(null), null);
|
||||
});
|
||||
|
||||
test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => {
|
||||
// Fixed dome, one horizontal figure: the datasheet IS the optical state.
|
||||
assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
|
||||
// Known hardware capability != known pose state: PTZ, multi-sensor, and a
|
||||
// model sold in several lens options all stay estimated.
|
||||
for (const model of [
|
||||
'Q6135-LE',
|
||||
'XNP-6400RW',
|
||||
'AUTODOME IP 5000i',
|
||||
'P3707-PE',
|
||||
'Q6000-E Mk II',
|
||||
'DS-2CD2087G3-LI2UY',
|
||||
]) {
|
||||
assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model);
|
||||
}
|
||||
assert.equal(fixedHorizontalFovDeg(null), null);
|
||||
});
|
||||
|
||||
test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => {
|
||||
assert.equal(
|
||||
specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }),
|
||||
'SPEC FOV 2.4–58° (CAPABILITY)',
|
||||
);
|
||||
assert.equal(
|
||||
specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }),
|
||||
'SPEC FOV 113° (CAPABILITY)',
|
||||
);
|
||||
// Already the camera's FOV (fixed lens) or nothing known: no token.
|
||||
assert.equal(
|
||||
specFovCapabilityToken({
|
||||
fovSource: 'datasheet',
|
||||
specFovMinDeg: 110,
|
||||
specFovMaxDeg: 110,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(specFovCapabilityToken({}), null);
|
||||
assert.equal(specFovCapabilityToken(null), null);
|
||||
});
|
||||
|
||||
/** Browser catalog with the pose math stubbed: only enrichment is under test. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('catalog keeps the estimated FOV for PTZ hardware and fills only fixed-lens datasheets', () => {
|
||||
const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' },
|
||||
{ id: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' },
|
||||
{ id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' },
|
||||
]);
|
||||
|
||||
// PTZ: the frustum keeps its estimate; the datasheet range is metadata.
|
||||
assert.equal(ptz.fovDeg, 44);
|
||||
assert.equal(ptz.fovSource, undefined);
|
||||
assert.equal(ptz.specFovMinDeg, 2.4);
|
||||
assert.equal(ptz.specFovMaxDeg, 58.3);
|
||||
assert.equal(ptz.spec.model, 'Q6135-LE');
|
||||
|
||||
// Fixed lens: the datasheet figure is the optical state.
|
||||
assert.equal(fixed.fovDeg, 110);
|
||||
assert.equal(fixed.fovSource, 'datasheet');
|
||||
|
||||
// Unknown hardware changes nothing.
|
||||
assert.equal(unknown.fovDeg, 44);
|
||||
assert.equal(unknown.spec, undefined);
|
||||
assert.equal(unknown.specFovMaxDeg, undefined);
|
||||
});
|
||||
|
||||
test('codec, format and product words never identify hardware', () => {
|
||||
// "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code.
|
||||
assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('H.264 Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('H.265 IP Camera'), null);
|
||||
});
|
||||
|
||||
test('generation and suffix distinctions are preserved', () => {
|
||||
// A Mk II is not the vendored (Mk I) Q6135-LE.
|
||||
assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null);
|
||||
// A longer part number is a different product...
|
||||
assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null);
|
||||
// ...unless the rest is a mains-frequency variant suffix.
|
||||
assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e');
|
||||
});
|
||||
|
||||
test('manufacturer must agree with the record brand', () => {
|
||||
// Purely numeric models need a brand match.
|
||||
assert.equal(lookupModelSpec('3950'), null);
|
||||
assert.equal(lookupModelSpec('Pelco 3950'), null);
|
||||
assert.equal(lookupModelSpec('3950', 'Pelco'), null);
|
||||
// A model code under the wrong manufacturer is not that record either.
|
||||
assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null);
|
||||
// Case-insensitive, with company suffixes and aliases.
|
||||
assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview');
|
||||
assert.equal(
|
||||
lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id,
|
||||
'axis-q6135-le',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id,
|
||||
'hanwha-xnp-6400rw',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('XNP-6400RW', 'Wisenet')?.id,
|
||||
'hanwha-xnp-6400rw',
|
||||
);
|
||||
});
|
||||
|
||||
test('King County snapshot model strings still resolve', () => {
|
||||
// Model/Manufacturer pairs as published in the King County feed.
|
||||
assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview');
|
||||
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
|
||||
assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome');
|
||||
assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le');
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id,
|
||||
'bosch-ndp-5502-30',
|
||||
);
|
||||
});
|
||||
|
||||
test('catalog never overwrites an explicit FOV or a curated pose', () => {
|
||||
const [explicit, curated, cohu, pelco] =
|
||||
browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' },
|
||||
{
|
||||
id: 'curated',
|
||||
lat: 47.7,
|
||||
lon: -122,
|
||||
model: 'F4105-LRE',
|
||||
poseSource: 'curated',
|
||||
},
|
||||
{ id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' },
|
||||
{
|
||||
id: 'pelco',
|
||||
lat: 47.7,
|
||||
lon: -122,
|
||||
model: '3950',
|
||||
manufacturer: 'Pelco',
|
||||
},
|
||||
]);
|
||||
assert.equal(explicit.fovDeg, 44);
|
||||
assert.equal(explicit.fovSource, undefined);
|
||||
assert.equal(explicit.spec.model, 'F4105-LRE');
|
||||
assert.equal(curated.fovDeg, 74);
|
||||
assert.equal(curated.fovSource, undefined);
|
||||
// The source's manufacturer reaches the lookup.
|
||||
assert.equal(cohu.spec?.id, 'cohu-3950-iview');
|
||||
assert.equal(pelco.spec, undefined);
|
||||
});
|
||||
|
||||
test('the DATASHEET label drops once calibration moves the FOV', () => {
|
||||
const camera = { fovSource: 'datasheet', fovDeg: 110 };
|
||||
assert.equal(isDatasheetFov(camera), true);
|
||||
assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true);
|
||||
assert.equal(
|
||||
isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(isDatasheetFov({ fovDeg: 74 }), false);
|
||||
assert.equal(isDatasheetFov(null), false);
|
||||
});
|
||||
|
||||
test('specSummary renders the HUD line and degrades by omission', () => {
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('Q6135-LE')),
|
||||
'Axis Q6135-LE · 1080p · IR 250m · PTZ',
|
||||
);
|
||||
// No night vision / no PTZ → those segments are simply absent.
|
||||
assert.equal(
|
||||
specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }),
|
||||
'Axis X · 4K',
|
||||
);
|
||||
assert.equal(specSummary(null), '');
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
specSummary,
|
||||
specFovCapabilityToken,
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
@@ -39,7 +44,12 @@ export function createPresentation({
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°`,
|
||||
// Identified hardware labels its FOV as datasheet-sourced (catalog.js)
|
||||
// only while no manual calibration offset has moved it.
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (DATASHEET)' : ''}`,
|
||||
// A PTZ/varifocal datasheet range is what the lens can do, not its
|
||||
// current zoom, so it is shown beside the estimate, never as it.
|
||||
specFovCapabilityToken(active.camera),
|
||||
`COVERAGE ${area.toFixed(2)}km²`,
|
||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
||||
@@ -49,6 +59,10 @@ export function createPresentation({
|
||||
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
||||
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
||||
`${viewBand.toUpperCase()} CONTEXT`,
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
@@ -86,6 +100,15 @@ export function createPresentation({
|
||||
headingEstimated: isHeadingEstimated(camera),
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
// Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js).
|
||||
fovSource: isDatasheetFov(camera)
|
||||
? 'datasheet'
|
||||
: camera.fovSource === 'datasheet'
|
||||
? 'calibrated'
|
||||
: camera.fovSource || 'estimated',
|
||||
specFovMinDeg: camera.specFovMinDeg ?? null,
|
||||
specFovMaxDeg: camera.specFovMaxDeg ?? null,
|
||||
spec: camera.spec || null,
|
||||
rangeM: camera.rangeM,
|
||||
elevationM: camera.absoluteHeightM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
|
||||
@@ -221,8 +221,12 @@ 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);
|
||||
const modeInferred = !transitModeResolved(feed, record.routeId);
|
||||
const mode = transitModeFor(feed, record.routeId, record.id);
|
||||
const modeInferred = !transitModeResolved(
|
||||
feed,
|
||||
record.routeId,
|
||||
record.id,
|
||||
);
|
||||
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
|
||||
const stamped = vehicleReportTimeMs(record, fetchedAt);
|
||||
const reportAt =
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* scene state, no network: everything here is exercised by node:test.
|
||||
*/
|
||||
|
||||
import { TRANSIT_MODE_ICON } from '../../data/transitFeeds.js';
|
||||
import {
|
||||
TRANSIT_MODE_ICON,
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion, playbackPosition } from './movement.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
@@ -337,7 +340,7 @@ export function buildTransitSelectionCopy(
|
||||
) {
|
||||
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
|
||||
const routeLabel = record.routeId
|
||||
? `Route ${record.routeId}`
|
||||
? `Route ${transitRouteLabel(feed, record.routeId)}`
|
||||
: record.label
|
||||
? `Vehicle ${record.label}`
|
||||
: `Vehicle ${record.id}`;
|
||||
@@ -345,7 +348,7 @@ export function buildTransitSelectionCopy(
|
||||
// The mode is said in words on its own line. A glyph and a route id do not
|
||||
// tell a reader that the dot on Tremont Street is a subway train, and a
|
||||
// subway drawn on a street reads as a bug until the card says what it is.
|
||||
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
|
||||
const kindWord = transitModeWord(mode, feed);
|
||||
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
|
||||
const motion = [];
|
||||
// What the SCREEN is doing leads, because that is what the reader can check.
|
||||
@@ -479,17 +482,50 @@ export const TRANSIT_MODE_ABBR = Object.freeze({
|
||||
unknown: 'TRANSIT',
|
||||
});
|
||||
|
||||
/**
|
||||
* The mode in the operator's own vocabulary. A Toronto 501 and a Helsinki 1010
|
||||
* are both the `tram` mode, but only one of them is called a tram by the people
|
||||
* who ride it. A feed may name the modes it runs; everything else falls through
|
||||
* to the shared table, so an override renames one word and nothing else.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {object|null} [feed] Registry entry.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeWord(mode, feed = null) {
|
||||
return (
|
||||
feed?.modeWords?.[mode]?.word ||
|
||||
TRANSIT_MODE_WORD[mode] ||
|
||||
TRANSIT_MODE_WORD.unknown
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's short mode word for the fixed-width detection fields.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {object|null} [feed] Registry entry.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeAbbr(mode, feed = null) {
|
||||
return (
|
||||
feed?.modeWords?.[mode]?.abbr ||
|
||||
TRANSIT_MODE_ABBR[mode] ||
|
||||
TRANSIT_MODE_ABBR.unknown
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bright first line of a vehicle's detection label: its route when the feed
|
||||
* names one, else the fleet number, else the raw id. What a person standing at
|
||||
* the stop would call it.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {object|null} [feed] Registry entry, for feeds whose route ids are
|
||||
* internal keys rather than the number on the vehicle.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionId(record) {
|
||||
export function transitDetectionId(record, feed = null) {
|
||||
const route =
|
||||
typeof record?.routeId === 'string' ? record.routeId.trim() : '';
|
||||
if (route) return elideRouteText(route);
|
||||
if (route) return elideRouteText(transitRouteLabel(feed, route));
|
||||
const label = typeof record?.label === 'string' ? record.label.trim() : '';
|
||||
if (label) return elideRouteText(label);
|
||||
return elideRouteText(String(record?.id || 'TRANSIT'));
|
||||
@@ -521,7 +557,7 @@ export function elideRouteText(text) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionClass(mode, feed) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
const kind = transitModeAbbr(mode, feed);
|
||||
const operator =
|
||||
typeof feed?.name === 'string' ? feed.name.toUpperCase() : '';
|
||||
return operator ? `${kind} ${operator}`.slice(0, 20) : kind;
|
||||
@@ -538,10 +574,17 @@ export function transitDetectionClass(mode, feed) {
|
||||
*
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {number} [nowMs]
|
||||
* @param {object|null} [feed] Registry entry, for its own mode vocabulary.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionMetric(entry, mode, nowMs = Date.now()) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
export function transitDetectionMetric(
|
||||
entry,
|
||||
mode,
|
||||
nowMs = Date.now(),
|
||||
feed = null,
|
||||
) {
|
||||
const kind = transitModeAbbr(mode, feed);
|
||||
const state = transitVehicleState(entry, nowMs);
|
||||
return state ? `${kind} ${state}` : kind;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ import {
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { transitModeTier } from '../../data/transitPresetStyle.js';
|
||||
|
||||
/**
|
||||
* The registry entry behind a vehicle. Live feeds answer first so a feed the
|
||||
* session disabled stops supplying vocabulary; the registry is the fallback,
|
||||
* because a vehicle can outlive the poll that brought it.
|
||||
* @param {object} state Layer state.
|
||||
* @param {object} entry Vehicle entry.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function feedForEntry(state, entry) {
|
||||
const id = entry?.feedId;
|
||||
if (!id) return null;
|
||||
return state._activeFeeds.get(id) || getRegisteredTransitFeed(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer row's answer to "what is on screen and is it honest?".
|
||||
* @param {object} context
|
||||
@@ -101,6 +115,7 @@ export function createQueries({ state, parts }) {
|
||||
contact._entry,
|
||||
contact._entry.mode,
|
||||
now,
|
||||
feedForEntry(state, contact._entry),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -128,14 +143,17 @@ export function createQueries({ state, parts }) {
|
||||
// Route and operator text change with the record and the mode, both of
|
||||
// which arrive on polls — the same events that rebuild this list.
|
||||
if (!entry.labelId || entry.labelRecord !== entry.record) {
|
||||
entry.labelId = transitDetectionId(entry.record);
|
||||
entry.labelId = transitDetectionId(
|
||||
entry.record,
|
||||
feedForEntry(state, entry),
|
||||
);
|
||||
entry.labelRecord = entry.record;
|
||||
}
|
||||
if (!entry.labelClass || entry.labelMode !== entry.mode) {
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
entry.labelClass = transitDetectionClass(entry.mode, feed);
|
||||
entry.labelClass = transitDetectionClass(
|
||||
entry.mode,
|
||||
feedForEntry(state, entry),
|
||||
);
|
||||
entry.labelMode = entry.mode;
|
||||
}
|
||||
const contact = {
|
||||
@@ -146,7 +164,12 @@ export function createQueries({ state, parts }) {
|
||||
id: entry.labelId,
|
||||
type: 'VEH',
|
||||
klass: entry.labelClass,
|
||||
metric: transitDetectionMetric(entry, entry.mode, now),
|
||||
metric: transitDetectionMetric(
|
||||
entry,
|
||||
entry.mode,
|
||||
now,
|
||||
feedForEntry(state, entry),
|
||||
),
|
||||
// The bracket carries the mode colour in every preset: the detection
|
||||
// canvas sits above the post-FX chain, where the sprites' own colour
|
||||
// is lost under NVG and FLIR. Keyless too — the feeds are keyless.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildTransitSelectionCopy,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
transitModeAbbr,
|
||||
transitModeWord,
|
||||
} from './policy.js';
|
||||
import { getTransitFeed } from '../../data/transitFeeds.js';
|
||||
|
||||
const ttc = () => getTransitFeed('ttc-toronto');
|
||||
const hsl = () => getTransitFeed('hsl-helsinki');
|
||||
|
||||
test('an operator that names its own vehicles overrides the shared mode word', () => {
|
||||
// A Toronto 501 is a streetcar. A Helsinki 1010 is a tram. Both are the
|
||||
// `tram` mode, so the word a reader sees has to come from the operator,
|
||||
// not from the mode.
|
||||
assert.equal(transitModeWord('tram', ttc()), 'Streetcar');
|
||||
assert.equal(transitModeAbbr('tram', ttc()), 'STREETCAR');
|
||||
assert.equal(transitModeWord('tram', hsl()), 'Tram');
|
||||
assert.equal(transitModeAbbr('tram', hsl()), 'TRAM');
|
||||
});
|
||||
|
||||
test('an override renames one mode, never the rest of the feed', () => {
|
||||
assert.equal(transitModeWord('bus', ttc()), 'Bus');
|
||||
assert.equal(transitModeWord('subway', ttc()), 'Subway');
|
||||
assert.equal(transitModeAbbr('bus', ttc()), 'BUS');
|
||||
});
|
||||
|
||||
test('a missing feed or unknown mode falls back to the shared table', () => {
|
||||
assert.equal(transitModeWord('tram', null), 'Tram');
|
||||
assert.equal(transitModeWord('spaceship', ttc()), 'Transit vehicle');
|
||||
assert.equal(transitModeAbbr('spaceship', ttc()), 'TRANSIT');
|
||||
assert.equal(transitModeWord('tram', { name: 'No override' }), 'Tram');
|
||||
});
|
||||
|
||||
test('the selection card calls a Toronto 501 a streetcar', () => {
|
||||
const record = { id: '4400', routeId: '501', timestamp: 0 };
|
||||
const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null);
|
||||
assert.match(copy.details.join(' | '), /Streetcar · TTC/);
|
||||
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
|
||||
});
|
||||
|
||||
test('detection labels carry the operator vocabulary within the field width', () => {
|
||||
const klass = transitDetectionClass('tram', ttc());
|
||||
assert.equal(klass, 'STREETCAR TTC');
|
||||
assert.ok(klass.length <= 20, 'fits the detection class field');
|
||||
assert.equal(transitDetectionClass('tram', hsl()), 'TRAM HSL');
|
||||
const metric = transitDetectionMetric({ mode: 'tram' }, 'tram', 0, ttc());
|
||||
assert.match(metric, /^STREETCAR/);
|
||||
});
|
||||
|
||||
test('a Guelph vehicle is labelled with the number on the bus, not the database key', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
const record = { id: '196', routeId: '2991', timestamp: 0 };
|
||||
const copy = buildTransitSelectionCopy(guelph, record, 'bus', 0, null, null);
|
||||
assert.match(copy.title, /Route 1\b/);
|
||||
assert.doesNotMatch(copy.title, /2991/);
|
||||
assert.equal(transitDetectionId(record, guelph), '1');
|
||||
});
|
||||
|
||||
test('a feed that already speaks rider route numbers is untouched', () => {
|
||||
const record = { id: '4400', routeId: '501', timestamp: 0 };
|
||||
assert.equal(transitDetectionId(record, ttc()), '501');
|
||||
const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null);
|
||||
assert.match(copy.title, /Route 501/);
|
||||
// No feed at all still renders the raw route rather than throwing.
|
||||
assert.equal(transitDetectionId(record), '501');
|
||||
});
|
||||
|
||||
test('a vehicle with no route still falls back to its fleet number', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
assert.equal(transitDetectionId({ id: '196', label: '196' }, guelph), '196');
|
||||
});
|
||||
@@ -328,8 +328,12 @@ export function createTransitHistory({
|
||||
t > now + 10000
|
||||
)
|
||||
continue;
|
||||
const byVehicle = feed.vehicleMode?.(record.id);
|
||||
const mode =
|
||||
feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown';
|
||||
(byVehicle !== 'unknown' && byVehicle) ||
|
||||
feed.routeMode?.(record.routeId) ||
|
||||
feed.defaultMode ||
|
||||
'unknown';
|
||||
const context = contextOf(record, mode);
|
||||
if (!context) continue;
|
||||
const key = `${feed.id}\u0000${record.id}`;
|
||||
|
||||
Reference in new issue
Block a user