Merge branch 'main' into upstream/nominatim-cache-mode-windows

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-11-05 10:23:39 -08:00
commit f717f8a0d7
28 files changed
+1053 -11

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/**
* @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_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.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]);
/**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+1
View File
@@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
stats.count ?? null,
stats.status || null,
stats.error || null,
stats.source || null,
];
})
.map((parts) => parts.join(':'))
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createModel } from './model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { layerFeedState } from '../../data/feedState.js';
import aisLiveVesselsLayer, {
_setVesselStateForTest,
_beginAisSessionForTest,
_applyAisFeedSnapshotForTest,
_setAisRuntimeForTest,
} from '../../data/aisLiveVessels.js';
test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => {
globalThis.document ??= { body: null };
const services = {
installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: { getNearby: () => [] },
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
let states;
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
const realQueries = createQueries({ state: layerState, services, parts });
parts.queries = { ...realQueries, collectSourceStates: () => states };
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const flights = (lastUpdate) => ({
available: true, enabled: true, stale: false,
stats: { source: 'OpenSky Network', count: 4, lastUpdate },
});
const vessels = (loading) => ({
available: true, enabled: true, stale: true,
stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading },
});
const rest = {
military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } },
'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
// 1. Stale AIS cache, nothing loading.
states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject(true);
assert.equal(ais().sourceStatus, 'stale');
// 2. AIS re-poll in flight while the flights poll lands (revision changes).
states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest };
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale');
// 3. AIS poll returns the same stale snapshot; only loading flips back.
states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too');
});
function fakeRuntime() {
return {
now: () => 1000,
setTimeout: () => ({}),
clearTimeout() {},
};
}
test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => {
globalThis.document ??= { body: null };
_setAisRuntimeForTest(fakeRuntime());
_setVesselStateForTest({ viewer: {}, records: [] });
try {
_beginAisSessionForTest();
// What server/providers/vessels/ais-live.js answers while its socket connects.
_applyAisFeedSnapshotForTest({}, {
rows: [], source: 'AISStream', status: 'connecting', refreshing: true,
});
const vesselStats = aisLiveVesselsLayer.getStats();
assert.equal(vesselStats.loading, true);
assert.equal(vesselStats.stale, true);
assert.equal(vesselStats.lastUpdate, null);
assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING');
const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 };
const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 };
const installStats = { coverage: { kind: 'subject', radiusM: 100000 } };
const statsById = {
flights: flightsStats,
military: militaryStats,
'ais-live-vessels': vesselStats,
'military-installations': installStats,
};
const services = {
installations: { getNearby: () => [], getStats: () => installStats },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: aisLiveVesselsLayer,
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
dataManager: {
isEnabled: () => true,
getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }),
layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])),
},
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
parts.queries = createQueries({ state: layerState, services, parts });
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels'];
assert.equal(vesselsSource.available, false, 'never-answered source is unavailable');
subject.refreshSelectedSubject(true);
const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING');
} finally {
_setVesselStateForTest({ enabled: false });
_setAisRuntimeForTest();
}
});
+17
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,18 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// A poll in flight keeps FALLBACK or STALE: the revision key ignores loading.
// Before the first answer there is no snapshot, so loading stays.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(
sourceState.available
? { ...sourceState.stats, loading: false }
: sourceState.stats,
);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +107,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +127,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +147,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +165,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+6 -1
View File
@@ -12,6 +12,11 @@ import {
} from '../../data/transitFeeds.js';
import { transitModeTier } from '../../data/transitPresetStyle.js';
/** Regions, not feeds: one region can carry several feeds (Rio has two). */
const TRANSIT_REGION_COUNT = new Set(
TRANSIT_ENABLED_FEEDS.map((feed) => feed.region),
).size;
/**
* The registry entry behind a vehicle. Live feeds answer first so a feed the
* session disabled stops supplying vocabulary; the registry is the fallback,
@@ -249,7 +254,7 @@ export function createQueries({ state, parts }) {
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createQueries } from './queries.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
test('the empty-coverage note counts regions, not feeds', () => {
const rio = TRANSIT_ENABLED_FEEDS.filter(
(feed) => feed.region === 'Rio de Janeiro, RJ',
);
assert.equal(rio.length, 2, 'Rio is one region with two feeds');
const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region));
assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length);
const stats = createQueries({
state: {
_activeFeeds: new Map(),
_vehicles: new Map(),
_enabled: true,
_altitudeGateOpen: true,
},
parts: {},
}).methods.getStats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.coverage,
`No feed here yet · ${regions.size} regions available`,
);
});
+12
View File
@@ -409,3 +409,15 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// Same field as flights and military, so Contacts names the provider.
source: layer.source,
};
},
};
+2
View File
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { sourceStatusLabel } from './layerNames.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -110,6 +111,7 @@ export function updateContext(info, heading) {
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus);
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
+44
View File
@@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
@@ -159,6 +166,43 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) =>
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
+4 -1
View File
@@ -25,7 +25,10 @@ export function createContextState() {
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
COCKPIT_CONTEXT_COHORTS.map((id) => [
id,
{ count: '—', unknown: false, source: '—' },
]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
+14
View File
@@ -141,3 +141,17 @@ export function sourceLabel(source) {
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* A provider name with the feed state it answered in, so a fallback or stale
* snapshot does not read as the live primary feed.
* @param {string} source English source name.
* @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`.
* @returns {string}
*/
export function sourceStatusLabel(source, status) {
const name = sourceLabel(source);
if (status === 'fallback') return `${name} · ${t('FALLBACK')}`;
if (status === 'stale') return `${name} · ${t('STALE')}`;
return name;
}
+17
View File
@@ -7,6 +7,7 @@ import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
sourceStatusLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
@@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a provider name carries the fallback or stale state it answered in', () => {
assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder');
assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK');
assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE');
setLocale('ru');
try {
assert.equal(
sourceStatusLabel('adsb.lol', 'fallback'),
`adsb.lol · ${t('FALLBACK')}`,
);
assert.notEqual(t('FALLBACK'), 'FALLBACK');
} finally {
setLocale('en');
}
});
+4 -4
View File
@@ -49,10 +49,10 @@ const weatherLabel = computed(() =>
<span class="cockpit-context-qualifier">{{ t('CONTEXT ONLY') }}</span>
</header>
<div class="cockpit-context-cohorts" role="list" :aria-label="t('Nearby cohort counts')">
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no" :title="c.cohorts.flights.source">{{ c.cohorts.flights.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no" :title="c.cohorts.military.source">{{ c.cohorts.military.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no" :title="c.cohorts['ais-live-vessels'].source">{{ c.cohorts['ais-live-vessels'].source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no" :title="c.cohorts['military-installations'].source">{{ c.cohorts['military-installations'].source }}</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>