mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge branch 'main' into german/map-render-error-state
This commit is contained in:
commit
d42b6fe764
329 files changed
+47020
-1080
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { NEWS_LAYER_ID } from '../layers/news/model.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
@@ -18,6 +19,7 @@ import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationRecentImagery } from './layers/recentImagery.js';
|
||||
import { createApplicationOsmPlaces } from './layers/osmPlaces.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationRail } from './layers/rail.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||
@@ -68,6 +70,7 @@ export function createApplicationCatalog({
|
||||
signal,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
newsOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
@@ -142,7 +145,13 @@ export function createApplicationCatalog({
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationNews({ source: sources.news }),
|
||||
// Ages, the window and the counts read the world clock: a host that
|
||||
// follows it for the layer shows the news at the time on the timeline.
|
||||
createApplicationNews({
|
||||
now: worldClock.timeFor(NEWS_LAYER_ID),
|
||||
...newsOptions,
|
||||
source: sources.news,
|
||||
}),
|
||||
createApplicationWildlifeSightings({
|
||||
source: sources['wildlife-sightings'],
|
||||
groundFloor: surface.groundFloor,
|
||||
@@ -191,6 +200,7 @@ export function createApplicationCatalog({
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationRail({ surface, source: sources.rail }),
|
||||
createApplicationFirms({
|
||||
surface,
|
||||
id: 'local-firms',
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 40);
|
||||
assert.equal(first.layers.length, 41);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -162,7 +162,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 40);
|
||||
assert.equal(catalog.layers.length, 41);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
Vendored
+2
-1
@@ -9,7 +9,8 @@ import { t } from '../i18n/index.js';
|
||||
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that does not initialize Core's locale.
|
||||
* picker, for a host that offers its own language choice (`switchLocale` from
|
||||
* the `./i18n` entry).
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
|
||||
@@ -18,6 +18,7 @@ export class LayerPresentation {
|
||||
this.pendingVisible = false;
|
||||
this._unsubscribe = manager.subscribeActivity((change) => {
|
||||
if (change.type === 'status') this.refresh();
|
||||
else if (change.type === 'registrations') this._panel?.rerender();
|
||||
else if (change.type === 'destroy-all') this.destroy();
|
||||
else {
|
||||
const reason =
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRailLayer } from '../../layers/rail/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { applyTrackedCameraFrame } from '../../data/trackedCamera.js';
|
||||
import { WORLD_FOCUS_FRAMING, requestWorldFocus } from '../../worldFocus.js';
|
||||
import {
|
||||
railFeedCredit,
|
||||
registerDynamicCredit,
|
||||
} from '../../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* Construct the Rail layer on the application's scene owners and its shared
|
||||
* ground floor, the same floor Transit and the aircraft stand on.
|
||||
* @param {{surface: object, source?: object}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createApplicationRail({ surface, source }) {
|
||||
if (!surface?.groundFloor)
|
||||
throw new TypeError('Rail needs the application surface services');
|
||||
const { groundFloor } = surface;
|
||||
return createRailLayer({
|
||||
source,
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
credits: { registerDynamicCredit, railFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
||||
cachedGroundFloor: groundFloor.cachedGroundFloor,
|
||||
warmGroundFloor: groundFloor.warmGroundFloor,
|
||||
neighborFloorM: groundFloor.neighborFloorM,
|
||||
coarseFloorCoord: groundFloor.coarseFloorCoord,
|
||||
},
|
||||
// A selected train is followed as a tracked aircraft is, or flown to
|
||||
// once as a vessel is (src/layers/rail/follow.js).
|
||||
camera: {
|
||||
framing: WORLD_FOCUS_FRAMING.train,
|
||||
requestWorldFocus,
|
||||
applyTrackedCameraFrame,
|
||||
isCockpitActive: () =>
|
||||
globalThis.document?.body?.classList?.contains('cockpit-mode') ===
|
||||
true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
rail: contract(['rail'], ['requestSnapshot', 'getFeeds']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 40);
|
||||
assert.equal(actual.layers.length, 41);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
@@ -247,3 +247,18 @@ test('absent military acquisition does not poll, suppress flights, or erase supp
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
test('a rail source must carry the feed catalog that keeps unbuilt packs out', () => {
|
||||
// Without getFeeds the layer could never learn which schedule packs are
|
||||
// built, so the live feeds that read their routes from one would never run.
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveCatalogSources({ rail: { requestSnapshot: async () => null } }),
|
||||
/Invalid catalog source: rail/,
|
||||
);
|
||||
const source = {
|
||||
requestSnapshot: async () => null,
|
||||
getFeeds: async () => [],
|
||||
};
|
||||
assert.equal(resolveCatalogSources({ rail: source }).sources.rail, source);
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { createDataLibrary } from '../library/controller.js';
|
||||
import { flyToLibraryExtent } from '../library/camera.js';
|
||||
import { createLibraryBrowser } from '../ui/libraryBrowser.js';
|
||||
import { useDataLibraryStore } from '../ui/stores/dataLibrary.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import {
|
||||
@@ -109,6 +114,51 @@ export function createApplicationTools({
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => geoFileImport?.destroy());
|
||||
// DATA ▸ Library: entries register their layers only when a viewer adds
|
||||
// them, after the core catalog is sealed. The browser dialog and the
|
||||
// controller share the application lifetime.
|
||||
const dataLibrary = createDataLibrary({
|
||||
dataManager,
|
||||
services: localGeoJsonServices,
|
||||
getTileset: () => tileset,
|
||||
mapStackController,
|
||||
shareLink: styleManager.shareLinkManager,
|
||||
requestRender: governorRequestRender,
|
||||
// Zoom to layer: an immediate navigation, so Cockpit can refuse it and
|
||||
// the current follow owner is released first (as for imported files).
|
||||
zoomTo: (extent) =>
|
||||
styleManager.runImmediateNavigation('layer', () =>
|
||||
flyToLibraryExtent(viewer, extent),
|
||||
) !== false,
|
||||
});
|
||||
defer(() => dataLibrary.destroy());
|
||||
// Browse: the owner presents the dialog through the store; LayerPanelsView
|
||||
// draws the Browse button and the dialog from it.
|
||||
const libraryStore = useDataLibraryStore(applicationPinia());
|
||||
const libraryBrowser = createLibraryBrowser({
|
||||
library: dataLibrary,
|
||||
publish: (view) => libraryStore.publish(view),
|
||||
});
|
||||
libraryStore.connect(libraryBrowser.controls);
|
||||
defer(() => {
|
||||
libraryBrowser.destroy();
|
||||
libraryStore.connect(null);
|
||||
});
|
||||
dataLibrary.start({ signal }).catch((error) => {
|
||||
if (!signal?.aborted) console.warn('[Library] start failed:', error);
|
||||
});
|
||||
// The live gate's handle (scripts/qa-data-library.mjs).
|
||||
const libraryHandle = {
|
||||
library: dataLibrary,
|
||||
browser: libraryBrowser,
|
||||
// What Copy Link would copy.
|
||||
buildShareLink: () => styleManager.shareLinkManager?.buildLink?.() ?? null,
|
||||
};
|
||||
window.__gevDataLibrary = libraryHandle;
|
||||
defer(() => {
|
||||
if (window.__gevDataLibrary === libraryHandle)
|
||||
delete window.__gevDataLibrary;
|
||||
});
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
// panel lives on the right rail, so both belong to the application
|
||||
// lifetime. The tileset lets the layer drape while the globe is hidden.
|
||||
|
||||
@@ -29,6 +29,7 @@ export const writeCalibrationStoreV2 = layer.writeCalibrationStoreV2;
|
||||
export const deriveCalBadge = layer.deriveCalBadge;
|
||||
export const computeFrustumGeometry = layer.computeFrustumGeometry;
|
||||
export const activationProbeClampRange = layer.activationProbeClampRange;
|
||||
export const flatGroundAxisDistance = layer.flatGroundAxisDistance;
|
||||
export const frameSignatureFromPixels = layer.frameSignatureFromPixels;
|
||||
export const _createCctvProjectionPlaneForTest =
|
||||
layer._createCctvProjectionPlaneForTest;
|
||||
|
||||
@@ -35,6 +35,7 @@ import cctvLayer, {
|
||||
_pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest,
|
||||
activationProbeClampRange,
|
||||
flatGroundAxisDistance,
|
||||
bindCctvWorldClickGesture,
|
||||
clearProbeClampOnDeactivation,
|
||||
computeFrustumGeometry,
|
||||
@@ -655,6 +656,17 @@ test('activation obstruction clamp uses the field-derived 12 m floor', () => {
|
||||
assert.equal(activationProbeClampRange(210, 250), null);
|
||||
});
|
||||
|
||||
test('on a globe map the axis ends on flat ground at the mount ground', () => {
|
||||
// A 24 m mount pitched 30° down meets the ground 48 m along its axis.
|
||||
assert.ok(Math.abs(flatGroundAxisDistance(24, -30) - 48) < 1e-9);
|
||||
// The default 700 m range then clamps just short of that, not 700 m out.
|
||||
const clamp = activationProbeClampRange(700, flatGroundAxisDistance(24, -30));
|
||||
assert.ok(Math.abs(clamp - 44) < 1e-9);
|
||||
assert.equal(flatGroundAxisDistance(24, 0), null);
|
||||
assert.equal(flatGroundAxisDistance(24, 10), null);
|
||||
assert.equal(flatGroundAxisDistance(0, -30), null);
|
||||
});
|
||||
|
||||
test('deactivation clears the probe clamp and restores nominal frustum geometry', () => {
|
||||
const record = {
|
||||
camera: { ...UNCLAMPED_CAMERA },
|
||||
|
||||
@@ -141,6 +141,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
|
||||
'· each photo is © its photographer, credited beside it',
|
||||
},
|
||||
{
|
||||
key: 'openvesselphotos',
|
||||
html:
|
||||
'Vessel photos: ' +
|
||||
'<a href="https://openvesselphotos.org" target="_blank" rel="noopener">Open Vessel Photos</a> ' +
|
||||
"· each photo keeps its author's open licence, credited beside it",
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -503,6 +510,10 @@ export const DATA_CREDITS = [
|
||||
key: 'gtfs-rt',
|
||||
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'rail-gtfs-rt',
|
||||
html: 'Trains: operator realtime feeds (each operator is credited below when its trains are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
html:
|
||||
@@ -759,6 +770,20 @@ export function transitFeedCredit(feed, snapshot = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-feed rail credit, registered the first time that feed's trains render
|
||||
* (see `src/data/railFeeds.js` for the licence of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function railFeedCredit(feed) {
|
||||
const transit = transitFeedCredit(feed);
|
||||
return {
|
||||
key: `rail-${feed.id}`,
|
||||
html: transit.html.replace(/^Transit \(/, 'Trains ('),
|
||||
};
|
||||
}
|
||||
|
||||
/** Registered when the Bhote Koshi event reconstruction activates. */
|
||||
export const BHOTE_KOSHI_CREDIT = {
|
||||
key: 'bhote-koshi-2026',
|
||||
|
||||
@@ -51,6 +51,13 @@ test('Planespotters is credited with a plain followable link', () => {
|
||||
assert.doesNotMatch(credit.html, /nofollow/);
|
||||
});
|
||||
|
||||
test('Open Vessel Photos is credited with a plain followable link', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'openvesselphotos');
|
||||
assert.ok(credit, 'the selected vessel photos come from Open Vessel Photos');
|
||||
assert.match(credit.html, /href="https:\/\/openvesselphotos\.org"/);
|
||||
assert.doesNotMatch(credit.html, /nofollow/);
|
||||
});
|
||||
|
||||
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||
// UCS database manual, section 3: references name the version (the Excel
|
||||
// file's name) and link www.ucsusa.org/satellite_database.
|
||||
@@ -244,3 +251,12 @@ test('Warsaw and Poznań credits carry the data date their licences ask for', ()
|
||||
assert.equal(mbta.key, 'transit-mbta');
|
||||
assert.doesNotMatch(mbta.html, /data of/);
|
||||
});
|
||||
|
||||
test('the always-on rail credit names live feeds only, never timetables', () => {
|
||||
// Every train shown is where its operator reported it; none is placed
|
||||
// from a timetable.
|
||||
const rail = DATA_CREDITS.find((entry) => entry.key === 'rail-gtfs-rt');
|
||||
assert.ok(rail);
|
||||
assert.match(rail.html, /realtime feeds/);
|
||||
assert.doesNotMatch(rail.html, /timetable|placed from/i);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @module irishRail
|
||||
* @description Adapter for the Irish Rail realtime API's running-trains list
|
||||
* (`getCurrentTrainsXML`), turning it into the train records the GTFS-RT path
|
||||
* produces.
|
||||
*
|
||||
* The document is a flat list of `<objTrainPositions>` elements, each with
|
||||
* seven text children (TrainStatus, TrainLatitude, TrainLongitude, TrainCode,
|
||||
* TrainDate, PublicMessage, Direction). That schema is all this reads, so it
|
||||
* is read with a small scanner instead of an XML library: no DTDs, no entity
|
||||
* definitions, no attributes, no nesting beyond those two levels. A document
|
||||
* that declares a DTD or entities is refused, and every size is capped (whole
|
||||
* text, train count, field length).
|
||||
*
|
||||
* Positions are NOT GPS: the operator places each train at the station or
|
||||
* timing point its signalling system last reported. The record says so with
|
||||
* `positionSource: 'event'` (added by the rail proxy). The feed carries no
|
||||
* time for that report, so a record's time is the fetch time, labelled
|
||||
* `timestampSource: 'fetch'`, which the layer ages conservatively.
|
||||
*
|
||||
* Pure: no Node built-ins, safe for node:test and any server runtime.
|
||||
*/
|
||||
|
||||
import { isPlausibleVehiclePosition } from './gtfsRealtime.js';
|
||||
|
||||
/** Longest document read (characters). A full day's list is about 35 KB. */
|
||||
export const IRISH_RAIL_MAX_TEXT_CHARS = 1024 * 1024;
|
||||
/** Most trains read from one document; about 80 run at once. */
|
||||
export const IRISH_RAIL_MAX_TRAINS = 2000;
|
||||
/** Longest accepted field (characters); the public message is the longest. */
|
||||
export const IRISH_RAIL_MAX_FIELD_CHARS = 512;
|
||||
|
||||
const FIELDS = Object.freeze([
|
||||
'TrainStatus',
|
||||
'TrainLatitude',
|
||||
'TrainLongitude',
|
||||
'TrainCode',
|
||||
'TrainDate',
|
||||
'PublicMessage',
|
||||
'Direction',
|
||||
]);
|
||||
|
||||
/** Error for a document this adapter will not read. */
|
||||
export class IrishRailFormatError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'IrishRailFormatError';
|
||||
}
|
||||
}
|
||||
|
||||
const NAMED_ENTITIES = Object.freeze({
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
});
|
||||
|
||||
/**
|
||||
* Decode the five predefined XML entities and numeric character references.
|
||||
* An unknown entity is left as written (no DTD can define one here).
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decodeXmlText(text) {
|
||||
return String(text).replace(
|
||||
/&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]{2,4});/g,
|
||||
(whole, body) => {
|
||||
if (body[0] === '#') {
|
||||
const code =
|
||||
body[1] === 'x' || body[1] === 'X'
|
||||
? Number.parseInt(body.slice(2), 16)
|
||||
: Number.parseInt(body.slice(1), 10);
|
||||
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
|
||||
? String.fromCodePoint(code)
|
||||
: whole;
|
||||
}
|
||||
return Object.hasOwn(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of `<name>...</name>` inside one train element, `''` for an empty
|
||||
* or self-closing element, null when absent or over the field cap.
|
||||
*/
|
||||
function readField(block, name) {
|
||||
const open = `<${name}>`;
|
||||
const start = block.indexOf(open);
|
||||
if (start < 0) {
|
||||
return block.includes(`<${name}/>`) || block.includes(`<${name} />`)
|
||||
? ''
|
||||
: null;
|
||||
}
|
||||
const from = start + open.length;
|
||||
const end = block.indexOf(`</${name}>`, from);
|
||||
if (end < 0 || end - from > IRISH_RAIL_MAX_FIELD_CHARS * 6) return null;
|
||||
const value = decodeXmlText(block.slice(from, end)).trim();
|
||||
return value.length <= IRISH_RAIL_MAX_FIELD_CHARS ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the running-trains document into raw train rows.
|
||||
* @param {string} text
|
||||
* @returns {{rows: Array<Record<string, string|null>>, truncated: boolean}}
|
||||
*/
|
||||
export function parseIrishRailTrainsXml(text) {
|
||||
if (typeof text !== 'string') throw new IrishRailFormatError('not text');
|
||||
if (text.length > IRISH_RAIL_MAX_TEXT_CHARS)
|
||||
throw new IrishRailFormatError(`document is ${text.length} characters`);
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(text))
|
||||
throw new IrishRailFormatError('document declares a DTD');
|
||||
if (!text.includes('<ArrayOfObjTrainPositions'))
|
||||
throw new IrishRailFormatError('not a running-trains document');
|
||||
const rows = [];
|
||||
let truncated = false;
|
||||
const OPEN = '<objTrainPositions>';
|
||||
const CLOSE = '</objTrainPositions>';
|
||||
let at = text.indexOf(OPEN);
|
||||
while (at >= 0) {
|
||||
const end = text.indexOf(CLOSE, at + OPEN.length);
|
||||
if (end < 0) break;
|
||||
if (rows.length >= IRISH_RAIL_MAX_TRAINS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const block = text.slice(at + OPEN.length, end);
|
||||
const row = {};
|
||||
for (const name of FIELDS) row[name] = readField(block, name);
|
||||
rows.push(row);
|
||||
at = text.indexOf(OPEN, end + CLOSE.length);
|
||||
}
|
||||
return { rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the three lines of a public message. The operator separates lines
|
||||
* with a literal backslash-n; a real newline is accepted too.
|
||||
*
|
||||
* A118\n14:50 - Dublin Connolly to Belfast (0 mins late)\nDeparted Dundalk next stop Newry
|
||||
* E244\nHowth to Bray\nExpected Departure 16:35
|
||||
* A311\n15:05 - Tralee to Mallow(-2 mins late)\nTERMINATED Mallow at 16:34
|
||||
*
|
||||
* @param {string|null} message
|
||||
* @returns {{origin: string|null, destination: string|null,
|
||||
* scheduledDeparture: string|null, delaySec: number|null,
|
||||
* event: 'departed'|'arrived'|'expected'|'terminated'|null,
|
||||
* lastStop: string|null, nextStop: string|null}}
|
||||
*/
|
||||
export function parsePublicMessage(message) {
|
||||
const result = {
|
||||
origin: null,
|
||||
destination: null,
|
||||
scheduledDeparture: null,
|
||||
delaySec: null,
|
||||
event: null,
|
||||
lastStop: null,
|
||||
nextStop: null,
|
||||
};
|
||||
const lines = String(message ?? '')
|
||||
.split(/\\n|\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const route =
|
||||
/^(?:(\d{1,2}:\d{2})\s*-\s*)?(.+?)\s+to\s+(.+?)(?:\s*\((-?\d{1,4})\s*mins?\s+late\))?$/i.exec(
|
||||
lines[1] || '',
|
||||
);
|
||||
if (route) {
|
||||
result.scheduledDeparture = route[1] || null;
|
||||
result.origin = route[2].trim();
|
||||
result.destination = route[3].trim();
|
||||
if (route[4] !== undefined) result.delaySec = Number(route[4]) * 60;
|
||||
}
|
||||
const event = lines[2] || '';
|
||||
let match;
|
||||
if (
|
||||
(match = /^(Departed|Arrived)\s+(.+?)\s+next stop\s+(.+)$/i.exec(event))
|
||||
) {
|
||||
result.event =
|
||||
match[1].toLowerCase() === 'departed' ? 'departed' : 'arrived';
|
||||
result.lastStop = match[2].trim();
|
||||
result.nextStop = match[3].trim();
|
||||
} else if (/^Expected Departure\b/i.test(event)) {
|
||||
result.event = 'expected';
|
||||
} else if (
|
||||
(match = /^TERMINATED\s+(.+?)\s+at\s+\d{1,2}:\d{2}/i.exec(event))
|
||||
) {
|
||||
result.event = 'terminated';
|
||||
result.lastStop = match[1].trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the running-trains document into unclassified train records in the
|
||||
* shape the GTFS-RT path produces (src/data/gtfsRealtime.js), plus the
|
||||
* delay the public message states (`delaySec`). The message's last and next
|
||||
* stop and the row's direction only decide `status`; nothing reads them
|
||||
* further, so they do not leave the server.
|
||||
*
|
||||
* Status `T` (terminated) trains are dropped: they are out of service, parked
|
||||
* at the end of their run. Status `N` trains are due to start within ten
|
||||
* minutes and stand at their origin (`STOPPED_AT`). A running train is
|
||||
* `STOPPED_AT` after "Arrived" and `IN_TRANSIT_TO` its next stop after
|
||||
* "Departed".
|
||||
*
|
||||
* @param {string} text The XML document.
|
||||
* @param {number} fetchedAtS Fetch time (epoch seconds), the only time the
|
||||
* feed lets a record carry.
|
||||
* @returns {{entityCount: number, truncated: boolean, vehicles: object[]}}
|
||||
*/
|
||||
export function decodeIrishRailTrains(text, fetchedAtS) {
|
||||
const { rows, truncated } = parseIrishRailTrainsXml(text);
|
||||
const seen = new Set();
|
||||
const vehicles = [];
|
||||
for (const row of rows) {
|
||||
const code = row.TrainCode;
|
||||
if (!code || !/^[A-Z0-9]{1,8}$/.test(code) || seen.has(code)) continue;
|
||||
const status = row.TrainStatus;
|
||||
if (status !== 'R' && status !== 'N') continue;
|
||||
const lat = Number.parseFloat(row.TrainLatitude ?? '');
|
||||
const lon = Number.parseFloat(row.TrainLongitude ?? '');
|
||||
if (!isPlausibleVehiclePosition(lat, lon)) continue;
|
||||
seen.add(code);
|
||||
const message = parsePublicMessage(row.PublicMessage);
|
||||
const routeId =
|
||||
message.origin && message.destination
|
||||
? `${message.origin} - ${message.destination}`
|
||||
: null;
|
||||
vehicles.push({
|
||||
id: code,
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lon: Number(lon.toFixed(6)),
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: fetchedAtS,
|
||||
timestampSource: 'fetch',
|
||||
routeId,
|
||||
tripId: code,
|
||||
directionId: null,
|
||||
label: routeId
|
||||
? `${code} ${message.origin} to ${message.destination}`
|
||||
: code,
|
||||
stopId: null,
|
||||
status:
|
||||
// i18n-ignore: GTFS-Realtime status codes, not UI text
|
||||
status === 'N' || message.event === 'arrived'
|
||||
? 'STOPPED_AT'
|
||||
: message.event === 'departed'
|
||||
? 'IN_TRANSIT_TO'
|
||||
: null,
|
||||
occupancy: null,
|
||||
delaySec: message.delaySec,
|
||||
});
|
||||
}
|
||||
return { entityCount: rows.length, truncated, vehicles };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
IRISH_RAIL_MAX_TEXT_CHARS,
|
||||
IRISH_RAIL_MAX_TRAINS,
|
||||
IrishRailFormatError,
|
||||
decodeIrishRailTrains,
|
||||
decodeXmlText,
|
||||
parseIrishRailTrainsXml,
|
||||
parsePublicMessage,
|
||||
} from './irishRail.js';
|
||||
|
||||
/** One `<objTrainPositions>` element, written by hand for these tests. */
|
||||
function train({
|
||||
status = 'R',
|
||||
lat = '53.3531',
|
||||
lon = '-6.24591',
|
||||
code = 'A118',
|
||||
date = '06 Nov 2026',
|
||||
message = 'A118\\n14:50 - Dublin Connolly to Belfast (3 mins late)\\nDeparted Dundalk next stop Newry',
|
||||
direction = 'Northbound',
|
||||
} = {}) {
|
||||
return ` <objTrainPositions>
|
||||
<TrainStatus>${status}</TrainStatus>
|
||||
<TrainLatitude>${lat}</TrainLatitude>
|
||||
<TrainLongitude>${lon}</TrainLongitude>
|
||||
<TrainCode>${code}</TrainCode>
|
||||
<TrainDate>${date}</TrainDate>
|
||||
<PublicMessage>${message}</PublicMessage>
|
||||
<Direction>${direction}</Direction>
|
||||
</objTrainPositions>`;
|
||||
}
|
||||
|
||||
function document(...trains) {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://api.irishrail.ie/realtime/">
|
||||
${trains.join('\n')}
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
}
|
||||
|
||||
const FETCHED_S = 1_794_000_000;
|
||||
|
||||
test('a running-trains document becomes GTFS-shaped train records', () => {
|
||||
const { vehicles, entityCount, truncated } = decodeIrishRailTrains(
|
||||
document(
|
||||
train(),
|
||||
train({
|
||||
code: 'E244',
|
||||
status: 'N',
|
||||
message: 'E244\\nHowth to Bray\\nExpected Departure 16:35',
|
||||
direction: 'Southbound',
|
||||
}),
|
||||
train({
|
||||
code: 'D825',
|
||||
message:
|
||||
'D825\\n16:40 - Dun Laoghaire to Dundalk (0 mins late)\\nArrived Dun Laoghaire next stop Blackrock',
|
||||
}),
|
||||
),
|
||||
FETCHED_S,
|
||||
);
|
||||
assert.equal(entityCount, 3);
|
||||
assert.equal(truncated, false);
|
||||
assert.equal(vehicles.length, 3);
|
||||
const [enterprise, dart, commuter] = vehicles;
|
||||
assert.deepEqual(enterprise, {
|
||||
id: 'A118',
|
||||
lat: 53.3531,
|
||||
lon: -6.24591,
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: FETCHED_S,
|
||||
timestampSource: 'fetch',
|
||||
routeId: 'Dublin Connolly - Belfast',
|
||||
tripId: 'A118',
|
||||
directionId: null,
|
||||
label: 'A118 Dublin Connolly to Belfast',
|
||||
stopId: null,
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: null,
|
||||
delaySec: 180,
|
||||
});
|
||||
// The message's stops and the row's direction decide the status only.
|
||||
for (const field of ['serviceState', 'lastStop', 'nextStop', 'direction'])
|
||||
assert.equal(field in dart, false, field);
|
||||
assert.equal(dart.status, 'STOPPED_AT');
|
||||
assert.equal(dart.routeId, 'Howth - Bray');
|
||||
assert.equal(dart.delaySec, null);
|
||||
assert.equal(commuter.status, 'STOPPED_AT');
|
||||
assert.equal(commuter.delaySec, 0);
|
||||
});
|
||||
|
||||
test('terminated, unplaced, malformed and repeated trains are dropped', () => {
|
||||
const { vehicles, entityCount } = decodeIrishRailTrains(
|
||||
document(
|
||||
train({
|
||||
code: 'A311',
|
||||
status: 'T',
|
||||
message:
|
||||
'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34',
|
||||
}),
|
||||
train({ code: 'A1', lat: '0', lon: '0' }),
|
||||
train({ code: 'A2', lat: 'north', lon: '-6.2' }),
|
||||
train({ code: 'A3', status: 'X' }),
|
||||
train({ code: 'bad code!' }),
|
||||
train({ code: 'A4' }),
|
||||
train({ code: 'A4', lat: '53.0' }),
|
||||
),
|
||||
FETCHED_S,
|
||||
);
|
||||
assert.equal(entityCount, 7);
|
||||
assert.deepEqual(
|
||||
vehicles.map((vehicle) => [vehicle.id, vehicle.lat]),
|
||||
[['A4', 53.3531]],
|
||||
);
|
||||
});
|
||||
|
||||
test('public messages: the three line shapes, with and without spacing', () => {
|
||||
assert.deepEqual(
|
||||
parsePublicMessage(
|
||||
'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34',
|
||||
),
|
||||
{
|
||||
origin: 'Tralee',
|
||||
destination: 'Mallow',
|
||||
scheduledDeparture: '15:05',
|
||||
delaySec: -120,
|
||||
event: 'terminated',
|
||||
lastStop: 'Mallow',
|
||||
nextStop: null,
|
||||
},
|
||||
);
|
||||
const real = parsePublicMessage(
|
||||
'D319\n16:27 - Docklands to M3 Parkway (1 mins late)\nArrived Pelletstown next stop Ashtown',
|
||||
);
|
||||
assert.equal(real.destination, 'M3 Parkway');
|
||||
assert.equal(real.event, 'arrived');
|
||||
assert.equal(real.nextStop, 'Ashtown');
|
||||
assert.equal(parsePublicMessage('').origin, null);
|
||||
assert.equal(parsePublicMessage(null).event, null);
|
||||
assert.equal(parsePublicMessage('X1\\nno route here\\n?').origin, null);
|
||||
});
|
||||
|
||||
test('entities are decoded and DTDs refused', () => {
|
||||
assert.equal(
|
||||
decodeXmlText('Tom & Jerry <> "' AB &bogus;'),
|
||||
'Tom & Jerry <> "\' AB &bogus;',
|
||||
);
|
||||
assert.equal(decodeXmlText('� �'), '� �');
|
||||
const { rows } = parseIrishRailTrainsXml(
|
||||
document(train({ direction: 'To Dun Laoghaire & Bray' })),
|
||||
);
|
||||
assert.equal(rows[0].Direction, 'To Dun Laoghaire & Bray');
|
||||
assert.throws(
|
||||
() =>
|
||||
parseIrishRailTrainsXml(
|
||||
`<!DOCTYPE x [<!ENTITY a "aaaa">]>${document(train())}`,
|
||||
),
|
||||
IrishRailFormatError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseIrishRailTrainsXml('<html>error</html>'),
|
||||
IrishRailFormatError,
|
||||
);
|
||||
assert.throws(() => parseIrishRailTrainsXml(42), IrishRailFormatError);
|
||||
});
|
||||
|
||||
test('sizes are capped: document, train count and field length', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseIrishRailTrainsXml(
|
||||
document(train()) + ' '.repeat(IRISH_RAIL_MAX_TEXT_CHARS),
|
||||
),
|
||||
/characters/,
|
||||
);
|
||||
const many = Array.from({ length: IRISH_RAIL_MAX_TRAINS + 3 }, (_, i) =>
|
||||
train({ code: `E${i}` }),
|
||||
);
|
||||
const capped = parseIrishRailTrainsXml(document(...many));
|
||||
assert.equal(capped.rows.length, IRISH_RAIL_MAX_TRAINS);
|
||||
assert.equal(capped.truncated, true);
|
||||
const { rows } = parseIrishRailTrainsXml(
|
||||
document(train({ message: 'm'.repeat(600) }), '<objTrainPositions>'),
|
||||
);
|
||||
assert.equal(rows.length, 1, 'an unclosed element is not a train');
|
||||
assert.equal(rows[0].PublicMessage, null, 'an over-long field is refused');
|
||||
const empty = parseIrishRailTrainsXml(
|
||||
document(
|
||||
'<objTrainPositions><TrainCode>E1</TrainCode><Direction /></objTrainPositions>',
|
||||
),
|
||||
);
|
||||
assert.equal(empty.rows[0].Direction, '');
|
||||
assert.equal(empty.rows[0].TrainStatus, null);
|
||||
});
|
||||
@@ -313,6 +313,13 @@ const OPTION_GROUPS = Object.freeze({
|
||||
high: 'h',
|
||||
}),
|
||||
]),
|
||||
rail: Object.freeze([
|
||||
booleanOption('transit', 't', true),
|
||||
booleanOption('local', 'l', true),
|
||||
booleanOption('regional', 'r', true),
|
||||
booleanOption('national', 'n', true),
|
||||
booleanOption('international', 'i', true),
|
||||
]),
|
||||
'wildlife-sightings': Object.freeze([
|
||||
booleanOption('confirmed', 'c', false),
|
||||
enumOption(
|
||||
@@ -700,6 +707,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'radio',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'rail',
|
||||
token: '02',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'rail',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'recent-imagery',
|
||||
token: '1',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 38);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 39);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 39);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,12 +243,14 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
// News holds 00 and Shipwrecks 01, the first pairs: the next token is 02.
|
||||
assert.equal(nextLayerStateToken(), '02');
|
||||
// The allocation rules below read the ledger before either took a pair.
|
||||
// News holds 00, Shipwrecks 01 and Rail 02, the first pairs: the next token
|
||||
// is 03.
|
||||
assert.equal(nextLayerStateToken(), '03');
|
||||
// The allocation rules below read the ledger before any of them took a pair.
|
||||
const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS };
|
||||
delete beforeNews.news;
|
||||
delete beforeNews.shipwrecks;
|
||||
delete beforeNews.rail;
|
||||
assert.equal(nextLayerStateToken(beforeNews), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
|
||||
@@ -97,10 +97,11 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
// The ledger before Wildlife Sightings took the last digit and News and
|
||||
// Shipwrecks the first pairs, for the allocation rules written against it.
|
||||
// The ledger before Wildlife Sightings took the last digit and News,
|
||||
// Shipwrecks and Rail the first pairs, for the allocation rules written
|
||||
// against it.
|
||||
const beforeLastDigitRows = reservationRows.filter(
|
||||
([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id),
|
||||
([id]) => !['wildlife-sightings', 'news', 'shipwrecks', 'rail'].includes(id),
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
@@ -112,6 +113,7 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
readFileSync(new URL(`./${name}`, import.meta.url)),
|
||||
);
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
// Layers published after the fixture's ledger are not in its registry.
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
|
||||
@@ -127,6 +129,11 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
/ Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
if (!rows.some(([id]) => id === 'rail'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'rail',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -150,7 +157,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 39);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -165,6 +172,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'wildlife-sightings': '9',
|
||||
news: '00',
|
||||
shipwrecks: '01',
|
||||
rail: '02',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -190,9 +198,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '02']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '03']])
|
||||
.future,
|
||||
'02',
|
||||
'03',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -423,19 +431,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
|
||||
withPublishedBase(
|
||||
{
|
||||
rows: [...reservationRows, ['retired-layer', '02']],
|
||||
rows: [...reservationRows, ['retired-layer', '03']],
|
||||
source:
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n',
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "03" });\n',
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 39);
|
||||
assert.equal(published['retired-layer'], '02');
|
||||
assert.equal(Object.keys(published).length, 40);
|
||||
assert.equal(published['retired-layer'], '03');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '02',
|
||||
newcomer: '03',
|
||||
}),
|
||||
/changed or removed/,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
["military-installations", "i"],
|
||||
["news", "00"],
|
||||
["radio", "r"],
|
||||
["rail", "02"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The Data Library's layer namespace, owned by the layer lifecycle: the
|
||||
* manager accepts a late registration only under `lib-<entry id>`, and the
|
||||
* library names its layers with the same rule.
|
||||
*/
|
||||
|
||||
/** A library entry id: lowercase, digits and hyphens, 3–48 characters. */
|
||||
export const LIBRARY_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/;
|
||||
export const LIBRARY_LAYER_PREFIX = 'lib-';
|
||||
|
||||
/** The manager id a library entry's layer registers under. */
|
||||
export function libraryLayerId(entryId) {
|
||||
return `${LIBRARY_LAYER_PREFIX}${entryId}`;
|
||||
}
|
||||
|
||||
/** The entry id behind a library layer id, or null. */
|
||||
export function libraryEntryIdOf(layerId) {
|
||||
if (typeof layerId !== 'string' || !layerId.startsWith(LIBRARY_LAYER_PREFIX))
|
||||
return null;
|
||||
const id = layerId.slice(LIBRARY_LAYER_PREFIX.length);
|
||||
return LIBRARY_ID_PATTERN.test(id) ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LayerLifecycle } from './lifecycle.js';
|
||||
|
||||
const layer = (id) => ({
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
async init() {},
|
||||
async enable() {},
|
||||
async update() {},
|
||||
async disable() {},
|
||||
async destroy() {},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
});
|
||||
|
||||
test('library layers register only after sealing and only in the lib- namespace', async () => {
|
||||
const manager = new LayerLifecycle({});
|
||||
const activity = [];
|
||||
manager.subscribeActivity((change) => activity.push(change.type));
|
||||
manager.register(layer('earthquakes'));
|
||||
assert.throws(
|
||||
() => manager.registerLibraryLayer(layer('lib-night-lights')),
|
||||
/after the catalog is sealed/,
|
||||
);
|
||||
manager.finalizeRegistrations([
|
||||
{ id: 'earthquakes', disposition: 'enabled-only' },
|
||||
]);
|
||||
assert.throws(
|
||||
() => manager.registerLibraryLayer(layer('radio')),
|
||||
/lib- namespace/,
|
||||
);
|
||||
assert.throws(() => manager.register(layer('lib-x-y-z')), /finalized/);
|
||||
manager.registerLibraryLayer(layer('lib-night-lights'));
|
||||
assert.equal(manager.isLibraryLayer('lib-night-lights'), true);
|
||||
assert.equal(manager.isLibraryLayer('earthquakes'), false);
|
||||
const rows = manager.getAll();
|
||||
assert.equal(
|
||||
rows.find(({ id }) => id === 'earthquakes').libraryEntryId,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
rows.find(({ id }) => id === 'lib-night-lights').libraryEntryId,
|
||||
'night-lights',
|
||||
);
|
||||
assert.equal(await manager.unregisterLibraryLayer('earthquakes'), false);
|
||||
assert.equal(manager.layers.has('earthquakes'), true);
|
||||
await manager.setEnabled('lib-night-lights', true, { origin: 'user' });
|
||||
assert.equal(await manager.unregisterLibraryLayer('lib-night-lights'), true);
|
||||
assert.equal(manager.layers.has('lib-night-lights'), false);
|
||||
assert.equal(manager.isLibraryLayer('lib-night-lights'), false);
|
||||
assert.equal(activity.filter((type) => type === 'registrations').length, 2);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
import { libraryEntryIdOf } from './libraryLayerIds.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
@@ -97,6 +98,7 @@ export class LayerLifecycle {
|
||||
this._registrationDispositions = null;
|
||||
this._allowQaRegistration = allowQaRegistration === true;
|
||||
this._qaLayerIds = new Set();
|
||||
this._libraryLayerIds = new Set();
|
||||
}
|
||||
|
||||
register(layerModule) {
|
||||
@@ -125,6 +127,41 @@ export class LayerLifecycle {
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Data Library layer after the catalog is sealed. Only ids in
|
||||
* the library namespace (`lib-<entry id>`) are accepted, so this seam can
|
||||
* never stand in for a core layer, and library layers never join the core
|
||||
* share-token registry: their selection travels in `lib=` instead.
|
||||
* @param {object} layerModule
|
||||
* @returns {string} The registered id.
|
||||
*/
|
||||
registerLibraryLayer(layerModule) {
|
||||
if (!this._registrationsFinalized)
|
||||
throw new Error('Library layers register after the catalog is sealed');
|
||||
if (!libraryEntryIdOf(layerModule?.id))
|
||||
throw new Error('Library layer ids must use the lib- namespace');
|
||||
this._registerLayer(layerModule);
|
||||
this._libraryLayerIds.add(layerModule.id);
|
||||
this._publishActivity({ type: 'registrations', layerId: layerModule.id });
|
||||
return layerModule.id;
|
||||
}
|
||||
|
||||
/** Destroy a layer registered through `registerLibraryLayer`. */
|
||||
async unregisterLibraryLayer(layerId) {
|
||||
if (!this._libraryLayerIds.has(layerId)) return false;
|
||||
const destroyed = await this.destroyLayer(layerId);
|
||||
if (destroyed) {
|
||||
this._libraryLayerIds.delete(layerId);
|
||||
this._publishActivity({ type: 'registrations', layerId });
|
||||
}
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
/** Whether a registered layer came from the Data Library. */
|
||||
isLibraryLayer(layerId) {
|
||||
return this._libraryLayerIds.has(layerId);
|
||||
}
|
||||
|
||||
_registerLayer(layerModule) {
|
||||
if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) {
|
||||
throw new Error('Data layer must provide a stable id');
|
||||
@@ -2231,6 +2268,7 @@ export class LayerLifecycle {
|
||||
}
|
||||
this.layers.delete(layerId);
|
||||
this._qaLayerIds.delete(layerId);
|
||||
this._libraryLayerIds.delete(layerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2274,6 +2312,10 @@ export class LayerLifecycle {
|
||||
// showInTogglePanel). The layer reports stats.keyRequired while that
|
||||
// key is absent; the panel builds the guidance text from the pair.
|
||||
requiresKeyId: entry.module.requiresKeyId || null,
|
||||
// Data Library entry id; the panel groups these under Library.
|
||||
libraryEntryId: this._libraryLayerIds.has(id)
|
||||
? libraryEntryIdOf(id)
|
||||
: null,
|
||||
enabled: entry.enabled,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
lifecycleUncertain: entry.lifecycleUncertain,
|
||||
|
||||
@@ -86,6 +86,13 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
|
||||
if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) {
|
||||
details.push(clampCardLine(river));
|
||||
}
|
||||
} else if (Array.isArray(props.__gevDetails)) {
|
||||
// Data Library features carry their card lines from the manifest
|
||||
// (src/library/formats.js presentLibraryProperties).
|
||||
for (const line of props.__gevDetails.slice(0, 2)) {
|
||||
const clean = cleanLabel(line);
|
||||
if (clean) details.push(clampCardLine(clean));
|
||||
}
|
||||
}
|
||||
|
||||
return { title, details };
|
||||
@@ -292,7 +299,10 @@ export function localDatasetError(error) {
|
||||
* A minimal, rock-solid native implementation for loading local GeoJSON Data.
|
||||
* Draws 3D stems (polylines) attached to Point entities and ensures
|
||||
* standard scene.pick natively clicks them.
|
||||
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
|
||||
* @param {object} options Dataset URL (or a `loadFeatures({signal})` loader
|
||||
* resolving to GeoJSON features), identity, appearance (an optional
|
||||
* `featureColor(properties)` returns a CSS colour per feature) and optional
|
||||
* Cesium adapters.
|
||||
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
|
||||
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
|
||||
* One live instance per layer id is allowed in a given viewer/context/overlay host.
|
||||
@@ -310,6 +320,8 @@ export function createLocalGeoJsonLayer(
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
loadFeatures = null,
|
||||
featureColor = null,
|
||||
screenSpaceEventHandlerFactory = (canvas) =>
|
||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
projectToWindow = (scene, position) =>
|
||||
@@ -353,6 +365,12 @@ export function createLocalGeoJsonLayer(
|
||||
* @type {Array<object>|null}
|
||||
*/
|
||||
let _cachedFeatures = null;
|
||||
/**
|
||||
* The source a `replaceFeatures()` rebuild is replacing: it stays drawn
|
||||
* until the new one is in the scene, and comes back if the rebuild fails.
|
||||
* @type {{source: object, stemRecords: object[], count: number, lastUpdate: number|null, error: string|null}|null}
|
||||
*/
|
||||
let _outgoing = null;
|
||||
/**
|
||||
* Globe-LOD active set: the record ids allowed to carry a live stem right
|
||||
* now. This bounds geometry refreshes and ground-sample work to the
|
||||
@@ -437,7 +455,19 @@ export function createLocalGeoJsonLayer(
|
||||
* survive a disable is the per-frame visualizer walk and the entity memory.
|
||||
* @param {object} viewer
|
||||
*/
|
||||
const releaseOutgoing = (viewer) => {
|
||||
if (!_outgoing) return;
|
||||
const { source } = _outgoing;
|
||||
_outgoing = null;
|
||||
try {
|
||||
viewer?.dataSources?.remove(source, true);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
const releaseDataSource = (viewer) => {
|
||||
releaseOutgoing(viewer);
|
||||
if (!_dataSource) return;
|
||||
const source = _dataSource;
|
||||
_dataSource = null;
|
||||
@@ -477,7 +507,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
const layer = {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
@@ -585,7 +615,17 @@ export function createLocalGeoJsonLayer(
|
||||
let addedToScene = false;
|
||||
try {
|
||||
let features = _cachedFeatures;
|
||||
if (!features) {
|
||||
if (!features && loadFeatures) {
|
||||
// A caller-owned loader (the Data Library) fetches and bounds
|
||||
// its own live dataset; this layer only renders it.
|
||||
features = await loadFeatures({
|
||||
signal: _loadController.signal,
|
||||
});
|
||||
if (_destroyed) return;
|
||||
if (!Array.isArray(features))
|
||||
throw new Error('dataset unavailable');
|
||||
_cachedFeatures = features;
|
||||
} else if (!features) {
|
||||
const response = await fetch(url, {
|
||||
signal: _loadController.signal,
|
||||
});
|
||||
@@ -639,9 +679,21 @@ export function createLocalGeoJsonLayer(
|
||||
_stemRecords = [];
|
||||
_stemGeometryDirty = true;
|
||||
|
||||
// An optional per-feature colour (Data Library colorBy); one
|
||||
// Cesium colour per distinct CSS value.
|
||||
const colorCache = new Map([[color, baseColor]]);
|
||||
const colorFor = (entity) => {
|
||||
if (typeof featureColor !== 'function') return baseColor;
|
||||
const css = featureColor(propertyObject(entity)) || color;
|
||||
if (!colorCache.has(css))
|
||||
colorCache.set(css, Cesium.Color.fromCssColorString(css));
|
||||
return colorCache.get(css);
|
||||
};
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const feature = entities[i];
|
||||
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
|
||||
const featureBaseColor = colorFor(feature);
|
||||
|
||||
let pos = feature.position?.getValue(Cesium.JulianDate.now());
|
||||
|
||||
@@ -649,7 +701,10 @@ export function createLocalGeoJsonLayer(
|
||||
// It's a polygon or line
|
||||
if (feature.polygon) {
|
||||
feature.polygon.outline = true;
|
||||
feature.polygon.outlineColor = baseColor;
|
||||
feature.polygon.outlineColor = featureBaseColor;
|
||||
if (featureBaseColor !== baseColor)
|
||||
feature.polygon.material =
|
||||
featureBaseColor.withAlpha(0.3);
|
||||
|
||||
// Calculate center point for the stem
|
||||
const hierarchy = feature.polygon.hierarchy?.getValue(
|
||||
@@ -689,7 +744,7 @@ export function createLocalGeoJsonLayer(
|
||||
// Store references for bounded stem scaling and native picking.
|
||||
feature.__localBaseCarto = carto;
|
||||
feature.__localBaseCartesian = base;
|
||||
registerEntityContext(feature, {
|
||||
const context = registerEntityContext(feature, {
|
||||
id: `${id}:${recordId}`,
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
@@ -716,11 +771,11 @@ export function createLocalGeoJsonLayer(
|
||||
feature.polyline = new Cesium.PolylineGraphics({
|
||||
positions: stemPositionBuffers[0],
|
||||
width: 3.5,
|
||||
material: new Cesium.ColorMaterialProperty(baseColor),
|
||||
material: new Cesium.ColorMaterialProperty(featureBaseColor),
|
||||
});
|
||||
feature.point = new Cesium.PointGraphics({
|
||||
pixelSize: 10,
|
||||
color: baseColor,
|
||||
color: featureBaseColor,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
// Never depth-cull the anchor against the photoreal mesh —
|
||||
@@ -732,6 +787,7 @@ export function createLocalGeoJsonLayer(
|
||||
_stemRecords.push({
|
||||
id: recordId,
|
||||
entity: feature,
|
||||
context,
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
@@ -749,7 +805,7 @@ export function createLocalGeoJsonLayer(
|
||||
position: tip,
|
||||
properties,
|
||||
priority,
|
||||
accent: color,
|
||||
accent: featureBaseColor.toCssHexString(),
|
||||
})
|
||||
: null,
|
||||
});
|
||||
@@ -1065,6 +1121,108 @@ export function createLocalGeoJsonLayer(
|
||||
|
||||
disable: disableLayer,
|
||||
|
||||
/**
|
||||
* Forget the parsed features so the next enable() loads them again: a
|
||||
* caller-owned live loader (the Data Library) uses it once its data is
|
||||
* older than the source allows.
|
||||
*/
|
||||
dropCachedFeatures: () => {
|
||||
_cachedFeatures = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a new feature set in place of the current one while the layer is
|
||||
* on (a live loader's refresh). The current features stay drawn until
|
||||
* the new ones are in the scene; a selected feature stays selected when
|
||||
* the new set still has it (same id, or same place and label); a failed
|
||||
* rebuild keeps the current features and rejects. Off, the set is only
|
||||
* kept for the next enable().
|
||||
* @param {object} viewer
|
||||
* @param {object[]} features GeoJSON features.
|
||||
*/
|
||||
replaceFeatures: async (viewer, features) => {
|
||||
if (_destroyed) return;
|
||||
const previousFeatures = _cachedFeatures;
|
||||
_cachedFeatures = features;
|
||||
if (!_enabled || !_dataSource || _loadPromise) return;
|
||||
const selected =
|
||||
viewer?.selectedEntity?.__localLayerId === id
|
||||
? viewer.selectedEntity
|
||||
: null;
|
||||
const selectedLabel = selected
|
||||
? _stemRecords.find((record) => record.entity === selected)?.context
|
||||
?.label
|
||||
: undefined;
|
||||
_outgoing = {
|
||||
source: _dataSource,
|
||||
stemRecords: _stemRecords,
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _error,
|
||||
};
|
||||
const outgoing = _outgoing;
|
||||
_dataSource = null;
|
||||
await coreEnable(viewer);
|
||||
// Disabled or destroyed meanwhile: release took both sources.
|
||||
if (_destroyed || !_enabled || _outgoing !== outgoing) return;
|
||||
_outgoing = null;
|
||||
if (!_dataSource) {
|
||||
// The rebuild failed: the current features come back as they were.
|
||||
const failure = _error;
|
||||
_cachedFeatures = previousFeatures;
|
||||
_dataSource = outgoing.source;
|
||||
_stemRecords = outgoing.stemRecords;
|
||||
_count = outgoing.count;
|
||||
_lastUpdate = outgoing.lastUpdate;
|
||||
_error = outgoing.error;
|
||||
_stemGeometryDirty = true;
|
||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||
for (const record of _stemRecords)
|
||||
if (record.context)
|
||||
registerEntityContext(record.entity, record.context);
|
||||
if (selected && viewer.selectedEntity === selected)
|
||||
selectEntityContext(selected);
|
||||
throw new Error(failure || 'dataset unavailable');
|
||||
}
|
||||
try {
|
||||
viewer?.dataSources?.remove(outgoing.source, true);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// Records the new set no longer has go with the old source.
|
||||
removeEntityContextsForLayer(id, {
|
||||
retainIds: new Set(
|
||||
_stemRecords.map((record) => record.context?.id).filter(Boolean),
|
||||
),
|
||||
});
|
||||
if (selected) {
|
||||
const same = (record) => {
|
||||
const old = selected.__localBaseCarto;
|
||||
const next = record.entity.__localBaseCarto;
|
||||
return (
|
||||
old &&
|
||||
next &&
|
||||
Math.abs(old.longitude - next.longitude) < 1e-9 &&
|
||||
Math.abs(old.latitude - next.latitude) < 1e-9 &&
|
||||
record.context?.label === selectedLabel
|
||||
);
|
||||
};
|
||||
const match = _stemRecords.find((record) =>
|
||||
selected.id !== undefined && record.entity.id === selected.id
|
||||
? true
|
||||
: same(record),
|
||||
);
|
||||
if (match) {
|
||||
viewer.selectedEntity = match.entity;
|
||||
selectEntityContext(match.entity);
|
||||
} else if (viewer.selectedEntity === selected) {
|
||||
viewer.selectedEntity = undefined;
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
}
|
||||
}
|
||||
viewer?.scene?.requestRender?.();
|
||||
},
|
||||
|
||||
destroy: (viewer) => {
|
||||
if (_destroyed) return;
|
||||
_destroyed = true;
|
||||
@@ -1089,6 +1247,8 @@ export function createLocalGeoJsonLayer(
|
||||
_error = null;
|
||||
},
|
||||
};
|
||||
const coreEnable = layer.enable;
|
||||
return layer;
|
||||
}
|
||||
|
||||
function compareLocalOverlayRecords(a, b) {
|
||||
@@ -1323,6 +1483,10 @@ function labelPriorityFromProperties(props, layerId) {
|
||||
if (props.output || tags['plant:output:electricity']) score += 120;
|
||||
if (layerId === 'local-dams') score += 80;
|
||||
if (layerId === 'local-datacenters') score += 60;
|
||||
// Data Library features carry their manifest importance (0–600), below the
|
||||
// named/unnamed gap so a rank never promotes an unnamed feature.
|
||||
if (Number.isFinite(props.__gevRank))
|
||||
score += Math.max(0, Math.min(600, props.__gevRank));
|
||||
return score;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
Offline polygons with no network dependency. The voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) reads the named regions, so "outline the
|
||||
Alps" resolves to the real range geometry; area annotations read the
|
||||
countries, states and provinces (`src/data/adminBoundaries.js`); and the News
|
||||
countries, states and provinces (`src/data/adminBoundaries.js`); the News
|
||||
layer reads the countries and the disputed areas
|
||||
(`server/providers/news/areas.js`).
|
||||
(`server/providers/news/areas.js`); and Data Library imagery draws its
|
||||
coverage outlines from the countries (`src/library/outlines.js`).
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @module openVesselPhoto
|
||||
* @description Reads an Open Vessel Photos answer into the one photo every
|
||||
* surface shows. Open Vessel Photos (https://openvesselphotos.org) serves a
|
||||
* free, keyless JSON and image API by MMSI. Every photo keeps the open licence
|
||||
* its author chose (CC BY, CC BY-SA, CC0 or public domain), so it is shown
|
||||
* with its credit and licence and links back to the vessel's page. A photo
|
||||
* whose image is not on Open Vessel Photos' own host, or whose answer names
|
||||
* no licence, is "no photo": nothing arbitrary is ever shown or linked.
|
||||
*
|
||||
* Accepts the upstream answer (`GET https://openvesselphotos.org/v1/mmsi/{mmsi}`,
|
||||
* `{mmsi, photos: [...]}`) and an already-read photo
|
||||
* (`{found, src, width, height, photographer, licence, link}`).
|
||||
* Pure: no fetch, no DOM.
|
||||
*/
|
||||
|
||||
/** The only host a photo may come from or link to. */
|
||||
const HOST = 'openvesselphotos.org';
|
||||
/** The API's per-vessel answer. */
|
||||
const API = `https://${HOST}/v1/mmsi/`;
|
||||
/** The image width asked for: sharp in the panel on a 2× screen, ~60 KB. */
|
||||
const IMAGE_SIZE = '800';
|
||||
|
||||
const NO_PHOTO = Object.freeze({ found: false });
|
||||
|
||||
/** A vessel's MMSI: nine digits. */
|
||||
export const MMSI = /^\d{9}$/;
|
||||
|
||||
/** The API answer for one MMSI. */
|
||||
export function openVesselPhotosUrl(mmsi) {
|
||||
return API + mmsi;
|
||||
}
|
||||
|
||||
/** The vessel's page on Open Vessel Photos. */
|
||||
function vesselPage(mmsi) {
|
||||
return `https://${HOST}/vessels/mmsi/${mmsi}`;
|
||||
}
|
||||
|
||||
/** The parsed `https:` URL on the Open Vessel Photos host, or null. */
|
||||
function ownUrl(value) {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return url.protocol === 'https:' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.port &&
|
||||
url.hostname === HOST
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
function dimension(value) {
|
||||
const number = Number(value);
|
||||
return value != null && Number.isFinite(number) && number > 0
|
||||
? Math.round(number)
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/** One upstream photo of the vessel `mmsi`, checked. */
|
||||
function checkedUpstream(photo, mmsi) {
|
||||
if (!photo || typeof photo !== 'object') return null;
|
||||
const image = ownUrl(photo.urls?.mmsi);
|
||||
const licence = text(photo.licenceName) || text(photo.licence);
|
||||
if (!image || !licence) return null;
|
||||
image.search = '';
|
||||
image.hash = '';
|
||||
image.searchParams.set('size', IMAGE_SIZE);
|
||||
// The answer gives the full image's size; the 800 px one keeps its shape.
|
||||
const width = dimension(photo.width);
|
||||
const height = dimension(photo.height);
|
||||
const scale =
|
||||
width && width > Number(IMAGE_SIZE) ? Number(IMAGE_SIZE) / width : 1;
|
||||
return {
|
||||
found: true,
|
||||
src: image.href,
|
||||
width: width ? Math.round(width * scale) : null,
|
||||
height: height ? Math.round(height * scale) : null,
|
||||
photographer: text(photo.credit),
|
||||
licence,
|
||||
link: vesselPage(mmsi),
|
||||
};
|
||||
}
|
||||
|
||||
/** An already-read photo, checked again. */
|
||||
function checkedRead(photo) {
|
||||
const image = ownUrl(photo.src);
|
||||
const page = ownUrl(photo.link);
|
||||
const licence = text(photo.licence);
|
||||
if (!image || !page || !licence) return null;
|
||||
return {
|
||||
found: true,
|
||||
src: image.href,
|
||||
width: dimension(photo.width),
|
||||
height: dimension(photo.height),
|
||||
photographer: text(photo.photographer),
|
||||
licence,
|
||||
link: page.href,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an Open Vessel Photos answer into the first photo that passes the
|
||||
* checks.
|
||||
* @param {*} payload Upstream answer or already-read photo.
|
||||
* @param {?string} [requestedMmsi] The MMSI asked for; an upstream answer
|
||||
* naming another is no photo.
|
||||
* @returns {{found: true, src: string, width: ?number, height: ?number,
|
||||
* photographer: string, licence: string, link: string} | {found: false}}
|
||||
*/
|
||||
export function parseOpenVesselPhoto(payload, requestedMmsi = null) {
|
||||
if (!payload || typeof payload !== 'object') return NO_PHOTO;
|
||||
if (Array.isArray(payload.photos)) {
|
||||
const mmsi = String(payload.mmsi ?? '');
|
||||
if (!MMSI.test(mmsi)) return NO_PHOTO;
|
||||
// An answer about another vessel is no photo of this one.
|
||||
if (requestedMmsi != null && mmsi !== String(requestedMmsi))
|
||||
return NO_PHOTO;
|
||||
for (const photo of payload.photos) {
|
||||
const checked = checkedUpstream(photo, mmsi);
|
||||
if (checked) return checked;
|
||||
}
|
||||
return NO_PHOTO;
|
||||
}
|
||||
if (payload.found !== true) return NO_PHOTO;
|
||||
return checkedRead(payload) || NO_PHOTO;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// src/data/openVesselPhoto.test.mjs — an Open Vessel Photos answer is read
|
||||
// into one credited, licensed photo, and nothing outside Open Vessel Photos'
|
||||
// own host is ever shown or linked.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
openVesselPhotosUrl,
|
||||
parseOpenVesselPhoto,
|
||||
} from './openVesselPhoto.js';
|
||||
|
||||
const UPSTREAM = Object.freeze({
|
||||
mmsi: '366999709',
|
||||
name: '01 USCGC Sockeye WPB87337',
|
||||
photos: [
|
||||
{
|
||||
n: 1,
|
||||
urls: {
|
||||
cfr: null,
|
||||
mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg',
|
||||
},
|
||||
credit: 'Gordon Leggett',
|
||||
licence: 'CC-BY-4.0',
|
||||
licenceName: 'CC BY 4.0',
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const PHOTO = Object.freeze({
|
||||
found: true,
|
||||
src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800',
|
||||
width: 800,
|
||||
height: 600,
|
||||
photographer: 'Gordon Leggett',
|
||||
licence: 'CC BY 4.0',
|
||||
link: 'https://openvesselphotos.org/vessels/mmsi/366999709',
|
||||
});
|
||||
|
||||
const withPhoto = (overrides) => ({
|
||||
...UPSTREAM,
|
||||
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
|
||||
});
|
||||
|
||||
test('the upstream answer maps to the 800 px image, its credit, licence and page', () => {
|
||||
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM), PHOTO);
|
||||
assert.equal(
|
||||
openVesselPhotosUrl('366999709'),
|
||||
'https://openvesselphotos.org/v1/mmsi/366999709',
|
||||
);
|
||||
});
|
||||
|
||||
test('an image narrower than 800 px keeps its own size', () => {
|
||||
const photo = parseOpenVesselPhoto(withPhoto({ width: 640, height: 480 }));
|
||||
assert.equal(photo.width, 640);
|
||||
assert.equal(photo.height, 480);
|
||||
});
|
||||
|
||||
test('an already-read photo passes through its checks again', () => {
|
||||
assert.deepEqual(parseOpenVesselPhoto(PHOTO), PHOTO);
|
||||
assert.deepEqual(parseOpenVesselPhoto({ ...PHOTO, licence: '' }), {
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a photo off the Open Vessel Photos host, or without a licence, is no photo', () => {
|
||||
for (const overrides of [
|
||||
{ urls: { mmsi: 'https://example.com/1.jpg' } },
|
||||
{
|
||||
urls: {
|
||||
mmsi: 'http://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg',
|
||||
},
|
||||
},
|
||||
{ urls: { mmsi: 'https://openvesselphotos.org.evil.com/1.jpg' } },
|
||||
{ urls: { mmsi: 'https://[email protected]/1.jpg' } },
|
||||
{ licence: '', licenceName: '' },
|
||||
])
|
||||
assert.deepEqual(parseOpenVesselPhoto(withPhoto(overrides)), {
|
||||
found: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseOpenVesselPhoto({ ...PHOTO, link: 'https://example.com/' }),
|
||||
{ found: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('a vessel without photos, or an answer with a bad MMSI, is no photo', () => {
|
||||
assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, photos: [] }), {
|
||||
found: false,
|
||||
});
|
||||
assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, mmsi: '12/../x' }), {
|
||||
found: false,
|
||||
});
|
||||
assert.deepEqual(parseOpenVesselPhoto(null), { found: false });
|
||||
});
|
||||
|
||||
test('the next photo stands in when the first fails its checks', () => {
|
||||
const second = {
|
||||
...UPSTREAM.photos[0],
|
||||
n: 2,
|
||||
urls: {
|
||||
mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg',
|
||||
},
|
||||
};
|
||||
const photo = parseOpenVesselPhoto({
|
||||
...UPSTREAM,
|
||||
photos: [{ ...UPSTREAM.photos[0], licenceName: '', licence: '' }, second],
|
||||
});
|
||||
assert.equal(
|
||||
photo.src,
|
||||
'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg?size=800',
|
||||
);
|
||||
});
|
||||
|
||||
test('an answer about another vessel than the one asked for is no photo', () => {
|
||||
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '366999709'), PHOTO);
|
||||
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '227000101'), {
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,491 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_CATEGORIES,
|
||||
RAIL_ENABLED_FEEDS,
|
||||
RAIL_FEED_ID_PATTERN,
|
||||
RAIL_FEED_REGISTRY,
|
||||
RAIL_MODES,
|
||||
RAIL_POSITION_SOURCES,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
isRailFeedPollable,
|
||||
publicRailCatalog,
|
||||
railEnabledFeeds,
|
||||
railFeedPositions,
|
||||
railFeedsInRange,
|
||||
} from './railFeeds.js';
|
||||
|
||||
const isHttps = (url) => new URL(url).protocol === 'https:';
|
||||
|
||||
test('every registered feed is complete, https and uniquely named', () => {
|
||||
const ids = new Set();
|
||||
for (const feed of RAIL_FEED_REGISTRY) {
|
||||
assert.match(feed.id, RAIL_FEED_ID_PATTERN, feed.id);
|
||||
assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`);
|
||||
ids.add(feed.id);
|
||||
const positions = railFeedPositions(feed);
|
||||
assert.ok(RAIL_POSITION_SOURCES.includes(positions), feed.id);
|
||||
assert.ok(isHttps(feed.url), feed.id);
|
||||
for (const url of [feed.tripUpdatesUrl, feed.staticGtfsUrl])
|
||||
if (url !== undefined) assert.ok(isHttps(url), `${feed.id} ${url}`);
|
||||
assert.ok(
|
||||
['gtfs-rt', 'irish-rail-xml', 'siri-vm'].includes(feed.format),
|
||||
feed.id,
|
||||
);
|
||||
if (feed.format === 'irish-rail-xml')
|
||||
assert.equal(positions, 'event', `${feed.id} positions are events`);
|
||||
for (const key of ['name', 'operator', 'region', 'license', 'attribution'])
|
||||
assert.ok(feed[key], `${feed.id} has ${key}`);
|
||||
assert.ok(URL.canParse(feed.licenseUrl), `${feed.id} licence url`);
|
||||
assert.equal(typeof feed.defaultEnabled, 'boolean', feed.id);
|
||||
assert.equal(typeof feed.terms?.quote, 'string', feed.id);
|
||||
assert.equal(typeof feed.classify, 'function', feed.id);
|
||||
assert.ok(
|
||||
Number.isFinite(feed.center.lat) && Number.isFinite(feed.center.lon),
|
||||
);
|
||||
assert.ok(feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000, feed.id);
|
||||
for (const ms of [feed.pollIntervalMs, feed.tripUpdatesIntervalMs])
|
||||
if (ms !== undefined) assert.ok(ms >= 5_000 && ms <= 300_000, feed.id);
|
||||
}
|
||||
});
|
||||
|
||||
// The fields the registry documents (railFeeds.js, RAIL_FEED_REGISTRY):
|
||||
// an entry carries nothing else, so no feed asks for a key, a sign-in or a
|
||||
// timetable to place its trains.
|
||||
const REGISTRY_FIELDS = new Set([
|
||||
'id',
|
||||
'name',
|
||||
'operator',
|
||||
'region',
|
||||
'center',
|
||||
'loadRadiusKm',
|
||||
'format',
|
||||
'url',
|
||||
'positions',
|
||||
'headers',
|
||||
'headerClock',
|
||||
'speedUnit',
|
||||
'pollIntervalMs',
|
||||
'tripUpdatesUrl',
|
||||
'tripUpdatesIntervalMs',
|
||||
'maxBodyBytes',
|
||||
'limits',
|
||||
'routesFrom',
|
||||
'positionsByTrainNumber',
|
||||
'staticGtfsUrl',
|
||||
'staticGtfsEntry',
|
||||
'staticHeaders',
|
||||
'staticRouteTypes',
|
||||
'staticTripEnds',
|
||||
'staticSimplifyM',
|
||||
'staticRouteRename',
|
||||
'staticTripRename',
|
||||
'license',
|
||||
'licenseUrl',
|
||||
'attribution',
|
||||
'defaultEnabled',
|
||||
'terms',
|
||||
'classify',
|
||||
]);
|
||||
|
||||
test('every feed reports live positions: GPS or signalling events', () => {
|
||||
for (const feed of RAIL_FEED_REGISTRY) {
|
||||
assert.notEqual(feed.positions, 'schedule', feed.id);
|
||||
assert.ok(
|
||||
['gps', 'event'].includes(railFeedPositions(feed)),
|
||||
`${feed.id} positions`,
|
||||
);
|
||||
assert.equal(typeof feed.url, 'string', `${feed.id} has a live feed`);
|
||||
}
|
||||
for (const entry of publicRailCatalog())
|
||||
assert.ok(['gps', 'event'].includes(entry.positions), entry.id);
|
||||
});
|
||||
|
||||
/** Query parameter names that carry a key, a token or a signature. */
|
||||
const ACCESS_PARAM =
|
||||
/^(api[-_]?key|appid|app[-_]?key|key|token|access[-_]?token|auth|sig|signature|subscription[-_]?key)$/i;
|
||||
|
||||
/** `name=value` of each access parameter in a URL (values decoded). */
|
||||
function accessParams(url) {
|
||||
if (!url) return [];
|
||||
return [...new URL(url).searchParams]
|
||||
.filter(([name]) => ACCESS_PARAM.test(name))
|
||||
.map(([name, value]) => `${name}=${value}`);
|
||||
}
|
||||
|
||||
// The only access parameter allowed, a fixed public value the publisher
|
||||
// prints in the published link for everyone, the same for every user and not
|
||||
// a credential of the deployer's: Bordeaux Métropole's TBM `apiKey` (on the
|
||||
// dataset page).
|
||||
const PUBLISHED_ACCESS_VALUES = new Set([
|
||||
'tbm-bordeaux apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
|
||||
]);
|
||||
|
||||
test('an access parameter in a feed URL is found, whatever its spelling', () => {
|
||||
for (const url of [
|
||||
'https://x.test/a?apiKey=1',
|
||||
'https://x.test/a?api_key=1',
|
||||
'https://x.test/a?APIKEY=1',
|
||||
'https://x.test/a?f=pb&key=1',
|
||||
'https://x.test/a?token=1',
|
||||
'https://x.test/a?access_token=1',
|
||||
'https://x.test/a?sr=b&sig=abc%3D',
|
||||
'https://x.test/a?signature=1',
|
||||
'https://x.test/a?subscription-key=1',
|
||||
])
|
||||
assert.equal(accessParams(url).length, 1, url);
|
||||
assert.deepEqual(accessParams('https://x.test/a?format=pb&route=key'), []);
|
||||
assert.deepEqual(accessParams(null), []);
|
||||
// Each published value is still in use, so the allow-list cannot go stale.
|
||||
const used = new Set();
|
||||
for (const feed of RAIL_FEED_REGISTRY)
|
||||
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
|
||||
for (const param of accessParams(url)) used.add(`${feed.id} ${param}`);
|
||||
assert.deepEqual([...used].sort(), [...PUBLISHED_ACCESS_VALUES].sort());
|
||||
});
|
||||
|
||||
test('every feed is on, credited with its licence and terms, and fetched as published', () => {
|
||||
const ids = new Set();
|
||||
for (const feed of RAIL_FEED_REGISTRY) {
|
||||
assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`);
|
||||
ids.add(feed.id);
|
||||
assert.equal(feed.defaultEnabled, true, `${feed.id} is on`);
|
||||
for (const field of Object.keys(feed))
|
||||
assert.ok(REGISTRY_FIELDS.has(field), `${feed.id}: ${field}`);
|
||||
assert.ok(typeof feed.license === 'string' && feed.license, feed.id);
|
||||
assert.ok(isHttps(feed.licenseUrl), `${feed.id} licence url`);
|
||||
assert.ok(
|
||||
typeof feed.attribution === 'string' && feed.attribution,
|
||||
feed.id,
|
||||
);
|
||||
assert.equal(typeof feed.terms?.quote, 'string', `${feed.id} terms`);
|
||||
assert.ok(feed.terms.quote || feed.terms.note, `${feed.id} terms basis`);
|
||||
// No URL carries an access parameter, but for the fixed public values
|
||||
// their publishers print for everyone (PUBLISHED_ACCESS_VALUES).
|
||||
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
|
||||
for (const param of accessParams(url))
|
||||
assert.ok(
|
||||
PUBLISHED_ACCESS_VALUES.has(`${feed.id} ${param}`),
|
||||
`${feed.id} ${url}`,
|
||||
);
|
||||
}
|
||||
assert.equal(ids.size, RAIL_FEED_REGISTRY.length);
|
||||
assert.deepEqual(
|
||||
RAIL_ENABLED_FEEDS.map((feed) => feed.id),
|
||||
RAIL_FEED_REGISTRY.map((feed) => feed.id),
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed is switched on only when its terms were read and quoted', () => {
|
||||
for (const feed of RAIL_ENABLED_FEEDS) {
|
||||
assert.ok(
|
||||
feed.terms.quote || feed.terms.note,
|
||||
`${feed.id} is enabled without a recorded basis`,
|
||||
);
|
||||
}
|
||||
// Tri-Rail states no licence; it is used without one by project owner
|
||||
// decision, and the terms say so.
|
||||
const triRail = getRailFeed('tri-rail');
|
||||
assert.ok(triRail, 'tri-rail is pollable');
|
||||
assert.ok(
|
||||
triRail.terms.note.startsWith(
|
||||
'Used without a published licence (project owner decision, 2026-11-10)',
|
||||
),
|
||||
);
|
||||
assert.match(triRail.license, /^No licence stated/);
|
||||
});
|
||||
|
||||
test('only enabled, well-formed ids resolve to a pollable feed', () => {
|
||||
assert.equal(getRailFeed('mta-metro-north')?.id, 'mta-metro-north');
|
||||
for (const id of ['', 'Nope', '../x', 'a', null, 42, 'unknown-feed'])
|
||||
assert.equal(getRailFeed(id), null, String(id));
|
||||
const mbta = getRegisteredRailFeed('mbta-rail');
|
||||
assert.equal(isRailFeedPollable(mbta), true);
|
||||
assert.equal(isRailFeedPollable({ ...mbta, defaultEnabled: false }), false);
|
||||
assert.deepEqual(
|
||||
railEnabledFeeds().map((entry) => entry.id),
|
||||
RAIL_ENABLED_FEEDS.map((entry) => entry.id),
|
||||
);
|
||||
});
|
||||
|
||||
test('classifiers keep trains and drop buses in mixed feeds', () => {
|
||||
const kind = (id, route) => classifyTrain(getRegisteredRailFeed(id), route);
|
||||
assert.deepEqual(kind('mbta-rail', 'CR-Worcester'), {
|
||||
category: 'local',
|
||||
mode: 'rail',
|
||||
});
|
||||
assert.equal(kind('mbta-rail', 'Red').mode, 'metro');
|
||||
assert.equal(kind('mbta-rail', 'Green-E').mode, 'tram');
|
||||
assert.equal(kind('mbta-rail', 'Mattapan').category, 'transit');
|
||||
assert.equal(kind('mbta-rail', '1'), null);
|
||||
assert.equal(kind('mbta-rail', 'Boat-F1'), null);
|
||||
assert.equal(kind('mbta-rail', 'Shuttle-Generic'), null);
|
||||
assert.equal(kind('mbta-rail', null), null);
|
||||
|
||||
assert.equal(kind('rtd-denver-rail', 'A').category, 'local');
|
||||
assert.equal(kind('rtd-denver-rail', '113G').category, 'local');
|
||||
assert.equal(kind('rtd-denver-rail', '117N').category, 'local');
|
||||
assert.equal(kind('rtd-denver-rail', '101E').category, 'transit');
|
||||
assert.equal(kind('rtd-denver-rail', '107R').category, 'transit');
|
||||
assert.equal(kind('rtd-denver-rail', '15'), null);
|
||||
assert.equal(kind('rtd-denver-rail', '15L'), null);
|
||||
|
||||
assert.equal(kind('capmetro-rail', '550').category, 'local');
|
||||
assert.equal(kind('capmetro-rail', '801'), null);
|
||||
// Northstar's old route id is a bus now and must not come back as a train.
|
||||
assert.equal(kind('metrotransit-rail', '888'), null);
|
||||
assert.equal(kind('metrotransit-rail', '902').mode, 'tram');
|
||||
assert.equal(kind('septa-trolley', 'M1').mode, 'metro');
|
||||
assert.equal(kind('septa-trolley', '17'), null);
|
||||
assert.equal(kind('translink-seq-rail', 'BRCA-5').category, 'local');
|
||||
assert.equal(kind('translink-seq-rail', '61-3'), null);
|
||||
assert.equal(kind('adelaide-metro', 'SEAFRD').category, 'local');
|
||||
assert.equal(kind('adelaide-metro', 'GLNELG').mode, 'tram');
|
||||
assert.equal(kind('adelaide-metro', 'J1A'), null);
|
||||
assert.equal(kind('mta-metro-north', null).category, 'local');
|
||||
});
|
||||
|
||||
test('Irish Rail: DART and Commuter local, InterCity by province, Enterprise international', () => {
|
||||
const kind = (code, route) =>
|
||||
classifyTrain(getRegisteredRailFeed('irish-rail'), route, {
|
||||
tripId: code,
|
||||
})?.category ?? null;
|
||||
assert.equal(kind('E244', 'Howth - Bray'), 'local');
|
||||
assert.equal(kind('D822', 'Grand Canal Dock - Drogheda'), 'local');
|
||||
assert.equal(kind('P571', 'Midleton - Cork'), 'local');
|
||||
assert.equal(kind('A216', 'Dublin Heuston - Cork'), 'national');
|
||||
assert.equal(kind('A720', 'Dublin Heuston - Galway'), 'national');
|
||||
assert.equal(kind('B701', 'Galway - Dublin Heuston'), 'national');
|
||||
assert.equal(kind('A308', 'Mallow - Tralee'), 'regional', 'both Munster');
|
||||
assert.equal(kind('A606', 'Dublin Connolly - Rosslare Europort'), 'regional');
|
||||
assert.equal(kind('A118', 'Dublin Connolly - Belfast'), 'international');
|
||||
assert.equal(kind('A131', 'Belfast - Dublin Connolly'), 'international');
|
||||
assert.equal(kind('A999', 'Nowhere - Elsewhere'), 'national');
|
||||
assert.equal(kind('A999', null), 'national');
|
||||
assert.equal(kind('not a code', 'Howth - Bray'), null);
|
||||
assert.equal(kind(null, 'Howth - Bray'), null);
|
||||
});
|
||||
|
||||
test('a classifier answer outside the known lists is treated as not a train', () => {
|
||||
const feed = (answer) => ({ classify: () => answer });
|
||||
assert.equal(
|
||||
classifyTrain(feed({ category: 'express', mode: 'rail' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(classifyTrain(feed({ category: 'local', mode: 'bus' })), null);
|
||||
assert.equal(classifyTrain(feed(null)), null);
|
||||
assert.equal(classifyTrain({}), null);
|
||||
assert.equal(classifyTrain(null), null);
|
||||
for (const feed of RAIL_FEED_REGISTRY) {
|
||||
for (const route of [
|
||||
'CR-Lowell',
|
||||
'A',
|
||||
'550',
|
||||
'901',
|
||||
'T1',
|
||||
'BRCA-5',
|
||||
'GAW',
|
||||
null,
|
||||
]) {
|
||||
const kind = feed.classify(route);
|
||||
if (!kind) continue;
|
||||
assert.ok(RAIL_CATEGORIES.includes(kind.category), `${feed.id} ${route}`);
|
||||
assert.ok(RAIL_MODES.includes(kind.mode), `${feed.id} ${route}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage selection returns enabled feeds near the point, nearest first', () => {
|
||||
const nyc = railFeedsInRange(40.75, -73.99).map((feed) => feed.id);
|
||||
assert.ok(nyc.includes('mta-metro-north') && nyc.includes('mta-lirr'));
|
||||
assert.deepEqual(railFeedsInRange(0, -150), []);
|
||||
assert.deepEqual(railFeedsInRange(Number.NaN, 0), []);
|
||||
const brisbane = railFeedsInRange(-27.47, 153.02);
|
||||
assert.equal(brisbane[0].id, 'translink-seq-rail');
|
||||
// The browser passes the server's catalog, which lists a feed that reads
|
||||
// its routes from a schedule pack only once that pack is built.
|
||||
const madrid = (built) =>
|
||||
railFeedsInRange(
|
||||
40.42,
|
||||
-3.7,
|
||||
0,
|
||||
publicRailCatalog({ schedulePackBuilt: () => built }),
|
||||
).map((feed) => feed.id);
|
||||
assert.ok(
|
||||
!madrid(false).includes('renfe-larga-distancia'),
|
||||
'no pack: not offered',
|
||||
);
|
||||
assert.ok(madrid(false).includes('renfe-cercanias'), 'needs no pack');
|
||||
assert.ok(madrid(true).includes('renfe-larga-distancia'));
|
||||
assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, null), []);
|
||||
assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, [{ id: 'x' }]), []);
|
||||
});
|
||||
|
||||
test('the public catalog exposes coverage and credit but never upstream URLs', () => {
|
||||
const catalog = publicRailCatalog();
|
||||
assert.equal(catalog.length, RAIL_ENABLED_FEEDS.length);
|
||||
for (const entry of catalog) {
|
||||
assert.equal(entry.url, undefined);
|
||||
assert.equal(entry.tripUpdatesUrl, undefined);
|
||||
assert.equal(entry.headers, undefined);
|
||||
assert.equal(entry.classify, undefined);
|
||||
assert.ok(entry.attribution && entry.licenseUrl);
|
||||
assert.ok(RAIL_POSITION_SOURCES.includes(entry.positions));
|
||||
assert.equal(typeof entry.tripUpdates, 'boolean');
|
||||
}
|
||||
const byId = new Map(catalog.map((entry) => [entry.id, entry]));
|
||||
assert.equal(byId.get('irish-rail').positions, 'event');
|
||||
assert.equal(byId.get('irish-rail').tripUpdates, false);
|
||||
assert.equal(byId.get('mbta-rail').positions, 'gps');
|
||||
assert.equal(byId.get('mbta-rail').tripUpdates, true);
|
||||
// No upstream URL reaches the browser, by any field but the licence link
|
||||
// (an access point's page can be both the licence and the download).
|
||||
const text = JSON.stringify(
|
||||
catalog.map((entry) => ({ ...entry, licenseUrl: null })),
|
||||
);
|
||||
for (const feed of RAIL_FEED_REGISTRY)
|
||||
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
|
||||
if (url) assert.ok(!text.includes(url), `${feed.id} ${url}`);
|
||||
});
|
||||
|
||||
test('Finland: commuter local, H/HDM regional, long distance national, Haaparanta international', () => {
|
||||
const kind = (route) =>
|
||||
classifyTrain(getRegisteredRailFeed('fintraffic-vr'), route)?.category ??
|
||||
null;
|
||||
assert.equal(kind('EPO_HKI_E_HL_10'), 'local');
|
||||
assert.equal(kind('SK_ÄHT_480_HDM_10'), 'regional');
|
||||
assert.equal(kind('TPE_PRI_1_H_10'), 'regional');
|
||||
for (const route of [
|
||||
'HKI_ROI_23_IC_10',
|
||||
'HKI_OL_45_S_10',
|
||||
'HKI_KOK_101_P_10',
|
||||
'HKI_KLI_265_PYO_10',
|
||||
])
|
||||
assert.equal(kind(route), 'national', route);
|
||||
assert.equal(kind('OL_HPA_407_IC_10'), 'international');
|
||||
assert.equal(kind('HPA_OL_408_IC_10'), 'international');
|
||||
for (const route of ['HKI_TKU_1_T_10', 'IC 23', null])
|
||||
assert.equal(kind(route), null, String(route));
|
||||
});
|
||||
|
||||
test('Renfe: products and the autonomous-community rule', () => {
|
||||
const feed = getRegisteredRailFeed('renfe-larga-distancia');
|
||||
const kind = (route) => classifyTrain(feed, route)?.category ?? null;
|
||||
for (const product of ['AVE', 'AVLO', 'ALVIA', 'EUROMED', 'Intercity'])
|
||||
for (const scope of ['S', 'X'])
|
||||
assert.equal(kind(`${product}~${scope}~1`), 'national', product);
|
||||
for (const product of ['MD', 'REGIONAL', 'REG.EXP.', 'PROXIMDAD', 'AVANT']) {
|
||||
assert.equal(kind(`${product}~S~1`), 'regional', product);
|
||||
assert.equal(kind(`${product}~X~1`), 'national', product);
|
||||
}
|
||||
assert.equal(kind('AVE INT~X~1'), 'international');
|
||||
assert.equal(kind('TRENCELTA~X~1'), 'international');
|
||||
assert.equal(kind('MD~B~1'), 'international', 'an end abroad');
|
||||
assert.equal(kind('BUS~S~1'), null);
|
||||
assert.equal(kind('1700037606GL023'), null, 'an unscoped operator id');
|
||||
assert.equal(kind(null), null);
|
||||
// The rename the path build applies to routes.txt rows.
|
||||
const places = {
|
||||
17000: { lat: 40.47, lon: -3.68 },
|
||||
37606: { lat: 38.89, lon: -6.98 },
|
||||
18000: { lat: 40.41, lon: -3.7 },
|
||||
87089: { lat: 43.3, lon: 5.38 },
|
||||
};
|
||||
const helpers = {
|
||||
stop: (id) => places[id] ?? null,
|
||||
stateAt: (lat) =>
|
||||
lat > 43
|
||||
? { country: 'FR', state: 'FR-13' }
|
||||
: lat < 39.5
|
||||
? { country: 'ES', state: 'ES-EX' }
|
||||
: { country: 'ES', state: 'ES-MD' },
|
||||
};
|
||||
const rename = (id, product) =>
|
||||
feed.staticRouteRename(
|
||||
{ route_id: id, route_short_name: product },
|
||||
helpers,
|
||||
);
|
||||
assert.equal(rename('1700037606GL023', 'MD'), 'MD~X~1700037606GL023');
|
||||
assert.equal(
|
||||
rename('1700018000GL001', 'REGIONAL'),
|
||||
'REGIONAL~S~1700018000GL001',
|
||||
);
|
||||
assert.equal(rename('1700087089AV016', 'AVE'), 'AVE~B~1700087089AV016');
|
||||
assert.equal(
|
||||
rename('1700099999GL001', 'MD'),
|
||||
'MD~X~1700099999GL001',
|
||||
'unplaced',
|
||||
);
|
||||
assert.equal(rename('short', 'MD'), null);
|
||||
assert.equal(
|
||||
classifyTrain(getRegisteredRailFeed('renfe-cercanias'), null)?.category,
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
test('Norway: by line and county, replacement buses dropped, Flåmsbana a tourist line', () => {
|
||||
const kind = (id, route, tripId = 'VYG:ServiceJourney:536_906310-R') =>
|
||||
classifyTrain(getRegisteredRailFeed(id), route, { tripId });
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:F4')?.category, 'national');
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:RE11')?.category, 'national');
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:L1')?.category, 'local');
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:RE20')?.category, 'international');
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:F1')?.category, 'international');
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:R55')?.category, 'regional');
|
||||
const flam = kind('entur-vy', 'VYG:Line:R45');
|
||||
assert.equal(flam.category, 'regional');
|
||||
assert.equal(flam.tourist, true);
|
||||
assert.equal(kind('entur-go-ahead', 'GOA:Line:50')?.category, 'national');
|
||||
assert.equal(kind('entur-go-ahead', 'GOA:Line:59')?.category, 'local');
|
||||
assert.equal(kind('entur-flytoget', 'FLT:Line:FLY1')?.category, 'local');
|
||||
assert.equal(kind('entur-sj-nord', 'SJN:Line:21')?.category, 'national');
|
||||
assert.equal(kind('entur-sj-nord', 'SJN:Line:72')?.category, 'international');
|
||||
assert.equal(kind('entur-sj-nord', 'SJN:Line:26')?.category, 'regional');
|
||||
assert.equal(
|
||||
kind('entur-vy', 'VYG:Line:RE11', 'VYG:ServiceJourney:BUS-836_761763-R'),
|
||||
null,
|
||||
'a replacement bus on a train line',
|
||||
);
|
||||
assert.equal(kind('entur-vy', 'VYG:Line:X99'), null);
|
||||
assert.match(getRegisteredRailFeed('entur-sj-nord').terms.note, /missing/);
|
||||
// Its positions come from SIRI VM, the timetable still gives its stops.
|
||||
assert.equal(
|
||||
railFeedPositions(getRegisteredRailFeed('entur-sj-nord')),
|
||||
'gps',
|
||||
);
|
||||
assert.equal(getRegisteredRailFeed('entur-sj-nord').format, 'siri-vm');
|
||||
});
|
||||
|
||||
test('feeds that read routes from their schedule pack can build one', () => {
|
||||
for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.routesFrom)) {
|
||||
assert.equal(feed.routesFrom, 'schedule', feed.id);
|
||||
assert.ok(isHttps(feed.staticGtfsUrl), feed.id);
|
||||
}
|
||||
for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.headerClock))
|
||||
assert.equal(feed.headerClock, 'untrusted', feed.id);
|
||||
// Identify-yourself headers never carry a person.
|
||||
for (const feed of RAIL_FEED_REGISTRY)
|
||||
for (const value of Object.values({
|
||||
...(feed.headers || {}),
|
||||
...(feed.staticHeaders || {}),
|
||||
}))
|
||||
assert.ok(!/@/.test(value), feed.id);
|
||||
});
|
||||
|
||||
test('a feed Transit also reads declares the same speed unit', async () => {
|
||||
const { TRANSIT_FEED_REGISTRY } = await import('./transitFeeds.js');
|
||||
const transitByUrl = new Map(
|
||||
TRANSIT_FEED_REGISTRY.map((feed) => [feed.url, feed]),
|
||||
);
|
||||
let shared = 0;
|
||||
for (const feed of RAIL_FEED_REGISTRY) {
|
||||
const transit = transitByUrl.get(feed.url);
|
||||
if (!transit) continue;
|
||||
shared += 1;
|
||||
assert.equal(feed.speedUnit, transit.speedUnit, feed.id);
|
||||
}
|
||||
assert.ok(shared > 0);
|
||||
assert.equal(getRegisteredRailFeed('tam-montpellier').speedUnit, 'km/h');
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Asian feeds: Japan's live tram feeds (Manyōsen and Toyama), registered,
|
||||
// switched on with their licence basis recorded, and sorted into
|
||||
// categories.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedPositions,
|
||||
railFeedsInRange,
|
||||
} from './railFeeds.js';
|
||||
|
||||
const LIVE = ['jp-manyosen', 'jp-toyama-tram'];
|
||||
|
||||
const feed = (id) => getRegisteredRailFeed(id);
|
||||
const kind = (id, routeId) => classifyTrain(feed(id), routeId);
|
||||
|
||||
const TRAM = { category: 'transit', mode: 'tram' };
|
||||
|
||||
test('the Japanese feeds are on, with their licence basis recorded', () => {
|
||||
for (const id of LIVE) {
|
||||
assert.ok(getRailFeed(id), `${id} is pollable`);
|
||||
assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id);
|
||||
assert.ok(feed(id).license, id);
|
||||
assert.match(feed(id).licenseUrl, /^https:\/\//, id);
|
||||
assert.ok(feed(id).attribution, id);
|
||||
assert.ok(feed(id).terms.quote, `${id} quotes its terms`);
|
||||
assert.match(feed(id).terms.note, /read 2026-11-10/, id);
|
||||
assert.doesNotMatch(feed(id).terms.note, /@/, id);
|
||||
assert.equal(railFeedPositions(feed(id)), 'gps', id);
|
||||
assert.match(feed(id).url, /^https:\/\/gtfs-rt-files\.buscatch\.jp\//);
|
||||
}
|
||||
assert.equal(
|
||||
feed('jp-toyama-tram').staticGtfsUrl,
|
||||
undefined,
|
||||
'its static GTFS codes the tram as buses',
|
||||
);
|
||||
});
|
||||
|
||||
test('Japan: the Manyōsen and Toyama trams are transit', () => {
|
||||
assert.deepEqual(kind('jp-manyosen', '1'), TRAM);
|
||||
assert.deepEqual(
|
||||
kind('jp-toyama-tram', '富山港線(南富山)(3004-1)'),
|
||||
TRAM,
|
||||
'a route id seen live',
|
||||
);
|
||||
});
|
||||
|
||||
test('each Japanese tram feed is polled around its own city only', () => {
|
||||
const near = (lat, lon) => railFeedsInRange(lat, lon).map((f) => f.id);
|
||||
// Takaoka for the Manyōsen, Toyama station for the city tram.
|
||||
assert.ok(near(36.75, 137.02).includes('jp-manyosen'));
|
||||
assert.ok(near(36.7, 137.21).includes('jp-toyama-tram'));
|
||||
// Tokyo and Nagoya are hundreds of kilometres off.
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.77],
|
||||
[35.17, 136.88],
|
||||
])
|
||||
for (const id of LIVE) assert.ok(!near(lat, lon).includes(id), id);
|
||||
for (const id of LIVE) assert.ok(feed(id).loadRadiusKm <= 25, id);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// The rail counts the documents state, against the registry: how many feeds
|
||||
// there are, how many publish GPS or station reports, how many work out of
|
||||
// the box, register trip updates or have a static GTFS, and one
|
||||
// DATA_SOURCES.md row per feed, none for a feed the registry does not hold.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
RAIL_FEED_REGISTRY,
|
||||
railFeedNeedsSchedulePack,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
|
||||
const read = (file) =>
|
||||
readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
|
||||
|
||||
const counts = {
|
||||
registry: RAIL_FEED_REGISTRY.length,
|
||||
gps: RAIL_FEED_REGISTRY.filter((feed) => railFeedPositions(feed) === 'gps')
|
||||
.length,
|
||||
stationReports: RAIL_FEED_REGISTRY.filter(
|
||||
(feed) => railFeedPositions(feed) === 'event',
|
||||
).length,
|
||||
outOfTheBox: RAIL_ENABLED_FEEDS.filter(
|
||||
(feed) => !railFeedNeedsSchedulePack(feed),
|
||||
).length,
|
||||
tripUpdates: RAIL_FEED_REGISTRY.filter(
|
||||
(feed) => typeof feed.tripUpdatesUrl === 'string',
|
||||
).length,
|
||||
staticGtfs: RAIL_FEED_REGISTRY.filter(
|
||||
(feed) => typeof feed.staticGtfsUrl === 'string',
|
||||
).length,
|
||||
};
|
||||
|
||||
test('the registry holds the feeds the documents count', () => {
|
||||
assert.deepEqual(counts, {
|
||||
registry: 30,
|
||||
gps: 29,
|
||||
stationReports: 1,
|
||||
outOfTheBox: 28,
|
||||
tripUpdates: 22,
|
||||
staticGtfs: 29,
|
||||
});
|
||||
assert.equal(RAIL_ENABLED_FEEDS.length, counts.registry, 'all switched on');
|
||||
assert.deepEqual(
|
||||
RAIL_FEED_REGISTRY.filter(
|
||||
(feed) => railFeedPositions(feed) === 'event',
|
||||
).map((feed) => feed.id),
|
||||
['irish-rail'],
|
||||
'the station reports are Irish Rail’s',
|
||||
);
|
||||
});
|
||||
|
||||
test('DATA_SOURCES.md states the rail counts and has one row per feed', () => {
|
||||
const sources = read('DATA_SOURCES.md');
|
||||
const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts;
|
||||
for (const phrase of [
|
||||
`The registry lists ${registry} feeds, all switched on`,
|
||||
`${gps} feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed`,
|
||||
`${tripUpdates} feeds register trip updates`,
|
||||
`${outOfTheBox} feeds work out of the box`,
|
||||
`(${staticGtfs} of the ${registry} have one)`,
|
||||
])
|
||||
assert.ok(sources.includes(phrase), phrase);
|
||||
const rows = sources
|
||||
.split('\n')
|
||||
.filter((line) => /^\| \*\*.+\| Rail layer trains, /.test(line));
|
||||
assert.equal(rows.length, registry, 'one row per feed');
|
||||
for (const feed of RAIL_FEED_REGISTRY)
|
||||
assert.ok(
|
||||
rows.some((row) => row.includes(`| Rail layer trains, ${feed.region}:`)),
|
||||
`${feed.id} has a row`,
|
||||
);
|
||||
});
|
||||
|
||||
test('CURRENT-STATE.md states the rail counts', () => {
|
||||
const state = read('docs/CURRENT-STATE.md');
|
||||
const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts;
|
||||
for (const phrase of [
|
||||
`(${registry} keyless feeds, all on, ${outOfTheBox} out of the box;`,
|
||||
`The registry lists ${registry} feeds, all`,
|
||||
`a host that has built nothing shows ${outOfTheBox} feeds`,
|
||||
`By position source, ${gps} feeds publish live GPS.`,
|
||||
'Irish Rail publishes the\nstation or timing point the signalling last reported.',
|
||||
`${tripUpdates} feeds register trip updates;`,
|
||||
`that has a static GTFS (${staticGtfs} of the ${registry})`,
|
||||
])
|
||||
assert.ok(state.includes(phrase), phrase);
|
||||
});
|
||||
|
||||
test('README.md states the rail counts', () => {
|
||||
const readme = read('README.md');
|
||||
const { registry, gps, outOfTheBox } = counts;
|
||||
const phrase = `from ${registry} keyless operator feeds: live GPS positions from ${gps} of them and Irish Rail’s station reports, ${outOfTheBox} live out of the box`;
|
||||
assert.ok(readme.includes(phrase), phrase);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// France (2026-11-10): the Bordeaux and Montpellier tram networks, from
|
||||
// transport.data.gouv.fr. Registered, switched on with the dataset's licence
|
||||
// field quoted, polled for live positions, and sorted into categories; no
|
||||
// registry URL is on a download path its host's robots.txt disallows.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_FEED_REGISTRY,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
|
||||
const kind = (id, routeId) => {
|
||||
const train = classifyTrain(getRegisteredRailFeed(id), routeId);
|
||||
return train ? `${train.category}/${train.mode}` : null;
|
||||
};
|
||||
|
||||
const FR_FEEDS = ['tbm-bordeaux', 'tam-montpellier'];
|
||||
|
||||
// ------------------------------------------------------------ registry
|
||||
|
||||
test('the French feeds are on and quote the dataset licence field', () => {
|
||||
for (const id of FR_FEEDS) {
|
||||
const feed = getRegisteredRailFeed(id);
|
||||
assert.ok(feed, `${id} is registered`);
|
||||
assert.ok(getRailFeed(id), `${id} is pollable`);
|
||||
assert.equal(feed.defaultEnabled, true, id);
|
||||
assert.match(feed.staticGtfsUrl, /^https:\/\//, id);
|
||||
assert.ok(['lov2', 'odc-odbl', 'fr-lo'].includes(feed.terms.quote), id);
|
||||
assert.match(feed.terms.note, /transport\.data\.gouv\.fr dataset "/, id);
|
||||
assert.match(feed.terms.note, /2026-11-10/, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('the ODbL dataset carries the share-alike note and the access point conditions', () => {
|
||||
const feed = getRegisteredRailFeed('tam-montpellier');
|
||||
assert.equal(feed.license, 'ODbL 1.0');
|
||||
assert.match(feed.terms.note, /share-alike/);
|
||||
assert.match(feed.terms.note, /dans le format d'origine/);
|
||||
assert.equal(
|
||||
getRegisteredRailFeed('tbm-bordeaux').license,
|
||||
'Licence Ouverte / Open Licence 1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('positions: Bordeaux and Montpellier trams are GPS, with trip updates', () => {
|
||||
for (const id of FR_FEEDS) {
|
||||
assert.equal(railFeedPositions(getRegisteredRailFeed(id)), 'gps', id);
|
||||
assert.ok(railFeedHasTripUpdates(getRegisteredRailFeed(id)), id);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ categories
|
||||
|
||||
test('tram lines are transit; buses are dropped', () => {
|
||||
const cases = [
|
||||
['tbm-bordeaux', '164', 'transit/tram'],
|
||||
['tbm-bordeaux', '901', null],
|
||||
['tam-montpellier', '5', 'transit/tram'],
|
||||
['tam-montpellier', 'A', null],
|
||||
];
|
||||
for (const [id, route, expected] of cases)
|
||||
assert.equal(kind(id, route), expected, `${id} ${route}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ download
|
||||
|
||||
// Hosts whose robots.txt disallows the download paths French publishers use.
|
||||
const DISALLOWED = [
|
||||
/^https:\/\/eu\.ftp\.opendatasoft\.com\//,
|
||||
/^https:\/\/thello\.axelor\.com\//,
|
||||
/^https:\/\/static\.data\.gouv\.fr\/resources\//,
|
||||
/^https:\/\/www\.data\.gouv\.fr\/(fr\/)?(api\/1\/)?datasets\/r\//,
|
||||
/\/explore\/dataset\/.*\/download/,
|
||||
];
|
||||
|
||||
test('no registry URL is on a download path its robots.txt disallows', () => {
|
||||
for (const feed of RAIL_FEED_REGISTRY)
|
||||
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
|
||||
if (url)
|
||||
for (const pattern of DISALLOWED)
|
||||
assert.ok(!pattern.test(url), `${feed.id} ${url}`);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
} from './railFeeds.js';
|
||||
import {
|
||||
railAdmissionGlobalMax,
|
||||
railNaturalRequestsPerMinute,
|
||||
} from './railProxy.js';
|
||||
|
||||
/** One end of a run, as the path build's `readTripEnds` writes it. */
|
||||
const at = (name, country, state) => ({ name, country, state });
|
||||
const ends = (
|
||||
from,
|
||||
to,
|
||||
countries = [...new Set([from.country, to.country])],
|
||||
) => ({ from, to, countries });
|
||||
|
||||
/**
|
||||
* The category a timetable trip gets: the feed's route rename (when it has
|
||||
* one), its trip rename with the run's ends, then its classifier on the pack
|
||||
* route id, as the build and the browser apply them.
|
||||
*/
|
||||
function tripKind(feedId, route, run = null, trip = { trip_id: 't1' }) {
|
||||
const feed = getRegisteredRailFeed(feedId);
|
||||
const base =
|
||||
(typeof feed.staticRouteRename === 'function'
|
||||
? feed.staticRouteRename(route, { stop: () => null, stateAt: () => null })
|
||||
: null) ?? route.route_id;
|
||||
const id =
|
||||
typeof feed.staticTripRename === 'function'
|
||||
? feed.staticTripRename(trip, base, { ends: run })
|
||||
: base;
|
||||
return classifyTrain(feed, id, { tripId: trip.trip_id });
|
||||
}
|
||||
const category = (...args) => tripKind(...args)?.category ?? null;
|
||||
|
||||
test('the national feeds are on and quote their terms', () => {
|
||||
for (const id of ['ktmb', 'entur-sj-nord']) {
|
||||
assert.ok(getRailFeed(id), `${id} is pollable`);
|
||||
assert.ok(getRegisteredRailFeed(id).terms.quote, `${id} quotes its terms`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the admission cap grows with the KTMB and SJ Nord feeds', () => {
|
||||
const before = RAIL_ENABLED_FEEDS.filter(
|
||||
(feed) => !['ktmb', 'entur-sj-nord'].includes(feed.id),
|
||||
);
|
||||
assert.ok(
|
||||
railNaturalRequestsPerMinute() > railNaturalRequestsPerMinute(before),
|
||||
);
|
||||
assert.ok(railAdmissionGlobalMax() >= railAdmissionGlobalMax(before));
|
||||
});
|
||||
|
||||
test('KTMB: Komuter local, Shuttle Tebrau international, ETS and Intercity by state', () => {
|
||||
const route = (id) => ({ route_id: id, route_type: '2' });
|
||||
const kl = at('KL SENTRAL', 'MY', 'MY-14');
|
||||
const jb = at('JB SENTRAL', 'MY', 'MY-01');
|
||||
const tumpat = at('TUMPAT', 'MY', 'MY-03');
|
||||
const guaMusang = at('GUA MUSANG', 'MY', 'MY-03');
|
||||
// The simplified polygons put Padang Besar station in Thailand.
|
||||
const padangBesar = at('PADANG BESAR', 'TH', 'TH-90');
|
||||
const butterworth = at('BUTTERWORTH', 'MY', 'MY-07');
|
||||
assert.equal(category('ktmb', route('KA15_KD19'), ends(kl, kl)), 'local');
|
||||
assert.equal(category('ktmb', route('KC05_KB18')), 'local');
|
||||
assert.equal(
|
||||
category('ktmb', route('100_47300'), ends(butterworth, padangBesar)),
|
||||
'local',
|
||||
'Komuter Utara',
|
||||
);
|
||||
assert.equal(category('ktmb', { route_id: 'SS', route_type: '0' }), 'local');
|
||||
assert.equal(category('ktmb', route('ST')), 'international');
|
||||
assert.equal(category('ktmb', route('ETS'), ends(kl, jb)), 'national');
|
||||
assert.equal(
|
||||
category('ktmb', route('ETS'), ends(kl, padangBesar)),
|
||||
'national',
|
||||
'Padang Besar is in Perlis',
|
||||
);
|
||||
assert.equal(
|
||||
category('ktmb', route('ETS'), ends(kl, at('HAT YAI', 'TH', 'TH-90'))),
|
||||
'international',
|
||||
);
|
||||
assert.equal(
|
||||
category(
|
||||
'ktmb',
|
||||
route('ERT'),
|
||||
ends(at('MERAPOH', 'MY', 'MY-06'), at('WOODLANDS CIQ', 'SG', 'SG-03')),
|
||||
),
|
||||
'international',
|
||||
);
|
||||
assert.equal(
|
||||
category('ktmb', route('SH'), ends(tumpat, guaMusang)),
|
||||
'regional',
|
||||
);
|
||||
assert.equal(category('ktmb', route('SH')), 'national', 'ends unknown');
|
||||
assert.equal(category('ktmb', route('BUS1'), ends(kl, jb)), null);
|
||||
// The realtime vehicles carry no route: it comes from the schedule pack.
|
||||
assert.equal(getRegisteredRailFeed('ktmb').routesFrom, 'schedule');
|
||||
assert.equal(getRegisteredRailFeed('ktmb').pollIntervalMs, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Tri-Rail, South Florida's commuter line: switched on with its basis,
|
||||
// polled for live positions, and sorted into categories by route or by
|
||||
// train number.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
|
||||
const feed = (id) => getRegisteredRailFeed(id);
|
||||
const kind = (id, routeId, context) =>
|
||||
classifyTrain(feed(id), routeId, context);
|
||||
|
||||
test('Tri-Rail is on, with its basis', () => {
|
||||
const tri = feed('tri-rail');
|
||||
assert.ok(getRailFeed('tri-rail'), 'pollable');
|
||||
assert.ok(RAIL_ENABLED_FEEDS.includes(tri));
|
||||
assert.ok(tri.terms.quote, 'quotes its terms');
|
||||
assert.ok(tri.terms.note, 'explains its basis');
|
||||
// Used by project owner decision, which it says, naming no person.
|
||||
assert.match(tri.terms.note, /\(project owner decision, 2026-11-10\)/);
|
||||
assert.doesNotMatch(tri.terms.note, /@/);
|
||||
});
|
||||
|
||||
test('Tri-Rail: live positions with trip updates, trains by route or train number', () => {
|
||||
const tri = feed('tri-rail');
|
||||
assert.equal(railFeedPositions(tri), 'gps');
|
||||
assert.ok(railFeedHasTripUpdates(tri));
|
||||
assert.equal(tri.staticGtfsUrl, 'https://gtfs.tri-rail.com/gtfs.zip');
|
||||
const local = { category: 'local', mode: 'rail' };
|
||||
// Pack routes: Mainline and Downtown Miami Link.
|
||||
assert.deepEqual(kind('tri-rail', '1'), local);
|
||||
assert.deepEqual(kind('tri-rail', '2'), local);
|
||||
// The airport shuttle bus, should a route id ever reach the classifier.
|
||||
assert.equal(kind('tri-rail', '4'), null);
|
||||
// Realtime vehicles carry no route, only the train number.
|
||||
assert.deepEqual(kind('tri-rail', null, { tripId: 'P617' }), local);
|
||||
assert.deepEqual(kind('tri-rail', '', { tripId: 'P601X' }), local);
|
||||
assert.equal(kind('tri-rail', null, { tripId: 'FLA Loop_T01' }), null);
|
||||
assert.equal(kind('tri-rail', null, {}), null);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// A United States feed whose publisher states no terms (Charlotte LYNX):
|
||||
// switched on with the reason recorded, and sorted into categories.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
import { keepsRouteType } from '../../scripts/build-rail-paths.mjs';
|
||||
|
||||
// The feeds that publish no terms, used without a published licence by project
|
||||
// owner decision of 2026-11-10.
|
||||
const ON = ['charlotte-lynx'];
|
||||
const BASIS =
|
||||
'Publishes no terms; used without a published licence (project owner decision, 2026-11-10).';
|
||||
|
||||
const TRAM = { category: 'transit', mode: 'tram' };
|
||||
|
||||
const feed = (id) => getRegisteredRailFeed(id);
|
||||
const kind = (id, routeId, context) =>
|
||||
classifyTrain(feed(id), routeId, context);
|
||||
|
||||
test('the no-terms feed is on and says why', () => {
|
||||
for (const id of ON) {
|
||||
const entry = feed(id);
|
||||
assert.ok(getRailFeed(id), `${id} is pollable`);
|
||||
assert.ok(RAIL_ENABLED_FEEDS.includes(entry), id);
|
||||
assert.equal(entry.license, 'No licence published', id);
|
||||
assert.ok(entry.terms.note.startsWith(BASIS), id);
|
||||
// Nothing personal in the recorded basis.
|
||||
assert.doesNotMatch(entry.terms.note, /@/, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Charlotte LYNX: live light rail and streetcar with trip updates, buses dropped', () => {
|
||||
const lynx = feed('charlotte-lynx');
|
||||
assert.equal(railFeedPositions(lynx), 'gps');
|
||||
assert.ok(railFeedHasTripUpdates(lynx));
|
||||
assert.match(
|
||||
lynx.staticGtfsUrl,
|
||||
/^https:\/\/gtfsrealtime\.ridetransit\.org\//,
|
||||
);
|
||||
// Routes seen live on 2026-11-10: 501 Blue Line, 510 Gold Line, buses.
|
||||
assert.deepEqual(kind('charlotte-lynx', '501'), TRAM);
|
||||
assert.deepEqual(kind('charlotte-lynx', '510'), TRAM);
|
||||
for (const bus of ['9', '22', '5', null])
|
||||
assert.equal(kind('charlotte-lynx', bus), null, String(bus));
|
||||
});
|
||||
|
||||
test('a listed staticRouteTypes is the whole list, so trains coded as buses build', () => {
|
||||
assert.equal(keepsRouteType({}, '3'), false, 'a bus by default');
|
||||
assert.equal(keepsRouteType({}, ' 2 '), true);
|
||||
assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '3'), true);
|
||||
assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '2'), false);
|
||||
assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '0'), true);
|
||||
assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '1'), false);
|
||||
assert.equal(keepsRouteType({ staticRouteTypes: [3] }, ''), false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// United States streetcar feeds (Seattle, Cincinnati): switched on with
|
||||
// their basis, polled for live positions, and sorted into categories with
|
||||
// the buses dropped.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
|
||||
// Licensed and switched on.
|
||||
const ON = ['seattle-streetcar', 'cincinnati-connector'];
|
||||
|
||||
const TRAM = { category: 'transit', mode: 'tram' };
|
||||
|
||||
const feed = (id) => getRegisteredRailFeed(id);
|
||||
const kind = (id, routeId, context) =>
|
||||
classifyTrain(feed(id), routeId, context);
|
||||
|
||||
test('the US streetcar feeds: licensed feeds on, each with its basis', () => {
|
||||
for (const id of ON) {
|
||||
assert.ok(getRailFeed(id), `${id} is pollable`);
|
||||
assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id);
|
||||
assert.ok(feed(id).terms.note, `${id} explains its basis`);
|
||||
assert.ok(feed(id).staticGtfsUrl, `${id} has a timetable`);
|
||||
assert.ok(feed(id).terms.quote, `${id} quotes its terms`);
|
||||
// Nothing personal in the recorded basis.
|
||||
assert.doesNotMatch(feed(id).terms.note, /@/, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('the US streetcar feeds poll positions and trip updates', () => {
|
||||
for (const id of ON) {
|
||||
assert.equal(railFeedPositions(feed(id)), 'gps', id);
|
||||
assert.ok(railFeedHasTripUpdates(feed(id)), id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Seattle Streetcar: the two City of Seattle routes of the King County feed', () => {
|
||||
assert.deepEqual(kind('seattle-streetcar', '100340'), TRAM);
|
||||
assert.deepEqual(kind('seattle-streetcar', '102638'), TRAM);
|
||||
assert.equal(kind('seattle-streetcar', '100001'), null, 'a Metro bus');
|
||||
assert.equal(kind('seattle-streetcar', null), null);
|
||||
assert.equal(
|
||||
feed('seattle-streetcar').attribution,
|
||||
'Transit scheduling, geographic, and real-time data provided by permission of King County',
|
||||
);
|
||||
});
|
||||
|
||||
test('Cincinnati Connector: the streetcar route, buses dropped', () => {
|
||||
assert.deepEqual(kind('cincinnati-connector', '100'), TRAM);
|
||||
assert.equal(kind('cincinnati-connector', '78'), null);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
publicRailCatalog,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
import {
|
||||
buildRailSnapshot,
|
||||
railMaxBodyBytes,
|
||||
railUpstreamHeaders,
|
||||
} from './railProxy.js';
|
||||
|
||||
const irish = getRegisteredRailFeed('irish-rail');
|
||||
|
||||
test('Irish Rail keeps its XML positions and gains a timetable', () => {
|
||||
assert.equal(irish.format, 'irish-rail-xml');
|
||||
assert.equal(irish.positions, 'event');
|
||||
assert.ok(getRailFeed('irish-rail'));
|
||||
assert.match(irish.staticGtfsUrl, /\/transitData\/Data\/GTFS_Realtime\.zip$/);
|
||||
assert.deepEqual(irish.staticRouteTypes, [2]);
|
||||
assert.equal(irish.positionsByTrainNumber, true);
|
||||
assert.equal(irish.routesFrom, 'schedule');
|
||||
// The XML's own headers and cap.
|
||||
assert.match(railUpstreamHeaders(irish).Accept, /xml/);
|
||||
assert.equal(railMaxBodyBytes(irish), 1024 * 1024);
|
||||
// The positions publish no licence; the NTA timetable is CC BY 4.0.
|
||||
assert.equal(
|
||||
irish.license,
|
||||
'No licence published (positions); CC BY 4.0 (NTA timetable)',
|
||||
);
|
||||
assert.equal(
|
||||
irish.attribution,
|
||||
'Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is',
|
||||
);
|
||||
});
|
||||
|
||||
test('Irish Rail is placed by its XML events and serves no trip updates', () => {
|
||||
assert.equal(railFeedPositions(irish), 'event');
|
||||
assert.equal(irish.tripUpdatesUrl, undefined);
|
||||
assert.equal(railFeedHasTripUpdates(irish), false);
|
||||
// Listed without a timetable pack: the XML's train codes classify alone.
|
||||
const entry = publicRailCatalog({ schedulePackBuilt: () => false }).find(
|
||||
(feed) => feed.id === 'irish-rail',
|
||||
);
|
||||
assert.equal(entry.positions, 'event');
|
||||
assert.equal(entry.tripUpdates, false);
|
||||
});
|
||||
|
||||
test('Irish Rail pack routes: the code letter and the provinces of the ends', () => {
|
||||
const rename = (code, from, to, countries = ['IE']) =>
|
||||
irish.staticTripRename({ trip_short_name: code }, 'R-1', {
|
||||
ends: {
|
||||
from: { name: from, country: countries[0] },
|
||||
to: { name: to, country: countries.at(-1) },
|
||||
countries,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
rename('A100', 'Dublin Connolly', 'Belfast', ['IE', 'GB']),
|
||||
'A~B~R-1',
|
||||
);
|
||||
assert.equal(rename('A203', 'Cork (Kent)', 'Dublin Heuston'), 'A~X~R-1');
|
||||
assert.equal(rename('A301', 'Mallow', 'Tralee (Casement)'), 'A~S~R-1');
|
||||
assert.equal(rename('P501', 'Mallow', 'Cobh'), 'P~S~R-1');
|
||||
assert.equal(rename('E206', 'Bray (Daly)', 'Howth'), 'E~X~R-1');
|
||||
assert.equal(rename('195.MF-BH.93-GRN-y11', 'a', 'b'), null, 'no train code');
|
||||
const kind = (route) => classifyTrain(irish, route, { tripId: '6024_1' });
|
||||
assert.equal(
|
||||
kind('A~B~DUB-BFT-O').category,
|
||||
'international',
|
||||
'the Enterprise',
|
||||
);
|
||||
assert.equal(kind('A~X~DUB-CORK-O').category, 'national');
|
||||
assert.equal(kind('A~S~DUB-TRALEE-O').category, 'regional');
|
||||
assert.equal(kind('E~X~BRAY-HOWTH-I').category, 'local', 'DART');
|
||||
assert.equal(kind('P~S~MAL-COBH-O').category, 'local', 'commuter');
|
||||
assert.equal(kind('D~S~MAL-COBH-O').category, 'local');
|
||||
// The XML's own records still classify by their code and ends.
|
||||
assert.equal(
|
||||
classifyTrain(irish, 'Dublin Connolly - Belfast', { tripId: 'A100' })
|
||||
.category,
|
||||
'international',
|
||||
);
|
||||
assert.equal(
|
||||
classifyTrain(irish, 'Bray - Howth', { tripId: 'E206' }).category,
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
test('an Irish Rail train code takes the timetable run it is on now', () => {
|
||||
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus><TrainLatitude>53.1442</TrainLatitude>
|
||||
<TrainLongitude>-6.06085</TrainLongitude><TrainCode>A610</TrainCode>
|
||||
<TrainDate>09 Nov 2026</TrainDate>
|
||||
<PublicMessage>A610\\n18:37 - Dublin Connolly to Rosslare Europort (3 mins late)\\nDeparted Greystones next stop Kilcoole</PublicMessage>
|
||||
<Direction>Southbound</Direction>
|
||||
</objTrainPositions>
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus><TrainLatitude>53.35</TrainLatitude>
|
||||
<TrainLongitude>-6.25</TrainLongitude><TrainCode>E999</TrainCode>
|
||||
<TrainDate>09 Nov 2026</TrainDate>
|
||||
<PublicMessage>E999\\n19:00 - Bray to Howth (0 mins late)\\nDeparted Connolly next stop Clontarf Road</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
const pack = { '6024_8435': 'A~X~DUB-EUROPORT-I' };
|
||||
const snapshot = buildRailSnapshot(
|
||||
irish,
|
||||
new TextEncoder().encode(xml),
|
||||
1_794_251_747_000,
|
||||
{
|
||||
routeFor: (tripId) => pack[tripId] ?? null,
|
||||
tripFor: (code) => (code === 'A610' ? '6024_8435' : null),
|
||||
},
|
||||
);
|
||||
const byCode = Object.fromEntries(
|
||||
snapshot.trains.map((train) => [train.trainNumber ?? train.tripId, train]),
|
||||
);
|
||||
assert.equal(byCode.A610.tripId, '6024_8435');
|
||||
assert.equal(byCode.A610.routeId, 'A~X~DUB-EUROPORT-I');
|
||||
assert.equal(byCode.A610.category, 'national');
|
||||
assert.equal(byCode.A610.positionSource, 'event');
|
||||
assert.equal(byCode.A610.id, 'A610', 'the vehicle keeps its code');
|
||||
// Not in the timetable: its own code and ends, as before.
|
||||
assert.equal(byCode.E999.tripId, 'E999');
|
||||
assert.equal(byCode.E999.trainNumber, undefined);
|
||||
assert.equal(byCode.E999.category, 'local');
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,297 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { crc32 } from 'node:zlib';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
NO_TRAIN_POSITION_BOX,
|
||||
NO_TRAIN_POSITION_COUNTRIES,
|
||||
isNoTrainPositionPoint,
|
||||
isNoTrainPositionStop,
|
||||
stopIdCountry,
|
||||
} from './railNoPositions.js';
|
||||
import { buildRailSnapshot } from './railProxy.js';
|
||||
import { createRunIndex } from './railScheduleIndex.js';
|
||||
import {
|
||||
buildPack,
|
||||
buildSchedule,
|
||||
openZip,
|
||||
} from '../../scripts/build-rail-paths.mjs';
|
||||
|
||||
const KYIV = [50.45, 30.52];
|
||||
const LVIV = [49.84, 24.03];
|
||||
const SIMFEROPOL = [44.95, 34.1];
|
||||
const AMSTERDAM = [52.37, 4.9];
|
||||
const PRZEMYSL = [49.78, 22.77];
|
||||
const CHISINAU = [47.0, 28.86];
|
||||
|
||||
/** A fake feed: every vehicle and route is a train, so only the rule drops. */
|
||||
function fakeFeed(extra = {}) {
|
||||
return Object.freeze({
|
||||
id: 'test-no-positions',
|
||||
name: 'Test',
|
||||
format: 'gtfs-rt',
|
||||
classify: () => ({ category: 'international', mode: 'rail' }),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('the area is Ukraine, Crimea included, behind a cheap box', () => {
|
||||
assert.deepEqual(NO_TRAIN_POSITION_COUNTRIES, ['UA']);
|
||||
for (const [lat, lon] of [KYIV, LVIV, SIMFEROPOL, [48.0, 37.8]])
|
||||
assert.equal(isNoTrainPositionPoint(lat, lon), true, `${lat},${lon}`);
|
||||
for (const [lat, lon] of [AMSTERDAM, PRZEMYSL, CHISINAU, [53.9, 27.56]])
|
||||
assert.equal(isNoTrainPositionPoint(lat, lon), false, `${lat},${lon}`);
|
||||
// Outside the box nothing more is computed.
|
||||
assert.ok(NO_TRAIN_POSITION_BOX.west > 22 && NO_TRAIN_POSITION_BOX.east < 41);
|
||||
for (const bad of [NaN, null, undefined])
|
||||
assert.equal(isNoTrainPositionPoint(bad, 30), false);
|
||||
});
|
||||
|
||||
test('stations on the border fall on their own side (OpenStreetMap positions)', () => {
|
||||
// Outside Ukraine, within 1 km of the border: on the Tisa (Bocicoi), by
|
||||
// the Polish crossing (Medyka) and on the Slovak line (Čierna nad Tisou).
|
||||
for (const [name, lat, lon] of [
|
||||
['Bocicoi', 47.95743, 24.02333],
|
||||
['Valea Vișeului', 47.91463, 24.15557],
|
||||
['Sighetu Marmației', 47.9358, 23.88536],
|
||||
['Medyka', 49.80248, 22.93929],
|
||||
['Čierna nad Tisou št. hr.', 48.43209, 22.13597],
|
||||
['Záhony', 48.4017, 22.18376],
|
||||
])
|
||||
assert.equal(isNoTrainPositionPoint(lat, lon), false, name);
|
||||
// Inside Ukraine, as close to the border.
|
||||
for (const [name, lat, lon] of [
|
||||
['Uhniv', 50.37859, 23.73241],
|
||||
['Korchiv', 50.39422, 23.83512],
|
||||
['Teresva', 47.99573, 23.70047],
|
||||
['Solotvyno-Likarnia', 47.96591, 23.86276],
|
||||
])
|
||||
assert.equal(isNoTrainPositionPoint(lat, lon), true, name);
|
||||
});
|
||||
|
||||
test('a stop is left out when its id names Ukraine or its position is inside', () => {
|
||||
assert.equal(stopIdCountry('ua:22005:1:1'), 'UA');
|
||||
assert.equal(stopIdCountry('Pua:22005'), 'UA');
|
||||
assert.equal(stopIdCountry('de:09162:6'), 'DE');
|
||||
assert.equal(stopIdCountry('8000105'), null);
|
||||
// A Ukrainian stop the outline would put in Hungary (Chop, on the border).
|
||||
assert.equal(
|
||||
isNoTrainPositionStop({ id: 'ua:22005:1:1', lat: 48.43, lon: 22.1 }),
|
||||
true,
|
||||
);
|
||||
// A stop whose id names Poland, at a position the outline puts in
|
||||
// Ukraine (Shehyni, across the border from Medyka): its id does not keep
|
||||
// it.
|
||||
assert.equal(
|
||||
isNoTrainPositionStop({ id: 'pl:1:1', lat: 49.7949, lon: 22.9768 }),
|
||||
true,
|
||||
);
|
||||
// Border stations on their own side stay, by id or by position alone.
|
||||
for (const [id, lat, lon] of [
|
||||
['pl:1:2', 49.80248, 22.93929], // Medyka
|
||||
['hu:1:1', 48.4017, 22.18376], // Záhony
|
||||
['sk:1:1', 48.43209, 22.13597], // Čierna nad Tisou
|
||||
['ro:1:1', 47.95743, 24.02333], // Bocicoi
|
||||
['5001', 49.80248, 22.93929],
|
||||
])
|
||||
assert.equal(isNoTrainPositionStop({ id, lat, lon }), false, id);
|
||||
assert.equal(
|
||||
isNoTrainPositionStop({ id: '9001', lat: LVIV[0], lon: LVIV[1] }),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNoTrainPositionStop(null), false);
|
||||
});
|
||||
|
||||
/** A minimal stored (uncompressed) zip. */
|
||||
function makeZip(files) {
|
||||
const locals = [];
|
||||
const centrals = [];
|
||||
let offset = 0;
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
const data = Buffer.from(text, 'utf8');
|
||||
const nameBytes = Buffer.from(name, 'utf8');
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt32LE(crc32(data), 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt32LE(crc32(data), 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(nameBytes.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
locals.push(local, nameBytes, data);
|
||||
centrals.push(central, nameBytes);
|
||||
offset += local.length + nameBytes.length + data.length;
|
||||
}
|
||||
const cd = Buffer.concat(centrals);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(locals.length / 3, 8);
|
||||
end.writeUInt16LE(locals.length / 3, 10);
|
||||
end.writeUInt32LE(cd.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, cd, end]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wien–Budapest, Budapest–Chop (a `ua:` stop the outline would miss),
|
||||
* Budapest–Lviv (an id without a country, inside the outline) and
|
||||
* Sighet–Bocicoi (a Romanian halt on the border river, which a coarser
|
||||
* outline would place in Ukraine).
|
||||
*/
|
||||
const GTFS = {
|
||||
'agency.txt':
|
||||
'agency_id,agency_name,agency_url,agency_timezone\n1,Test,https://example.invalid,Europe/Vienna\n',
|
||||
'routes.txt': 'route_id,route_short_name,route_type\nR,EC,2\n',
|
||||
'trips.txt': [
|
||||
'route_id,service_id,trip_id,trip_short_name',
|
||||
'R,daily,vienna-budapest,EC 1',
|
||||
'R,daily,budapest-chop,EC 2',
|
||||
'R,daily,budapest-lviv,EC 3',
|
||||
'R,daily,sighet-bocicoi,R 4',
|
||||
].join('\n'),
|
||||
'stops.txt': [
|
||||
'stop_id,stop_name,stop_lat,stop_lon',
|
||||
'at:49:1,Wien Hauptbahnhof,48.185,16.376',
|
||||
'hu:55:1,Budapest-Keleti,47.500,19.083',
|
||||
'ua:22005:1:1,Chop,48.43,22.1',
|
||||
'9001,Lviv,49.84,24.03',
|
||||
'43820,Sighetu Marmaţiei,47.9358,23.88536',
|
||||
'43753,Bocicoi Hm.,47.95743,24.02333',
|
||||
].join('\n'),
|
||||
'stop_times.txt': [
|
||||
'trip_id,arrival_time,departure_time,stop_id,stop_sequence',
|
||||
'vienna-budapest,08:00:00,08:00:00,at:49:1,1',
|
||||
'vienna-budapest,10:40:00,10:40:00,hu:55:1,2',
|
||||
'budapest-chop,11:00:00,11:00:00,hu:55:1,1',
|
||||
'budapest-chop,16:00:00,16:00:00,ua:22005:1:1,2',
|
||||
'budapest-lviv,12:00:00,12:00:00,hu:55:1,1',
|
||||
'budapest-lviv,20:00:00,20:00:00,9001,2',
|
||||
'sighet-bocicoi,09:00:00,09:00:00,43820,1',
|
||||
'sighet-bocicoi,09:20:00,09:20:00,43753,2',
|
||||
].join('\n'),
|
||||
'calendar.txt': [
|
||||
'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date',
|
||||
'daily,1,1,1,1,1,1,1,20260101,20271231',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
async function buildFake(t) {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-no-pos-test-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'feed.zip');
|
||||
await writeFile(file, makeZip(GTFS));
|
||||
const zip = await openZip(file);
|
||||
t.after(() => zip.handle.close());
|
||||
const feed = fakeFeed();
|
||||
const { pack } = await buildPack(feed, zip, { url: 'x' });
|
||||
return buildSchedule(feed, zip, { url: 'x' }, pack, {
|
||||
from: '2026-11-09',
|
||||
days: 2,
|
||||
});
|
||||
}
|
||||
|
||||
test('the build leaves out every timetable trip that stops in Ukraine', async (t) => {
|
||||
const { schedule, stats } = await buildFake(t);
|
||||
assert.deepEqual(Object.keys(schedule.trips).sort(), [
|
||||
'sighet-bocicoi',
|
||||
'vienna-budapest',
|
||||
]);
|
||||
assert.equal(stats.tripsNoPositions, 2);
|
||||
assert.deepEqual(schedule.stops.map((stop) => stop.name).sort(), [
|
||||
'Bocicoi Hm.',
|
||||
'Budapest-Keleti',
|
||||
'Sighetu Marmaţiei',
|
||||
'Wien Hauptbahnhof',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a kept stop records no country', async (t) => {
|
||||
const { schedule } = await buildFake(t);
|
||||
for (const stop of schedule.stops) {
|
||||
assert.equal(Object.hasOwn(stop, 'cc'), false, stop.name);
|
||||
assert.equal(Object.hasOwn(stop, 'country'), false, stop.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('a left-out trip has no runs for a reported train to take', async (t) => {
|
||||
const { schedule } = await buildFake(t);
|
||||
const runs = createRunIndex(schedule);
|
||||
for (const tripId of ['budapest-chop', 'budapest-lviv'])
|
||||
assert.deepEqual(runs.runsOf(tripId), [], tripId);
|
||||
assert.ok(runs.runsOf('vienna-budapest').length > 0);
|
||||
});
|
||||
|
||||
/** A GTFS-RT VehiclePositions message with one vehicle per position. */
|
||||
function encodeVehicles(positions) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(3, 1_794_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
positions.forEach(([lat, lon], i) => {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, `v${i}`);
|
||||
const vehicle = new PbfWriter();
|
||||
const trip = new PbfWriter();
|
||||
trip.writeStringField(1, `trip-${i}`);
|
||||
trip.writeStringField(5, 'R');
|
||||
vehicle.writeBytesField(1, trip.finish());
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, lat);
|
||||
position.writeFloatField(2, lon);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
vehicle.writeVarintField(5, 1_793_999_990);
|
||||
entity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
});
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('a live GPS position in Ukraine is dropped and counted, never sent', () => {
|
||||
const snapshot = buildRailSnapshot(
|
||||
fakeFeed({ url: 'https://example.invalid/vp' }),
|
||||
encodeVehicles([AMSTERDAM, KYIV, SIMFEROPOL, PRZEMYSL]),
|
||||
1_794_000_000_000,
|
||||
);
|
||||
assert.equal(snapshot.count, 2);
|
||||
assert.equal(snapshot.dropped, 2);
|
||||
for (const train of snapshot.trains)
|
||||
assert.equal(isNoTrainPositionPoint(train.lat, train.lon), false);
|
||||
});
|
||||
|
||||
test('an adapter position (Irish Rail XML) in Ukraine is dropped too', () => {
|
||||
const position = (code, [lat, lon]) => `
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>${lat}</TrainLatitude>
|
||||
<TrainLongitude>${lon}</TrainLongitude>
|
||||
<TrainCode>${code}</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>${code}</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>`;
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">${position('A101', AMSTERDAM)}${position('A102', LVIV)}
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
const snapshot = buildRailSnapshot(
|
||||
fakeFeed({ format: 'irish-rail-xml', url: 'https://example.invalid/xml' }),
|
||||
new TextEncoder().encode(body),
|
||||
1_794_000_000_000,
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.trains.map((train) => train.id),
|
||||
['A101'],
|
||||
);
|
||||
assert.equal(snapshot.dropped, 1);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @module railPaths
|
||||
* @description Track geometry for the Rail layer: a railway line as an indexed
|
||||
* polyline measured by arc length, with projection of a position onto it and
|
||||
* the point at a given arc length.
|
||||
*
|
||||
* Paths come from per-feed path packs (scripts/build-rail-paths.mjs), served by
|
||||
* `/api/rail/paths/<feedId>`. A pack holds each unique shape as a flat
|
||||
* `[lat, lon, lat, lon, ...]` array with its cumulative distances in metres.
|
||||
*
|
||||
* Distances are measured in one local equirectangular frame per path, centred
|
||||
* on the path's middle latitude. Over the extent of a commuter network (a few
|
||||
* hundred kilometres) the east-west scale error stays within a few per cent,
|
||||
* which moves a projection by metres, well under what a marker shows.
|
||||
*
|
||||
* Pure: no Cesium, no DOM, no Node built-ins.
|
||||
*/
|
||||
|
||||
/** Metres per degree of latitude (and of longitude at the equator). */
|
||||
const M_PER_DEG = 111_320;
|
||||
/** Segments per bounding box in the whole-path search index. */
|
||||
const CHUNK = 32;
|
||||
|
||||
/** Longitude difference wrapped into (-180, 180]. */
|
||||
function wrapLon(delta) {
|
||||
return ((((delta + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an indexed path from a flat lat/lon array.
|
||||
* @param {ArrayLike<number>} coords `[lat, lon, lat, lon, ...]`, at least two points.
|
||||
* @param {ArrayLike<number>} [dist] Cumulative metres per point. When absent,
|
||||
* wrong-sized or decreasing, the distances are measured in the local frame.
|
||||
* @returns {object|null} The path, or null when the input is not a polyline.
|
||||
*/
|
||||
export function createRailPath(coords, dist = null) {
|
||||
const n = Math.floor((coords?.length ?? 0) / 2);
|
||||
if (n < 2) return null;
|
||||
const lat = new Float64Array(n);
|
||||
const lon = new Float64Array(n);
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = Number(coords[2 * i]);
|
||||
const o = Number(coords[2 * i + 1]);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(o) || Math.abs(a) > 90)
|
||||
return null;
|
||||
lat[i] = a;
|
||||
lon[i] = o;
|
||||
if (a < minLat) minLat = a;
|
||||
if (a > maxLat) maxLat = a;
|
||||
}
|
||||
const lat0 = (minLat + maxLat) / 2;
|
||||
const lon0 = lon[0];
|
||||
const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180);
|
||||
const ky = M_PER_DEG;
|
||||
const x = new Float64Array(n);
|
||||
const y = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
x[i] = wrapLon(lon[i] - lon0) * kx;
|
||||
y[i] = (lat[i] - lat0) * ky;
|
||||
}
|
||||
const s = new Float64Array(n);
|
||||
let given = dist?.length === n && Number(dist[0]) >= 0;
|
||||
for (let i = 0; given && i < n; i++) {
|
||||
const d = Number(dist[i]);
|
||||
if (!Number.isFinite(d) || (i > 0 && d < s[i - 1])) given = false;
|
||||
else s[i] = d;
|
||||
}
|
||||
if (!given) {
|
||||
s[0] = 0;
|
||||
for (let i = 1; i < n; i++)
|
||||
s[i] = s[i - 1] + Math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]);
|
||||
}
|
||||
const chunks = Math.ceil((n - 1) / CHUNK);
|
||||
const box = new Float64Array(chunks * 4);
|
||||
for (let c = 0; c < chunks; c++) {
|
||||
let x0 = Infinity;
|
||||
let x1 = -Infinity;
|
||||
let y0 = Infinity;
|
||||
let y1 = -Infinity;
|
||||
const end = Math.min(n - 1, (c + 1) * CHUNK);
|
||||
for (let i = c * CHUNK; i <= end; i++) {
|
||||
if (x[i] < x0) x0 = x[i];
|
||||
if (x[i] > x1) x1 = x[i];
|
||||
if (y[i] < y0) y0 = y[i];
|
||||
if (y[i] > y1) y1 = y[i];
|
||||
}
|
||||
box[4 * c] = x0;
|
||||
box[4 * c + 1] = x1;
|
||||
box[4 * c + 2] = y0;
|
||||
box[4 * c + 3] = y1;
|
||||
}
|
||||
return Object.freeze({
|
||||
n,
|
||||
lat,
|
||||
lon,
|
||||
x,
|
||||
y,
|
||||
s,
|
||||
box,
|
||||
lat0,
|
||||
lon0,
|
||||
kx,
|
||||
ky,
|
||||
length: s[n - 1],
|
||||
});
|
||||
}
|
||||
|
||||
/** Index of the segment [i, i+1] whose arc range holds `s`, clamped. */
|
||||
function segmentAt(path, s) {
|
||||
const arc = path.s;
|
||||
let lo = 0;
|
||||
let hi = path.n - 2;
|
||||
if (!(s > arc[0])) return 0;
|
||||
if (s >= arc[hi + 1]) return hi;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (arc[mid] <= s) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance from (px, py) to segment i, writing the parameter to `hit`.
|
||||
* @returns {number}
|
||||
*/
|
||||
function segmentDistance2(path, i, px, py, hit) {
|
||||
const { x, y } = path;
|
||||
const dx = x[i + 1] - x[i];
|
||||
const dy = y[i + 1] - y[i];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 > 0 ? ((px - x[i]) * dx + (py - y[i]) * dy) / len2 : 0;
|
||||
if (t < 0) t = 0;
|
||||
else if (t > 1) t = 1;
|
||||
const ex = x[i] + dx * t - px;
|
||||
const ey = y[i] + dy * t - py;
|
||||
hit.t = t;
|
||||
return ex * ex + ey * ey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a position onto a path.
|
||||
*
|
||||
* With a hint, only the segments whose arc range overlaps
|
||||
* `[hintS - windowM, hintS + windowM]` are searched: a train known to be near
|
||||
* arc `hintS` cannot jump to the far side of a loop or to a parallel branch
|
||||
* that the same shape passes later. Without one, the whole path is searched.
|
||||
*
|
||||
* @param {object} path From createRailPath.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} [hintS] Expected arc length (m).
|
||||
* @param {number} [windowM] Half-width of the search window around the hint (m).
|
||||
* @param {{s?: number, offsetM?: number, index?: number}} [out]
|
||||
* @returns {{s: number, offsetM: number, index: number}|null}
|
||||
*/
|
||||
export function projectOnPath(path, lat, lon, hintS, windowM, out = {}) {
|
||||
if (!path || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const px = wrapLon(lon - path.lon0) * path.kx;
|
||||
const py = (lat - path.lat0) * path.ky;
|
||||
const hit = { t: 0 };
|
||||
let best = Infinity;
|
||||
let bestIndex = -1;
|
||||
let bestT = 0;
|
||||
if (Number.isFinite(hintS) && Number.isFinite(windowM) && windowM >= 0) {
|
||||
const first = segmentAt(path, hintS - windowM);
|
||||
const last = segmentAt(path, hintS + windowM);
|
||||
for (let i = first; i <= last; i++) {
|
||||
const d2 = segmentDistance2(path, i, px, py, hit);
|
||||
if (d2 < best) {
|
||||
best = d2;
|
||||
bestIndex = i;
|
||||
bestT = hit.t;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { box } = path;
|
||||
const chunks = box.length / 4;
|
||||
for (let c = 0; c < chunks; c++) {
|
||||
const ox = Math.max(box[4 * c] - px, 0, px - box[4 * c + 1]);
|
||||
const oy = Math.max(box[4 * c + 2] - py, 0, py - box[4 * c + 3]);
|
||||
if (ox * ox + oy * oy >= best) continue;
|
||||
const end = Math.min(path.n - 2, (c + 1) * CHUNK - 1);
|
||||
for (let i = c * CHUNK; i <= end; i++) {
|
||||
const d2 = segmentDistance2(path, i, px, py, hit);
|
||||
if (d2 < best) {
|
||||
best = d2;
|
||||
bestIndex = i;
|
||||
bestT = hit.t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
const s = path.s;
|
||||
out.s = s[bestIndex] + (s[bestIndex + 1] - s[bestIndex]) * bestT;
|
||||
out.offsetM = Math.sqrt(best);
|
||||
out.index = bestIndex;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The position and direction of travel at an arc length, clamped to the path.
|
||||
* @param {object} path From createRailPath.
|
||||
* @param {number} s Arc length (m).
|
||||
* @param {{lat?: number, lon?: number, bearingDeg?: number}} [out]
|
||||
* @returns {{lat: number, lon: number, bearingDeg: number}|null}
|
||||
*/
|
||||
export function pointAtS(path, s, out = {}) {
|
||||
if (!path || !Number.isFinite(s)) return null;
|
||||
const clamped = Math.max(0, Math.min(path.length, s));
|
||||
let i = segmentAt(path, clamped);
|
||||
// A zero-length segment has no direction; borrow its neighbour's.
|
||||
while (i < path.n - 2 && path.s[i + 1] === path.s[i]) i++;
|
||||
const span = path.s[i + 1] - path.s[i];
|
||||
const t =
|
||||
span > 0 ? Math.max(0, Math.min(1, (clamped - path.s[i]) / span)) : 0;
|
||||
out.lat = path.lat[i] + (path.lat[i + 1] - path.lat[i]) * t;
|
||||
let lon = path.lon[i] + wrapLon(path.lon[i + 1] - path.lon[i]) * t;
|
||||
if (lon > 180) lon -= 360;
|
||||
else if (lon < -180) lon += 360;
|
||||
out.lon = lon;
|
||||
const dx = path.x[i + 1] - path.x[i];
|
||||
const dy = path.y[i + 1] - path.y[i];
|
||||
out.bearingDeg =
|
||||
dx === 0 && dy === 0
|
||||
? NaN
|
||||
: ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Path-pack format this module reads. */
|
||||
export const RAIL_PATH_PACK_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Wrap a decoded path pack for lookups. Shapes are indexed lazily, the first
|
||||
* time a train asks for one.
|
||||
* @param {object} raw The JSON body of `/api/rail/paths/<feedId>`.
|
||||
* @returns {{feedId: string, shapeCount: number,
|
||||
* pathAt: (index: number) => object|null,
|
||||
* shapeForTrip: (tripId: string) => number|null,
|
||||
* shapesForRoute: (routeId: string) => number[]}}
|
||||
*/
|
||||
export function createRailPathPack(raw) {
|
||||
if (
|
||||
!raw ||
|
||||
raw.version !== RAIL_PATH_PACK_VERSION ||
|
||||
typeof raw.feedId !== 'string' ||
|
||||
!Array.isArray(raw.shapes) ||
|
||||
!Array.isArray(raw.dist) ||
|
||||
raw.dist.length !== raw.shapes.length
|
||||
)
|
||||
throw new TypeError('Malformed rail path pack');
|
||||
const shapes = raw.shapes;
|
||||
const dist = raw.dist;
|
||||
const trips = raw.trips && typeof raw.trips === 'object' ? raw.trips : {};
|
||||
const routes = raw.routes && typeof raw.routes === 'object' ? raw.routes : {};
|
||||
const built = new Map();
|
||||
const validIndex = (value) =>
|
||||
Number.isInteger(value) && value >= 0 && value < shapes.length
|
||||
? value
|
||||
: null;
|
||||
return Object.freeze({
|
||||
feedId: raw.feedId,
|
||||
/** When the pack was built (ISO), matched by a schedule's pathsBuiltAt. */
|
||||
builtAt: typeof raw.builtAt === 'string' ? raw.builtAt : null,
|
||||
shapeCount: shapes.length,
|
||||
pathAt(index) {
|
||||
if (validIndex(index) === null) return null;
|
||||
if (!built.has(index))
|
||||
built.set(index, createRailPath(shapes[index], dist[index]));
|
||||
return built.get(index);
|
||||
},
|
||||
shapeForTrip(tripId) {
|
||||
const id = String(tripId ?? '');
|
||||
return id && Object.hasOwn(trips, id) ? validIndex(trips[id]) : null;
|
||||
},
|
||||
shapesForRoute(routeId) {
|
||||
const id = String(routeId ?? '');
|
||||
const list = id && Object.hasOwn(routes, id) ? routes[id] : null;
|
||||
return Array.isArray(list)
|
||||
? list.filter((value) => validIndex(value) !== null)
|
||||
: [];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_PATH_PACK_VERSION,
|
||||
createRailPath,
|
||||
createRailPathPack,
|
||||
pointAtS,
|
||||
projectOnPath,
|
||||
} from './railPaths.js';
|
||||
|
||||
const M_PER_DEG = 111_320;
|
||||
|
||||
/** An L: 1 km east along the equator, then 1 km north. */
|
||||
function lPath() {
|
||||
const dLon = 1000 / M_PER_DEG;
|
||||
return createRailPath([0, 0, 0, dLon, dLon, dLon]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hairpin: 2 km east along lat 0, a 100 m step north, then 2 km back west.
|
||||
* The two legs are parallel and 100 m apart, like the two sides of a loop.
|
||||
*/
|
||||
function hairpin() {
|
||||
const d = 1 / M_PER_DEG;
|
||||
return createRailPath([0, 0, 0, 2000 * d, 100 * d, 2000 * d, 100 * d, 0]);
|
||||
}
|
||||
|
||||
test('a path measures its arc length in metres', () => {
|
||||
const path = lPath();
|
||||
assert.equal(path.n, 3);
|
||||
assert.ok(Math.abs(path.length - 2000) < 0.5, String(path.length));
|
||||
assert.equal(createRailPath([0, 0]), null, 'one point is not a path');
|
||||
assert.equal(createRailPath([0, 0, Number.NaN, 1]), null);
|
||||
assert.equal(createRailPath(null), null);
|
||||
});
|
||||
|
||||
test('given distances are used, and bad ones are measured instead', () => {
|
||||
const coords = [0, 0, 0, 0.01, 0, 0.02];
|
||||
assert.deepEqual([...createRailPath(coords, [0, 5, 10]).s], [0, 5, 10]);
|
||||
const measured = createRailPath(coords, [0, 10, 5]);
|
||||
assert.ok(Math.abs(measured.s[2] - 2 * 0.01 * M_PER_DEG) < 0.5);
|
||||
const wrongSize = createRailPath(coords, [0, 1]);
|
||||
assert.ok(wrongSize.s[1] > 1000);
|
||||
});
|
||||
|
||||
test('projection finds the arc and the offset from the line', () => {
|
||||
const path = lPath();
|
||||
const d = 1 / M_PER_DEG;
|
||||
const hit = projectOnPath(path, 20 * d, 500 * d);
|
||||
assert.ok(Math.abs(hit.s - 500) < 0.5, String(hit.s));
|
||||
assert.ok(Math.abs(hit.offsetM - 20) < 0.5, String(hit.offsetM));
|
||||
assert.equal(hit.index, 0);
|
||||
const second = projectOnPath(path, 300 * d, 1030 * d);
|
||||
assert.ok(Math.abs(second.s - 1300) < 0.5, String(second.s));
|
||||
assert.ok(Math.abs(second.offsetM - 30) < 0.5);
|
||||
assert.equal(second.index, 1);
|
||||
const beyond = projectOnPath(path, 2000 * d, 1000 * d);
|
||||
assert.ok(Math.abs(beyond.s - 2000) < 0.5, 'clamped to the end');
|
||||
assert.ok(Math.abs(beyond.offsetM - 1000) < 1);
|
||||
assert.equal(projectOnPath(path, Number.NaN, 0), null);
|
||||
assert.equal(projectOnPath(null, 0, 0), null);
|
||||
});
|
||||
|
||||
test('a hint keeps the projection on its own side of a loop', () => {
|
||||
const path = hairpin();
|
||||
const d = 1 / M_PER_DEG;
|
||||
// 45 m north of the outbound leg, 55 m south of the return leg: the
|
||||
// whole-path answer is the outbound leg...
|
||||
const point = [45 * d, 1000 * d];
|
||||
const free = projectOnPath(path, ...point);
|
||||
assert.ok(Math.abs(free.s - 1000) < 1, String(free.s));
|
||||
// ...but a train known to be on the return leg stays on it.
|
||||
const hinted = projectOnPath(path, ...point, 3100, 500);
|
||||
assert.ok(Math.abs(hinted.s - 3100) < 1, String(hinted.s));
|
||||
assert.ok(Math.abs(hinted.offsetM - 55) < 0.5);
|
||||
});
|
||||
|
||||
test('the whole-path search agrees with a brute-force scan on a long path', () => {
|
||||
const coords = [];
|
||||
for (let i = 0; i < 500; i++)
|
||||
coords.push(40 + Math.sin(i / 20) * 0.01, -74 + i * 0.001);
|
||||
const path = createRailPath(coords);
|
||||
for (const [lat, lon] of [
|
||||
[40.004, -73.9],
|
||||
[40.02, -73.75],
|
||||
[39.99, -73.6],
|
||||
]) {
|
||||
const fast = projectOnPath(path, lat, lon);
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < path.n - 1; i++) {
|
||||
const hit = projectOnPath(
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
(path.s[i] + path.s[i + 1]) / 2,
|
||||
0,
|
||||
);
|
||||
best = Math.min(best, hit.offsetM);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(fast.offsetM - best) < 1e-6,
|
||||
`${fast.offsetM} vs ${best}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the point at an arc length carries the direction of travel', () => {
|
||||
const path = lPath();
|
||||
const d = 1 / M_PER_DEG;
|
||||
const east = pointAtS(path, 250);
|
||||
assert.ok(Math.abs(east.lon - 250 * d) < 1e-9);
|
||||
assert.equal(east.lat, 0);
|
||||
assert.ok(Math.abs(east.bearingDeg - 90) < 1e-6, String(east.bearingDeg));
|
||||
const north = pointAtS(path, 1500);
|
||||
assert.ok(Math.abs(north.lat - 500 * d) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(north.bearingDeg - 0) < 1e-6 ||
|
||||
Math.abs(north.bearingDeg - 360) < 1e-6,
|
||||
);
|
||||
const out = {};
|
||||
assert.equal(pointAtS(path, -50, out), out, 'writes into the caller scratch');
|
||||
assert.equal(out.lat, 0);
|
||||
assert.equal(out.lon, 0);
|
||||
assert.ok(Math.abs(pointAtS(path, 9e9).lat - 1000 * d) < 1e-9);
|
||||
assert.equal(pointAtS(path, Number.NaN), null);
|
||||
});
|
||||
|
||||
test('a pack resolves trips and routes to shapes and builds paths lazily', () => {
|
||||
const raw = {
|
||||
version: RAIL_PATH_PACK_VERSION,
|
||||
feedId: 'grt-ion',
|
||||
shapes: [
|
||||
[43.4, -80.5, 43.41, -80.5],
|
||||
[43.4, -80.5, 43.4, -80.49],
|
||||
],
|
||||
dist: [
|
||||
[0, 1113.2],
|
||||
[0, 808.9],
|
||||
],
|
||||
trips: { t1: 0, t2: 1, bad: 7 },
|
||||
routes: { 301: [0, 1, 9] },
|
||||
};
|
||||
const pack = createRailPathPack(raw);
|
||||
assert.equal(pack.feedId, 'grt-ion');
|
||||
assert.equal(pack.shapeCount, 2);
|
||||
assert.equal(pack.shapeForTrip('t1'), 0);
|
||||
assert.equal(pack.shapeForTrip('bad'), null, 'out-of-range index');
|
||||
assert.equal(pack.shapeForTrip('constructor'), null, 'no prototype keys');
|
||||
assert.equal(pack.shapeForTrip(''), null);
|
||||
assert.deepEqual(pack.shapesForRoute('301'), [0, 1]);
|
||||
assert.deepEqual(pack.shapesForRoute('302'), []);
|
||||
const path = pack.pathAt(1);
|
||||
assert.equal(path.length, 808.9);
|
||||
assert.equal(pack.pathAt(1), path, 'built once');
|
||||
assert.equal(pack.pathAt(5), null);
|
||||
for (const broken of [
|
||||
null,
|
||||
{ ...raw, version: 2 },
|
||||
{ ...raw, feedId: 3 },
|
||||
{ ...raw, dist: [] },
|
||||
])
|
||||
assert.throws(() => createRailPathPack(broken), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @module railProxy
|
||||
* @description Pure server-side mechanics for the `/api/rail` proxy.
|
||||
*
|
||||
* The upstream mechanics are the Transit proxy's (src/data/transitProxy.js):
|
||||
* the same decoder, timestamp repair, redirect rule, conditional headers, cache
|
||||
* windows and backoff ladder. What differs is which feeds are reachable and
|
||||
* what leaves the server: only vehicles a feed's classifier recognises as
|
||||
* trains, each tagged with its category, mode and position source. On top of
|
||||
* that come the Irish Rail XML and SIRI-VM adapters and trip updates. Every
|
||||
* train is placed where its operator reports it. Every feed is open: no
|
||||
* request carries a credential.
|
||||
*/
|
||||
|
||||
import {
|
||||
TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
TRANSIT_PROXY_MAX_BODY_BYTES,
|
||||
TRANSIT_PROXY_STALE_MAX_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
TransitFeedShapeError,
|
||||
buildTransitSnapshot,
|
||||
} from './transitProxy.js';
|
||||
import { GTFS_INCREMENTALITY_FULL_DATASET } from './gtfsRealtime.js';
|
||||
import {
|
||||
RAIL_FEED_REGISTRY,
|
||||
classifyTrain,
|
||||
getRailFeed,
|
||||
railFeedHasTripUpdates,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
import { decodeIrishRailTrains } from './irishRail.js';
|
||||
import { decodeSiriVehicleMonitoring } from './railSiriVm.js';
|
||||
import { decodeTripUpdates } from './railTripUpdates.js';
|
||||
import { isNoTrainPositionPoint } from './railNoPositions.js';
|
||||
|
||||
/**
|
||||
* Text formats with their own adapter, by registry `format`. Each turns the
|
||||
* document and the fetch time (epoch s) into `{entityCount, truncated,
|
||||
* vehicles, feedTimestamp?}`.
|
||||
*/
|
||||
const TEXT_ADAPTERS = Object.freeze({
|
||||
'irish-rail-xml': decodeIrishRailTrains,
|
||||
'siri-vm': decodeSiriVehicleMonitoring,
|
||||
});
|
||||
|
||||
export {
|
||||
TRANSIT_ADMISSION_MAX_PER_FEED as RAIL_ADMISSION_MAX_PER_FEED,
|
||||
TRANSIT_ADMISSION_WINDOW_MS as RAIL_ADMISSION_WINDOW_MS,
|
||||
TRANSIT_PROXY_TIMEOUT_MS as RAIL_PROXY_TIMEOUT_MS,
|
||||
TransitFeedShapeError as RailFeedShapeError,
|
||||
isAcceptableTransitUpstreamUrl as isAcceptableRailUpstreamUrl,
|
||||
nextTransitBackoffMs as nextRailBackoffMs,
|
||||
} from './transitProxy.js';
|
||||
|
||||
/**
|
||||
* Largest path or schedule pack the proxy serves or reads (bytes). The
|
||||
* biggest national packs built so far were 14 to 16 MB; anything far beyond
|
||||
* that is a build fault, not a pack to ship. A pack over the cap is refused
|
||||
* with its own answer, not taken for a missing one.
|
||||
*/
|
||||
export const RAIL_PATH_PACK_MAX_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Default freshness window for trip updates. Predictions move more slowly
|
||||
* than positions, and several operators refresh them every 30 to 60 s.
|
||||
*/
|
||||
export const RAIL_TRIP_UPDATES_TTL_MS = 30_000;
|
||||
/** Shortest window a registry entry may ask for. */
|
||||
export const RAIL_MIN_TTL_MS = 5_000;
|
||||
/** Trips one `/updates` answer carries, after the rail filter. */
|
||||
export const RAIL_UPDATES_MAX_TRIPS = 3_000;
|
||||
/** Stop updates one `/updates` answer carries, across all its trips. */
|
||||
export const RAIL_UPDATES_MAX_STOPS = 40_000;
|
||||
|
||||
/**
|
||||
* Freshness window for one route of a feed: its own interval when the
|
||||
* registry sets one (never below RAIL_MIN_TTL_MS), else the default.
|
||||
* @param {object} feed
|
||||
* @param {'trains'|'updates'} route
|
||||
* @returns {number} ms
|
||||
*/
|
||||
export function railFeedTtlMs(feed, route = 'trains') {
|
||||
const own =
|
||||
route === 'updates' ? feed?.tripUpdatesIntervalMs : feed?.pollIntervalMs;
|
||||
if (Number.isFinite(own) && own > 0) return Math.max(RAIL_MIN_TTL_MS, own);
|
||||
return route === 'updates' ? RAIL_TRIP_UPDATES_TTL_MS : TRANSIT_PROXY_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest byte cap a feed may set for itself (`maxBodyBytes`): nothing
|
||||
* larger is read into memory.
|
||||
*/
|
||||
export const RAIL_PROXY_MAX_BODY_CEILING_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upstream byte cap for a feed: its own cap when it sets one (never above
|
||||
* RAIL_PROXY_MAX_BODY_CEILING_BYTES), else the Transit proxy's 8 MiB.
|
||||
* @param {object} feed
|
||||
* @returns {number}
|
||||
*/
|
||||
export function railMaxBodyBytes(feed) {
|
||||
const own = feed?.maxBodyBytes;
|
||||
return Number.isFinite(own) && own > 0
|
||||
? Math.min(own, RAIL_PROXY_MAX_BODY_CEILING_BYTES)
|
||||
: TRANSIT_PROXY_MAX_BODY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry against a freshness window: the Transit policy with
|
||||
* a per-feed TTL. The serve-stale window is the same ten minutes.
|
||||
* @param {{at: number}|null|undefined} entry
|
||||
* @param {number} now
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function railCacheStateFor(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < ttlMs) return 'fresh';
|
||||
if (age < Math.max(TRANSIT_PROXY_STALE_MAX_MS, ttlMs)) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `/trains/<feedId>`, `/paths/<feedId>`, `/schedule/<feedId>` or
|
||||
* `/updates/<feedId>` (the path after the `/api/rail` mount) to a pollable
|
||||
* feed. Anything else (another route, an unknown or disabled id, an
|
||||
* `/updates` request for a feed with no trip updates, path tricks) resolves
|
||||
* to null and the caller 404s.
|
||||
* @param {string} url Request URL relative to the mount point.
|
||||
* @returns {{ route: 'feeds' } |
|
||||
* { route: 'trains'|'paths'|'schedule'|'updates', feed: object } | null}
|
||||
*/
|
||||
export function resolveRailRoute(url) {
|
||||
const pathname = String(url || '').split('?')[0];
|
||||
if (pathname === '/feeds' || pathname === '/feeds/')
|
||||
return { route: 'feeds' };
|
||||
const match = /^\/(trains|paths|schedule|updates)\/([^/]+)\/?$/.exec(
|
||||
pathname,
|
||||
);
|
||||
if (!match) return null;
|
||||
let id;
|
||||
try {
|
||||
id = decodeURIComponent(match[2]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const feed = getRailFeed(id);
|
||||
if (!feed) return null;
|
||||
if (match[1] === 'updates' && !railFeedHasTripUpdates(feed)) return null;
|
||||
return { route: match[1], feed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify decoded vehicles and keep only trains. A position where no train
|
||||
* is placed (src/data/railNoPositions.js: Ukraine) is dropped first, so it
|
||||
* is only counted and never leaves the server.
|
||||
*/
|
||||
function keepTrains(feed, vehicles, routeFor = null) {
|
||||
const positionSource = railFeedPositions(feed) === 'event' ? 'event' : 'gps';
|
||||
const trains = [];
|
||||
for (const vehicle of vehicles) {
|
||||
if (isNoTrainPositionPoint(vehicle.lat, vehicle.lon)) continue;
|
||||
const routeId = resolvedRouteId(feed, vehicle, routeFor);
|
||||
const kind = classifyTrain(feed, routeId, { tripId: vehicle.tripId });
|
||||
if (!kind) continue;
|
||||
trains.push({
|
||||
...vehicle,
|
||||
routeId,
|
||||
category: kind.category,
|
||||
mode: kind.mode,
|
||||
...(kind.tourist ? { tourist: true } : {}),
|
||||
positionSource,
|
||||
});
|
||||
}
|
||||
return trains;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route a record is classified by. A feed with `routesFrom: 'schedule'`
|
||||
* publishes trips whose route is missing or not the one its classifier reads;
|
||||
* `routeFor(tripId)` answers from the feed's schedule pack, whose route ids
|
||||
* the build wrote to carry the product. A trip the pack does not know keeps
|
||||
* what the feed said, which its classifier then drops.
|
||||
*/
|
||||
function resolvedRouteId(feed, record, routeFor) {
|
||||
if (feed?.routesFrom !== 'schedule' || typeof routeFor !== 'function')
|
||||
return record.routeId;
|
||||
return (record.tripId && routeFor(record.tripId)) || record.routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a feed whose positions name each train by its number
|
||||
* (`positionsByTrainNumber`, Irish Rail's train codes), the record takes
|
||||
* the schedule pack's trip that number is running now (`tripFor`, given the
|
||||
* number and the record, whose stated delay moves its timetable), so the
|
||||
* train is drawn on its line and its card lists its stops; the number stays
|
||||
* as `trainNumber`. A number the timetable does not place keeps its own id.
|
||||
*/
|
||||
function byTrainNumber(feed, vehicles, tripFor) {
|
||||
if (feed?.positionsByTrainNumber !== true || typeof tripFor !== 'function')
|
||||
return vehicles;
|
||||
return vehicles.map((vehicle) => {
|
||||
const tripId = vehicle.tripId ? tripFor(vehicle.tripId, vehicle) : null;
|
||||
return tripId
|
||||
? { ...vehicle, tripId, trainNumber: vehicle.tripId }
|
||||
: vehicle;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For a feed whose header clock cannot be trusted (`headerClock:
|
||||
* 'untrusted'`, Renfe: local time written as UTC), a time the Transit
|
||||
* ladder took from the header is replaced by the fetch time and labelled so.
|
||||
*/
|
||||
function distrustHeaderClock(feed, vehicles, now) {
|
||||
if (feed?.headerClock !== 'untrusted') return vehicles;
|
||||
const fetchedS = Math.floor(now / 1000);
|
||||
return vehicles.map((vehicle) =>
|
||||
vehicle.timestampSource === 'header'
|
||||
? { ...vehicle, timestamp: fetchedS, timestampSource: 'fetch' }
|
||||
: vehicle,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upstream answer that is no document of the feed's: a failed fetch, not
|
||||
* a reason to distrust the last good snapshot, which the service keeps
|
||||
* serving while it is young enough (`code: 'UPSTREAM_UNREADABLE'`, a 502).
|
||||
* @param {string} detail
|
||||
* @returns {Error & {code: string}}
|
||||
*/
|
||||
function unreadableUpstream(detail) {
|
||||
const failure = new Error(`upstream document unreadable: ${detail}`);
|
||||
failure.code = 'UPSTREAM_UNREADABLE';
|
||||
return failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a GTFS-RT decode and refuse what is not a FeedMessage: bytes protobuf
|
||||
* cannot read (an HTML or JSON error page served as 200), and a message
|
||||
* without a FeedHeader or its gtfs_realtime_version, which every
|
||||
* FeedMessage carries. An empty body (a 200 or 204 with nothing in it)
|
||||
* decodes to exactly that, and would otherwise read as a full snapshot with
|
||||
* no trains. A DIFFERENTIAL feed's refusal passes through as it is.
|
||||
* @template {{version: string|null}} T
|
||||
* @param {() => T} decode
|
||||
* @returns {T}
|
||||
*/
|
||||
function decodeGtfsRealtime(decode) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decode();
|
||||
} catch (error) {
|
||||
if (error instanceof TransitFeedShapeError) throw error;
|
||||
throw unreadableUpstream(error?.message || String(error));
|
||||
}
|
||||
if (decoded?.version === null || decoded?.version === undefined)
|
||||
throw unreadableUpstream('no FeedHeader with a gtfs_realtime_version');
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode upstream bytes into the JSON snapshot the Rail layer consumes.
|
||||
*
|
||||
* For GTFS-RT feeds, decoding, the refusal of DIFFERENTIAL feeds and the
|
||||
* report-time ladder are the Transit proxy's; a body that is no FeedMessage
|
||||
* (no header, no version, not protobuf) is refused as unreadable
|
||||
* (decodeGtfsRealtime). Irish Rail's XML and SIRI-VM go through their
|
||||
* adapters. The classifier then runs on every vehicle: a vehicle it does
|
||||
* not recognise (a bus in a mixed feed, a shuttle, an out-of-service vehicle
|
||||
* with no route in a mixed feed) is dropped and only counted, so the browser
|
||||
* receives trains and nothing else. Every train carries `positionSource`:
|
||||
* `gps` for GTFS-RT positions, `event` for signalling events.
|
||||
*
|
||||
* @param {object} feed Rail registry entry.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Raw upstream body.
|
||||
* @param {number} [now=Date.now()] Fetch time (ms epoch).
|
||||
* @param {{routeFor?: Function|null, tripFor?: Function|null}} [lookups]
|
||||
* `routeFor` resolves a trip's route from the schedule pack, and
|
||||
* `tripFor(number, record)` a train number's running trip
|
||||
* (`positionsByTrainNumber`).
|
||||
* @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null,
|
||||
* version: string|null, entityCount: number, truncated: boolean, count: number,
|
||||
* dropped: number, positions: string, trains: object[] }}
|
||||
*/
|
||||
export function buildRailSnapshot(
|
||||
feed,
|
||||
bytes,
|
||||
now = Date.now(),
|
||||
{ routeFor = null, tripFor = null } = {},
|
||||
) {
|
||||
const adapter = Object.hasOwn(TEXT_ADAPTERS, feed?.format)
|
||||
? TEXT_ADAPTERS[feed.format]
|
||||
: null;
|
||||
if (adapter) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const text = new TextDecoder('utf-8').decode(view);
|
||||
let decoded;
|
||||
try {
|
||||
decoded = adapter(text, Math.floor(now / 1000));
|
||||
} catch (error) {
|
||||
// An unreadable document (an error page served as 200, a truncated
|
||||
// body) is a failed fetch, not a reason to distrust the last good one.
|
||||
throw unreadableUpstream(error?.message || String(error));
|
||||
}
|
||||
const trains = keepTrains(
|
||||
feed,
|
||||
byTrainNumber(feed, decoded.vehicles, tripFor),
|
||||
routeFor,
|
||||
);
|
||||
return {
|
||||
feedId: feed.id,
|
||||
name: feed.name,
|
||||
fetchedAt: now,
|
||||
feedTimestamp: Number.isFinite(decoded.feedTimestamp)
|
||||
? decoded.feedTimestamp
|
||||
: null,
|
||||
version: null,
|
||||
entityCount: decoded.entityCount,
|
||||
truncated: decoded.truncated,
|
||||
count: trains.length,
|
||||
dropped: decoded.vehicles.length - trains.length,
|
||||
positions: railFeedPositions(feed),
|
||||
trains,
|
||||
};
|
||||
}
|
||||
const decoded = decodeGtfsRealtime(() =>
|
||||
buildTransitSnapshot(feed, bytes, now),
|
||||
);
|
||||
const trains = keepTrains(
|
||||
feed,
|
||||
distrustHeaderClock(feed, decoded.vehicles, now),
|
||||
routeFor,
|
||||
);
|
||||
return {
|
||||
feedId: decoded.feedId,
|
||||
name: decoded.name,
|
||||
fetchedAt: decoded.fetchedAt,
|
||||
feedTimestamp:
|
||||
feed.headerClock === 'untrusted' ? null : decoded.feedTimestamp,
|
||||
version: decoded.version,
|
||||
entityCount: decoded.entityCount,
|
||||
truncated: decoded.truncated,
|
||||
count: trains.length,
|
||||
dropped: decoded.vehicles.length - trains.length,
|
||||
positions: railFeedPositions(feed),
|
||||
trains,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a TripUpdates feed into the `/api/rail/updates/<feedId>` body: one
|
||||
* record per rail trip, filtered with the same classifier as the positions
|
||||
* (a feed's buses never leave the server), capped in trips and stops.
|
||||
*
|
||||
* A DIFFERENTIAL feed is refused, for the same reason positions are: an
|
||||
* unmentioned trip would read as one with no prediction. A body that is no
|
||||
* FeedMessage is refused as unreadable (decodeGtfsRealtime).
|
||||
*
|
||||
* @param {object} feed Rail registry entry.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage.
|
||||
* @param {number} [now=Date.now()] Fetch time (ms epoch).
|
||||
* @param {{routeFor?: ((tripId: string) => string|null)|null}} [lookups]
|
||||
* `routeFor` is the schedule pack's trip-to-route lookup, for a feed
|
||||
* whose trips do not carry the route its classifier reads (`routesFrom:
|
||||
* 'schedule'`).
|
||||
* @returns {{ feedId: string, name: string, fetchedAt: number,
|
||||
* feedTimestamp: number|null, entityCount: number, truncated: boolean,
|
||||
* count: number, dropped: number, trips: object[] }}
|
||||
*/
|
||||
export function buildRailTripUpdatesSnapshot(
|
||||
feed,
|
||||
bytes,
|
||||
now = Date.now(),
|
||||
{ routeFor = null } = {},
|
||||
) {
|
||||
const decoded = decodeGtfsRealtime(() => decodeTripUpdates(bytes));
|
||||
if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) {
|
||||
throw new TransitFeedShapeError(
|
||||
`feed is differential (incrementality ${decoded.incrementality})`,
|
||||
'differential',
|
||||
);
|
||||
}
|
||||
const trips = [];
|
||||
let truncated = decoded.truncated;
|
||||
let stops = 0;
|
||||
let rail = 0;
|
||||
for (const trip of decoded.trips) {
|
||||
const routeId = resolvedRouteId(feed, trip, routeFor);
|
||||
const kind = classifyTrain(feed, routeId, { tripId: trip.tripId });
|
||||
if (!kind) continue;
|
||||
rail += 1;
|
||||
if (
|
||||
trips.length >= RAIL_UPDATES_MAX_TRIPS ||
|
||||
stops + trip.stops.length > RAIL_UPDATES_MAX_STOPS
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
stops += trip.stops.length;
|
||||
trips.push({
|
||||
...trip,
|
||||
routeId,
|
||||
category: kind.category,
|
||||
mode: kind.mode,
|
||||
...(kind.tourist ? { tourist: true } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
feedId: feed.id,
|
||||
name: feed.name,
|
||||
fetchedAt: now,
|
||||
feedTimestamp: decoded.timestamp,
|
||||
entityCount: decoded.entityCount,
|
||||
truncated,
|
||||
count: trips.length,
|
||||
dropped: decoded.trips.length - rail,
|
||||
trips,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream request headers. Same obligations as Transit's (conditional
|
||||
* validators, gzip, a User-Agent that says who is asking, any identify-yourself
|
||||
* header the registry names), with this proxy's own name in the User-Agent.
|
||||
* A feed's own `headers` (an Accept for an XML feed) come last.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {{etag?: string|null, lastModified?: string|null}} [validators]
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function railUpstreamHeaders(feed, validators = null) {
|
||||
return {
|
||||
'User-Agent':
|
||||
'gods-eye-view-rail-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1',
|
||||
'Accept-Encoding': 'gzip',
|
||||
...(validators?.etag ? { 'If-None-Match': validators.etag } : {}),
|
||||
...(validators?.lastModified
|
||||
? { 'If-Modified-Since': validators.lastModified }
|
||||
: {}),
|
||||
...(feed?.headers || {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream requests one minute may need when every feed in the registry is
|
||||
* polled at once: one per freshness window for each feed's positions and
|
||||
* trip updates.
|
||||
* @param {ReadonlyArray<object>} [feeds]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function railNaturalRequestsPerMinute(feeds = RAIL_FEED_REGISTRY) {
|
||||
let total = 0;
|
||||
for (const feed of feeds) {
|
||||
if (feed?.defaultEnabled !== true) continue;
|
||||
if (feed.url) total += Math.ceil(60_000 / railFeedTtlMs(feed, 'trains'));
|
||||
if (railFeedHasTripUpdates(feed))
|
||||
total += Math.ceil(60_000 / railFeedTtlMs(feed, 'updates'));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-wide admission cap (requests per admission window, all
|
||||
* operators): the natural load of every enabled feed, half again on top for
|
||||
* retries on the backoff ladder, never below the Transit proxy's own cap
|
||||
* (TRANSIT_ADMISSION_MAX_GLOBAL, 40). It is a backstop against a bug or a
|
||||
* runaway client, not the protection an operator relies on: that is each
|
||||
* feed's freshness window and the per-feed admission count. Transit's fixed
|
||||
* cap alone sat below the natural load once the registry passed about ten
|
||||
* feeds polled every 15 s, so a viewer of several regions got 503s from a
|
||||
* healthy proxy.
|
||||
* @param {ReadonlyArray<object>} [feeds]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function railAdmissionGlobalMax(feeds = RAIL_FEED_REGISTRY) {
|
||||
return Math.max(
|
||||
TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
Math.ceil(railNaturalRequestsPerMinute(feeds) * 1.5),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies
|
||||
* (HIT / MISS / INFLIGHT / STALE-ERROR); `X-Rail-Contact` is when the operator
|
||||
* last ANSWERED, which a revalidated (304) feed advances without changing the
|
||||
* body's own fetch time. `max-age` is what is left of the feed's own
|
||||
* freshness window once the snapshot is `ageMs` old, so a HIT near the end
|
||||
* of the window is not kept a whole window more downstream.
|
||||
* @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState
|
||||
* @param {string} [upstreamHost]
|
||||
* @param {number|null} [contactedAt]
|
||||
* @param {number} [ttlMs]
|
||||
* @param {number} [ageMs] How old the snapshot is (ms); 0 for a fresh one.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function railResponseHeaders(
|
||||
cacheState,
|
||||
upstreamHost = '',
|
||||
contactedAt = null,
|
||||
ttlMs = TRANSIT_PROXY_TTL_MS,
|
||||
ageMs = 0,
|
||||
) {
|
||||
const age = Number.isFinite(ageMs) && ageMs > 0 ? ageMs : 0;
|
||||
return {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control':
|
||||
cacheState === 'STALE-ERROR'
|
||||
? 'no-store'
|
||||
: `public, max-age=${Math.max(0, Math.ceil((ttlMs - age) / 1000))}`,
|
||||
'X-GEV-Cache': cacheState,
|
||||
...(upstreamHost ? { 'X-Rail-Upstream': upstreamHost } : {}),
|
||||
...(Number.isFinite(contactedAt)
|
||||
? { 'X-Rail-Contact': String(Math.floor(contactedAt)) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
RAIL_UPDATES_MAX_TRIPS,
|
||||
RailFeedShapeError,
|
||||
buildRailSnapshot,
|
||||
buildRailTripUpdatesSnapshot,
|
||||
railAdmissionGlobalMax,
|
||||
railNaturalRequestsPerMinute,
|
||||
railCacheStateFor,
|
||||
railFeedTtlMs,
|
||||
railMaxBodyBytes,
|
||||
railResponseHeaders,
|
||||
railUpstreamHeaders,
|
||||
resolveRailRoute,
|
||||
} from './railProxy.js';
|
||||
import {
|
||||
RAIL_FEED_REGISTRY,
|
||||
getRailFeed,
|
||||
getRegisteredRailFeed,
|
||||
railFeedPositions,
|
||||
} from './railFeeds.js';
|
||||
|
||||
/** A minimal TripUpdates FeedMessage: `[tripId, routeId, delay]` rows. */
|
||||
function encodeTripUpdates(rows, { incrementality = null } = {}) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
if (incrementality !== null) header.writeVarintField(2, incrementality);
|
||||
header.writeVarintField(3, 1_794_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const [tripId, routeId, delay, stopCount = 1] of rows) {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, tripId);
|
||||
const update = new PbfWriter();
|
||||
const trip = new PbfWriter();
|
||||
trip.writeStringField(1, tripId);
|
||||
if (routeId) trip.writeStringField(5, routeId);
|
||||
update.writeBytesField(1, trip.finish());
|
||||
for (let i = 0; i < stopCount; i += 1) {
|
||||
const stop = new PbfWriter();
|
||||
stop.writeVarintField(1, i + 1);
|
||||
const arrival = new PbfWriter();
|
||||
arrival.writeVarintField(1, delay);
|
||||
stop.writeBytesField(2, arrival.finish());
|
||||
stop.writeStringField(4, `s${i}`);
|
||||
update.writeBytesField(2, stop.finish());
|
||||
}
|
||||
entity.writeBytesField(3, update.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
const IRISH_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>54.0</TrainLatitude>
|
||||
<TrainLongitude>-6.4</TrainLongitude>
|
||||
<TrainCode>A118</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>A118\\n14:50 - Dublin Connolly to Belfast (0 mins late)\\nDeparted Dundalk next stop Newry</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>53.29</TrainLatitude>
|
||||
<TrainLongitude>-6.13</TrainLongitude>
|
||||
<TrainCode>E931</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>E931\\n15:03 - Greystones to Howth (16 mins late)\\nDeparted Sutton next stop Howth</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
|
||||
/** A minimal FeedMessage whose vehicles carry a trip route id. */
|
||||
function encodeFeed({ incrementality = null, entities = [] }) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
if (incrementality !== null) header.writeVarintField(2, incrementality);
|
||||
header.writeVarintField(3, 1_700_000_000);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const entity of entities) {
|
||||
const feedEntity = new PbfWriter();
|
||||
feedEntity.writeStringField(1, entity.id);
|
||||
const vehicle = new PbfWriter();
|
||||
if (entity.route) {
|
||||
const trip = new PbfWriter();
|
||||
trip.writeStringField(1, `trip-${entity.id}`);
|
||||
trip.writeStringField(5, entity.route);
|
||||
vehicle.writeBytesField(1, trip.finish());
|
||||
}
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, entity.lat ?? 42.35);
|
||||
position.writeFloatField(2, entity.lon ?? -71.06);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
vehicle.writeVarintField(5, 1_699_999_990);
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, entity.id);
|
||||
// A vendor extension (field 1999), as Adelaide Metro sends: it must be
|
||||
// skipped, not fail the snapshot.
|
||||
const extension = new PbfWriter();
|
||||
extension.writeVarintField(1, 1);
|
||||
descriptor.writeBytesField(1999, extension.finish());
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
feedEntity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, feedEntity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('route resolution admits only the catalog and enabled rail feeds', () => {
|
||||
assert.deepEqual(resolveRailRoute('/feeds'), { route: 'feeds' });
|
||||
assert.deepEqual(resolveRailRoute('/feeds/?x=1'), { route: 'feeds' });
|
||||
assert.equal(resolveRailRoute('/trains/mbta-rail')?.feed?.id, 'mbta-rail');
|
||||
assert.equal(resolveRailRoute('/trains/mbta-rail/')?.feed?.id, 'mbta-rail');
|
||||
assert.equal(resolveRailRoute('/trains/vre'), null, 'not registered');
|
||||
assert.equal(
|
||||
resolveRailRoute('/trains/mbta'),
|
||||
null,
|
||||
'a transit id is not a rail id',
|
||||
);
|
||||
assert.equal(resolveRailRoute('/vehicles/mbta-rail'), null);
|
||||
assert.equal(resolveRailRoute('/trains/mbta-rail/extra'), null);
|
||||
assert.equal(resolveRailRoute('/trains/..%2F..%2Fetc'), null);
|
||||
assert.equal(resolveRailRoute('/trains/%E0%A4%A'), null);
|
||||
assert.equal(resolveRailRoute(''), null);
|
||||
assert.equal(resolveRailRoute(undefined), null);
|
||||
});
|
||||
|
||||
test('path packs resolve for enabled rail feeds only', () => {
|
||||
const route = resolveRailRoute('/paths/mta-lirr');
|
||||
assert.equal(route?.route, 'paths');
|
||||
assert.equal(route?.feed?.id, 'mta-lirr');
|
||||
assert.equal(resolveRailRoute('/paths/mta-lirr/?v=1')?.route, 'paths');
|
||||
assert.equal(resolveRailRoute('/trains/mta-lirr')?.route, 'trains');
|
||||
assert.equal(resolveRailRoute('/paths/vre'), null, 'not registered');
|
||||
assert.equal(resolveRailRoute('/paths/unknown'), null);
|
||||
assert.equal(resolveRailRoute('/paths/'), null);
|
||||
assert.equal(resolveRailRoute('/paths/mta-lirr/extra'), null);
|
||||
assert.equal(resolveRailRoute('/paths/..%2F..%2Fetc%2Fpasswd'), null);
|
||||
assert.equal(resolveRailRoute('/paths/mta-lirr.json'), null);
|
||||
assert.equal(resolveRailRoute('/paths/%E0%A4%A'), null);
|
||||
});
|
||||
|
||||
test('snapshots keep classified trains only and count what was dropped', () => {
|
||||
const feed = getRailFeed('mbta-rail');
|
||||
const snapshot = buildRailSnapshot(
|
||||
feed,
|
||||
encodeFeed({
|
||||
entities: [
|
||||
{ id: 'cr1', route: 'CR-Lowell' },
|
||||
{ id: 'red1', route: 'Red' },
|
||||
{ id: 'grn1', route: 'Green-D' },
|
||||
{ id: 'bus1', route: '39' },
|
||||
{ id: 'none' },
|
||||
],
|
||||
}),
|
||||
1_700_000_000_000,
|
||||
);
|
||||
assert.equal(snapshot.feedId, 'mbta-rail');
|
||||
assert.equal(snapshot.entityCount, 5);
|
||||
assert.equal(snapshot.count, 3);
|
||||
assert.equal(snapshot.dropped, 2);
|
||||
assert.deepEqual(
|
||||
snapshot.trains.map(({ id, category, mode }) => [id, category, mode]),
|
||||
[
|
||||
['cr1', 'local', 'rail'],
|
||||
['red1', 'transit', 'metro'],
|
||||
['grn1', 'transit', 'tram'],
|
||||
],
|
||||
);
|
||||
assert.equal(snapshot.trains[0].timestampSource, 'vehicle');
|
||||
assert.equal(snapshot.vehicles, undefined, 'buses never leave the server');
|
||||
});
|
||||
|
||||
test('a rail-only feed keeps vehicles that carry no route', () => {
|
||||
const snapshot = buildRailSnapshot(
|
||||
getRailFeed('mta-metro-north'),
|
||||
encodeFeed({ entities: [{ id: 'mnr1' }] }),
|
||||
);
|
||||
assert.equal(snapshot.count, 1);
|
||||
assert.equal(snapshot.trains[0].category, 'local');
|
||||
});
|
||||
|
||||
test('a differential feed is refused, as in the Transit proxy', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRailSnapshot(
|
||||
getRailFeed('mbta-rail'),
|
||||
encodeFeed({ incrementality: 1, entities: [] }),
|
||||
),
|
||||
RailFeedShapeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('a body that is no FeedMessage is unreadable, not an empty snapshot', () => {
|
||||
const feed = getRailFeed('mbta-rail');
|
||||
const headerless = new PbfWriter();
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, 'x');
|
||||
headerless.writeBytesField(2, entity.finish());
|
||||
const versionless = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeVarintField(3, 1_700_000_000);
|
||||
versionless.writeBytesField(1, header.finish());
|
||||
for (const [label, bytes] of [
|
||||
['empty', new Uint8Array(0)],
|
||||
['no header', headerless.finish()],
|
||||
['no version', versionless.finish()],
|
||||
['html', new TextEncoder().encode('<!DOCTYPE html><html></html>')],
|
||||
['json', new TextEncoder().encode('{"error":"quota"}')],
|
||||
]) {
|
||||
for (const build of [buildRailSnapshot, buildRailTripUpdatesSnapshot])
|
||||
assert.throws(
|
||||
() => build(feed, bytes, 1_700_000_000_000),
|
||||
(error) =>
|
||||
error.code === 'UPSTREAM_UNREADABLE' &&
|
||||
!(error instanceof RailFeedShapeError),
|
||||
`${build.name}: ${label}`,
|
||||
);
|
||||
}
|
||||
// A FeedMessage with its header and nothing else is a real empty feed.
|
||||
const quiet = encodeFeed({ entities: [] });
|
||||
assert.equal(buildRailSnapshot(feed, quiet).count, 0);
|
||||
assert.equal(buildRailTripUpdatesSnapshot(feed, quiet).count, 0);
|
||||
});
|
||||
|
||||
test('upstream headers identify the rail proxy and let a feed override Accept', () => {
|
||||
const plain = railUpstreamHeaders(getRailFeed('mbta-rail'), {
|
||||
etag: '"abc"',
|
||||
lastModified: 'Thu, 01 Jan 2026 00:00:00 GMT',
|
||||
});
|
||||
assert.match(plain['User-Agent'], /gods-eye-view-rail-proxy/);
|
||||
assert.equal(plain['If-None-Match'], '"abc"');
|
||||
assert.equal(plain['If-Modified-Since'], 'Thu, 01 Jan 2026 00:00:00 GMT');
|
||||
const adelaide = railUpstreamHeaders(getRegisteredRailFeed('adelaide-metro'));
|
||||
assert.equal(adelaide.Accept, 'application/x-google-protobuf');
|
||||
assert.equal(
|
||||
railUpstreamHeaders(getRailFeed('irish-rail')).Accept,
|
||||
'text/xml, application/xml;q=0.9',
|
||||
);
|
||||
});
|
||||
|
||||
test('response headers report cache state and operator contact', () => {
|
||||
const fresh = railResponseHeaders('MISS', 'cdn.mbta.com', 1_700_000_000_123);
|
||||
assert.equal(fresh['X-GEV-Cache'], 'MISS');
|
||||
assert.equal(fresh['X-Rail-Upstream'], 'cdn.mbta.com');
|
||||
assert.equal(fresh['X-Rail-Contact'], '1700000000123');
|
||||
assert.match(fresh['Cache-Control'], /max-age=15/);
|
||||
assert.equal(railResponseHeaders('STALE-ERROR')['Cache-Control'], 'no-store');
|
||||
assert.equal(railResponseHeaders('HIT')['X-Rail-Contact'], undefined);
|
||||
});
|
||||
|
||||
test('GTFS trains say their positions are GPS', () => {
|
||||
const snapshot = buildRailSnapshot(
|
||||
getRailFeed('mbta-rail'),
|
||||
encodeFeed({ entities: [{ id: 'cr1', route: 'CR-Lowell' }] }),
|
||||
);
|
||||
assert.equal(snapshot.positions, 'gps');
|
||||
assert.equal(snapshot.trains[0].positionSource, 'gps');
|
||||
});
|
||||
|
||||
test('Irish Rail XML becomes classified, event-positioned trains', () => {
|
||||
const now = 1_794_000_000_000;
|
||||
const snapshot = buildRailSnapshot(
|
||||
getRailFeed('irish-rail'),
|
||||
new TextEncoder().encode(IRISH_XML),
|
||||
now,
|
||||
);
|
||||
assert.equal(snapshot.feedId, 'irish-rail');
|
||||
assert.equal(snapshot.positions, 'event');
|
||||
assert.equal(snapshot.entityCount, 2);
|
||||
assert.equal(snapshot.count, 2);
|
||||
assert.equal(snapshot.feedTimestamp, null);
|
||||
const byId = new Map(snapshot.trains.map((t) => [t.id, t]));
|
||||
assert.equal(byId.get('A118').category, 'international');
|
||||
assert.equal(byId.get('A118').positionSource, 'event');
|
||||
assert.equal(byId.get('A118').timestampSource, 'fetch');
|
||||
assert.equal(byId.get('A118').timestamp, now / 1000);
|
||||
assert.equal(byId.get('E931').category, 'local');
|
||||
assert.equal(byId.get('E931').delaySec, 960);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRailSnapshot(
|
||||
getRailFeed('irish-rail'),
|
||||
new TextEncoder().encode('<html>Service Unavailable</html>'),
|
||||
),
|
||||
(error) =>
|
||||
error.code === 'UPSTREAM_UNREADABLE' &&
|
||||
!(error instanceof RailFeedShapeError),
|
||||
);
|
||||
});
|
||||
|
||||
test('no enabled feed is placed from its timetable', () => {
|
||||
// Every train is where its operator reports it: no feed answers `/trains`
|
||||
// with a timetable-only list.
|
||||
for (const feed of RAIL_FEED_REGISTRY.filter((f) => f.defaultEnabled))
|
||||
assert.ok(
|
||||
['gps', 'event'].includes(railFeedPositions(feed)) &&
|
||||
typeof feed.url === 'string',
|
||||
feed.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('trip updates keep rail trips only, with category, and refuse differential feeds', () => {
|
||||
const feed = getRailFeed('mbta-rail');
|
||||
const snapshot = buildRailTripUpdatesSnapshot(
|
||||
feed,
|
||||
encodeTripUpdates([
|
||||
['cr', 'CR-Worcester', 120],
|
||||
['red', 'Red', -30],
|
||||
['bus', '39', 60],
|
||||
['none', null, 0],
|
||||
]),
|
||||
5_000,
|
||||
);
|
||||
assert.equal(snapshot.feedId, 'mbta-rail');
|
||||
assert.equal(snapshot.fetchedAt, 5_000);
|
||||
assert.equal(snapshot.feedTimestamp, 1_794_000_000);
|
||||
assert.equal(snapshot.count, 2);
|
||||
assert.equal(snapshot.dropped, 2);
|
||||
assert.deepEqual(
|
||||
snapshot.trips.map((t) => [t.tripId, t.routeId, t.delaySec, t.category]),
|
||||
[
|
||||
['cr', 'CR-Worcester', 120, 'local'],
|
||||
['red', 'Red', -30, 'transit'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(snapshot.trips[0].stops[0], {
|
||||
stopId: 's0',
|
||||
stopSequence: 1,
|
||||
arrival: { time: null, delay: 120 },
|
||||
departure: null,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRailTripUpdatesSnapshot(
|
||||
feed,
|
||||
encodeTripUpdates([], { incrementality: 1 }),
|
||||
),
|
||||
RailFeedShapeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('a train named by its number takes its run, never a timetable position', () => {
|
||||
const now = 1_794_000_000_000;
|
||||
const bytes = new TextEncoder().encode(IRISH_XML);
|
||||
const plain = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now);
|
||||
const named = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now, {
|
||||
tripFor: (code) => (code === 'A118' ? 'pack-trip' : null),
|
||||
routeFor: (tripId) =>
|
||||
tripId === 'pack-trip' ? 'A~X~DUB-EUROPORT-I' : null,
|
||||
});
|
||||
|
||||
const at = (snapshot) =>
|
||||
Object.fromEntries(
|
||||
snapshot.trains.map((t) => [t.id, [t.lat, t.lon, t.positionSource]]),
|
||||
);
|
||||
assert.deepEqual(at(named), at(plain), 'positions as reported');
|
||||
const train = named.trains.find((t) => t.id === 'A118');
|
||||
assert.equal(train.tripId, 'pack-trip');
|
||||
assert.equal(train.trainNumber, 'A118');
|
||||
});
|
||||
|
||||
test('trip updates are kept by their own route', () => {
|
||||
const feed = getRailFeed('mbta-rail');
|
||||
const snapshot = buildRailTripUpdatesSnapshot(
|
||||
feed,
|
||||
encodeTripUpdates([
|
||||
['cr', 'CR-Worcester', 120],
|
||||
['bus', '39', 60],
|
||||
]),
|
||||
5_000,
|
||||
{ routeFor: () => null },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.trips.map((t) => [t.tripId, t.routeId]),
|
||||
[['cr', 'CR-Worcester']],
|
||||
);
|
||||
assert.equal(snapshot.dropped, 1);
|
||||
});
|
||||
|
||||
test('trip update answers are capped in trips', () => {
|
||||
const rows = Array.from({ length: RAIL_UPDATES_MAX_TRIPS + 2 }, (_, i) => [
|
||||
`t${i}`,
|
||||
'CR-Lowell',
|
||||
0,
|
||||
]);
|
||||
const snapshot = buildRailTripUpdatesSnapshot(
|
||||
getRailFeed('mbta-rail'),
|
||||
encodeTripUpdates(rows),
|
||||
);
|
||||
assert.equal(snapshot.count, RAIL_UPDATES_MAX_TRIPS);
|
||||
assert.equal(snapshot.truncated, true);
|
||||
assert.equal(snapshot.dropped, 0, 'capped trips are rail, not dropped');
|
||||
});
|
||||
|
||||
test('/updates resolves only for feeds that publish trip updates', () => {
|
||||
assert.equal(resolveRailRoute('/updates/mbta-rail')?.route, 'updates');
|
||||
assert.equal(resolveRailRoute('/updates/marc')?.feed?.id, 'marc');
|
||||
assert.equal(resolveRailRoute('/updates/irish-rail'), null, 'no updates');
|
||||
assert.equal(resolveRailRoute('/updates/capmetro-rail'), null);
|
||||
assert.equal(resolveRailRoute('/updates/vre'), null, 'not registered');
|
||||
assert.equal(resolveRailRoute('/updates/mbta-rail/x'), null);
|
||||
});
|
||||
|
||||
test('feed windows: own interval when set, a floor, and per-route defaults', () => {
|
||||
assert.equal(railFeedTtlMs(getRailFeed('mbta-rail')), 15_000);
|
||||
assert.equal(railFeedTtlMs(getRailFeed('mbta-rail'), 'updates'), 30_000);
|
||||
assert.equal(railFeedTtlMs(getRailFeed('irish-rail')), 30_000);
|
||||
assert.equal(railFeedTtlMs(getRailFeed('adelaide-metro'), 'updates'), 60_000);
|
||||
assert.equal(railFeedTtlMs({ pollIntervalMs: 10 }), 5_000);
|
||||
assert.equal(railMaxBodyBytes(getRailFeed('irish-rail')), 1024 * 1024);
|
||||
assert.equal(railMaxBodyBytes(getRailFeed('mbta-rail')), 8 * 1024 * 1024);
|
||||
assert.equal(railCacheStateFor(null, 0), 'none');
|
||||
assert.equal(railCacheStateFor({ at: 0 }, 29_000, 30_000), 'fresh');
|
||||
assert.equal(railCacheStateFor({ at: 0 }, 31_000, 30_000), 'stale');
|
||||
assert.equal(railCacheStateFor({ at: 0 }, 11 * 60_000, 30_000), 'expired');
|
||||
});
|
||||
|
||||
test('response headers follow the feed window', () => {
|
||||
assert.match(
|
||||
railResponseHeaders('MISS', 'h', null, 60_000)['Cache-Control'],
|
||||
/max-age=60/,
|
||||
);
|
||||
// An older snapshot is kept downstream only for the rest of its window.
|
||||
const maxAge = (ageMs) =>
|
||||
railResponseHeaders('HIT', 'h', null, 60_000, ageMs)['Cache-Control'];
|
||||
assert.equal(maxAge(45_000), 'public, max-age=15');
|
||||
assert.equal(maxAge(59_200), 'public, max-age=1');
|
||||
assert.equal(maxAge(60_000), 'public, max-age=0');
|
||||
assert.equal(maxAge(-5), 'public, max-age=60', 'a clock step is no age');
|
||||
});
|
||||
|
||||
test('/schedule resolves for every pollable feed', () => {
|
||||
assert.equal(resolveRailRoute('/schedule/ktmb')?.route, 'schedule');
|
||||
assert.equal(
|
||||
resolveRailRoute('/schedule/irish-rail')?.feed?.id,
|
||||
'irish-rail',
|
||||
);
|
||||
assert.equal(resolveRailRoute('/schedule/mbta-rail')?.route, 'schedule');
|
||||
assert.equal(resolveRailRoute('/schedule/vre'), null, 'not registered');
|
||||
assert.equal(resolveRailRoute('/schedule/ktmb/x'), null);
|
||||
assert.equal(resolveRailRoute('/schedule/..%2Fktmb'), null);
|
||||
});
|
||||
|
||||
test('the global admission cap scales with the registry', () => {
|
||||
const natural = railNaturalRequestsPerMinute();
|
||||
const cap = railAdmissionGlobalMax();
|
||||
assert.ok(natural > 40, 'the registry outgrew the old fixed cap');
|
||||
assert.ok(
|
||||
cap >= natural * 1.5,
|
||||
'room for retries on top of the natural load',
|
||||
);
|
||||
assert.equal(railAdmissionGlobalMax([]), 40, 'never below the Transit floor');
|
||||
const one = [{ defaultEnabled: true, url: 'https://x.test/vp' }];
|
||||
assert.equal(railNaturalRequestsPerMinute(one), 4, 'one feed at 15 s');
|
||||
const updatesOnly = [
|
||||
{ defaultEnabled: true, tripUpdatesUrl: 'https://x.test/tu' },
|
||||
];
|
||||
assert.equal(railNaturalRequestsPerMinute(updatesOnly), 2, 'no positions');
|
||||
assert.equal(
|
||||
railNaturalRequestsPerMinute([{ ...one[0], defaultEnabled: false }]),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
/** Vehicles with a trip id and no route or time, as Renfe sends them. */
|
||||
function encodeRenfeLike(headerTime, trips) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
header.writeVarintField(3, headerTime);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const [id, tripId, route] of trips) {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, id);
|
||||
const vehicle = new PbfWriter();
|
||||
const trip = new PbfWriter();
|
||||
trip.writeStringField(1, tripId);
|
||||
if (route) trip.writeStringField(5, route);
|
||||
vehicle.writeBytesField(1, trip.finish());
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, 40.4);
|
||||
position.writeFloatField(2, -3.7);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
const descriptor = new PbfWriter();
|
||||
descriptor.writeStringField(1, id);
|
||||
vehicle.writeBytesField(8, descriptor.finish());
|
||||
entity.writeBytesField(4, vehicle.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
test('a feed with an untrusted header clock stamps its trains with the fetch time', () => {
|
||||
const now = 1_794_000_000_000;
|
||||
// Renfe writes local time as UTC: the header reads an hour behind.
|
||||
const bytes = encodeRenfeLike(now / 1000 - 3600, [
|
||||
['04192', '0419212026-11-06'],
|
||||
]);
|
||||
const routes = new Map([['0419212026-11-06', 'ALVIA~X~1700037606GL023']]);
|
||||
const snapshot = buildRailSnapshot(
|
||||
getRailFeed('renfe-larga-distancia'),
|
||||
bytes,
|
||||
now,
|
||||
{
|
||||
routeFor: (tripId) => routes.get(tripId) ?? null,
|
||||
},
|
||||
);
|
||||
assert.equal(snapshot.count, 1);
|
||||
const [train] = snapshot.trains;
|
||||
assert.equal(train.timestampSource, 'fetch');
|
||||
assert.equal(train.timestamp, now / 1000, 'not the header time');
|
||||
assert.equal(
|
||||
snapshot.feedTimestamp,
|
||||
null,
|
||||
'the header clock is not reported',
|
||||
);
|
||||
assert.equal(
|
||||
train.routeId,
|
||||
'ALVIA~X~1700037606GL023',
|
||||
'from the schedule pack',
|
||||
);
|
||||
assert.equal(train.category, 'national');
|
||||
// A trusted feed keeps the header ladder.
|
||||
const trusted = buildRailSnapshot(
|
||||
getRailFeed('mta-metro-north'),
|
||||
encodeRenfeLike(now / 1000 - 5, [['mnr1', 't1']]),
|
||||
now,
|
||||
);
|
||||
assert.equal(trusted.trains[0].timestampSource, 'header');
|
||||
});
|
||||
|
||||
test('a trip the schedule pack does not know is dropped, not guessed', () => {
|
||||
const snapshot = buildRailSnapshot(
|
||||
getRailFeed('renfe-larga-distancia'),
|
||||
encodeRenfeLike(1_794_000_000, [
|
||||
['a', 'known'],
|
||||
['b', 'unknown'],
|
||||
]),
|
||||
1_794_000_000_000,
|
||||
{ routeFor: (tripId) => (tripId === 'known' ? 'MD~S~1' : null) },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.trains.map((t) => [t.id, t.category]),
|
||||
[['a', 'regional']],
|
||||
);
|
||||
assert.equal(snapshot.dropped, 1);
|
||||
// Without a lookup nothing in this feed can be classified.
|
||||
const blind = buildRailSnapshot(
|
||||
getRailFeed('renfe-larga-distancia'),
|
||||
encodeRenfeLike(1_794_000_000, [['a', 'known']]),
|
||||
);
|
||||
assert.equal(blind.count, 0);
|
||||
});
|
||||
|
||||
test('trip updates take their route from the schedule pack too, and carry the tourist flag', () => {
|
||||
const snapshot = buildRailTripUpdatesSnapshot(
|
||||
getRailFeed('renfe-larga-distancia'),
|
||||
encodeTripUpdates([
|
||||
['0419212026-11-06', null, 60],
|
||||
['999', null, 0],
|
||||
]),
|
||||
5_000,
|
||||
{
|
||||
routeFor: (tripId) =>
|
||||
tripId === '0419212026-11-06' ? 'ALVIA~X~1700037606GL023' : null,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.trips.map((t) => [t.tripId, t.routeId, t.category]),
|
||||
[['0419212026-11-06', 'ALVIA~X~1700037606GL023', 'national']],
|
||||
);
|
||||
assert.equal(snapshot.dropped, 1, 'the trip the pack does not know');
|
||||
const flam = buildRailSnapshot(
|
||||
getRailFeed('entur-vy'),
|
||||
encodeFeed({ entities: [{ id: 'flam', route: 'VYG:Line:R45' }] }),
|
||||
);
|
||||
assert.equal(flam.trains[0].tourist, true);
|
||||
assert.equal(flam.trains[0].category, 'regional');
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
import {
|
||||
dayNumberOf,
|
||||
formatClock,
|
||||
isTimeZone,
|
||||
parseServiceDate,
|
||||
serviceDayOriginMs,
|
||||
} from './railTimeZones.js';
|
||||
|
||||
/**
|
||||
* @module railSchedule
|
||||
* @description Timetable tier of the Rail layer: which trains run now
|
||||
* according to the operator's static GTFS, where along their line the
|
||||
* timetable puts them (only to match a live train to its run, never drawn),
|
||||
* and the stop list a train card shows.
|
||||
*
|
||||
* Schedule packs come from scripts/build-rail-paths.mjs (`<feedId>.schedule.json`).
|
||||
* Service calendars are resolved to concrete dates at build time, so this
|
||||
* module never evaluates a calendar. Stop times are seconds after the service
|
||||
* day's GTFS origin (noon minus 12 h, agency time zone) and may exceed 24 h.
|
||||
*
|
||||
* Realtime delays follow GTFS-RT TripUpdate semantics:
|
||||
*
|
||||
* delays = { [tripId]: { delaySec?, startDate?, relationship?, stops?: [{
|
||||
* stopId? | stopSequence?, relationship?,
|
||||
* arrival?: { time?, delay? }, departure?: { time?, delay? } }] } }
|
||||
*
|
||||
* (the trip records of src/data/railTripUpdates.js, keyed by trip id). A
|
||||
* trip `relationship` of 'CANCELED' or 'DELETED' drops the run from
|
||||
* activeTrips; a stop `relationship` of 'SKIPPED' (or `skipped: true`) is
|
||||
* passed without a stop, and one of 'NO_DATA' has no prediction: that stop
|
||||
* and the stops after it keep their timetable times until the next update.
|
||||
*
|
||||
* `time` is POSIX seconds (milliseconds are accepted too), `delay` seconds.
|
||||
* A stop update's delay carries on to later stops until the next update;
|
||||
* stops before the first update use the trip's `delaySec` (else the
|
||||
* timetable). `startDate` ('YYYYMMDD' or 'YYYY-MM-DD') pins the entry to one
|
||||
* run of a trip that runs on several days.
|
||||
*
|
||||
* Clock strings are 24-hour 'HH:MM' in each STATION's own time zone.
|
||||
*
|
||||
* Pure: no Cesium, no DOM, no I/O.
|
||||
*/
|
||||
|
||||
/** Schedule-pack format this module reads. */
|
||||
export const RAIL_SCHEDULE_PACK_VERSION = 1;
|
||||
|
||||
/** Earliest a delayed or early train is looked for around its timetable (ms). */
|
||||
const EARLY_SLACK_MS = 60 * 60_000;
|
||||
const LATE_SLACK_MS = 12 * 60 * 60_000;
|
||||
const MINUS = '−';
|
||||
/** TripUpdate relationships under which a scheduled run does not run. */
|
||||
const CANCELED = new Set(['CANCELED', 'DELETED']);
|
||||
|
||||
/**
|
||||
* A relative time chip as the Launches layer writes its countdown: `T−4`
|
||||
* for an event four minutes ahead, `T+3` for one three minutes past. Minutes
|
||||
* round up toward the event (Math.ceil, as the Launches countdown rounds its
|
||||
* seconds), so `T−1` shows until the moment itself and `T+0` for the first
|
||||
* minute after. An hour or more reads `T−1:05`.
|
||||
* @param {number} deltaMs Event time minus now.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRelative(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs)) return '';
|
||||
const minutes = Math.ceil(deltaMs / 60_000);
|
||||
const sign = deltaMs > 0 ? MINUS : '+';
|
||||
const total = Math.abs(minutes);
|
||||
const text =
|
||||
total >= 60
|
||||
? `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||
: String(total);
|
||||
return `T${sign}${text}`;
|
||||
}
|
||||
|
||||
const isIndex = (value, length) =>
|
||||
Number.isInteger(value) && value >= 0 && value < length;
|
||||
|
||||
/** Largest delay a live report may state, either way (s): a day. */
|
||||
const LIVE_DELAY_MAX_S = 86_400;
|
||||
|
||||
/**
|
||||
* The delay a live train's own report states (`delaySec`: Irish Rail's
|
||||
* public message, SIRI VM's `Delay`), for a feed that publishes no trip
|
||||
* updates; null when it states none, or more than a day either way (a unit
|
||||
* error, not a late train).
|
||||
* @param {{delaySec?: unknown}|null|undefined} record
|
||||
* @returns {number|null} Seconds, positive when late.
|
||||
*/
|
||||
export function liveDelaySec(record) {
|
||||
const value = record?.delaySec;
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
Math.abs(value) <= LIVE_DELAY_MAX_S
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and index a decoded schedule pack.
|
||||
* @param {object} raw The JSON body of `<feedId>.schedule.json`.
|
||||
* @returns {object} A schedule for the functions below.
|
||||
*/
|
||||
export function createSchedule(raw) {
|
||||
if (
|
||||
!raw ||
|
||||
raw.version !== RAIL_SCHEDULE_PACK_VERSION ||
|
||||
typeof raw.feedId !== 'string' ||
|
||||
!Array.isArray(raw.stops) ||
|
||||
!Array.isArray(raw.patterns) ||
|
||||
!Array.isArray(raw.profiles) ||
|
||||
!raw.trips ||
|
||||
typeof raw.trips !== 'object' ||
|
||||
!raw.services ||
|
||||
typeof raw.services !== 'object'
|
||||
)
|
||||
throw new TypeError('Malformed rail schedule pack');
|
||||
const timezone = isTimeZone(raw.timezone) ? raw.timezone : 'UTC';
|
||||
const stops = raw.stops.map((stop, index) => ({
|
||||
index,
|
||||
id: String(stop?.id ?? ''),
|
||||
name: String(stop?.name ?? ''),
|
||||
lat: Number(stop?.lat),
|
||||
lon: Number(stop?.lon),
|
||||
parent: isIndex(stop?.parent, raw.stops.length) ? stop.parent : null,
|
||||
platform: stop?.platform ? String(stop.platform) : null,
|
||||
ownZone: isTimeZone(stop?.tz) ? stop.tz : null,
|
||||
}));
|
||||
for (const stop of stops) {
|
||||
const parent = stop.parent === null ? null : stops[stop.parent];
|
||||
stop.timezone = stop.ownZone ?? parent?.ownZone ?? timezone;
|
||||
delete stop.ownZone;
|
||||
Object.freeze(stop);
|
||||
}
|
||||
const stopIndexById = new Map(stops.map((stop) => [stop.id, stop.index]));
|
||||
const patterns = raw.patterns.map((pattern, index) => {
|
||||
const list = Array.isArray(pattern?.stops) ? pattern.stops : [];
|
||||
const n = list.length;
|
||||
if (n < 2 || !list.every((value) => isIndex(value, stops.length)))
|
||||
throw new TypeError(`Malformed rail schedule pattern ${index}`);
|
||||
const dist =
|
||||
Array.isArray(pattern.dist) && pattern.dist.length === n
|
||||
? pattern.dist.map(Number)
|
||||
: new Array(n).fill(0);
|
||||
const seq =
|
||||
Array.isArray(pattern.seq) && pattern.seq.length === n
|
||||
? pattern.seq.map(Number)
|
||||
: list.map((_, k) => k + 1);
|
||||
return Object.freeze({
|
||||
index,
|
||||
shape: Number.isInteger(pattern.shape) ? pattern.shape : null,
|
||||
stops: Object.freeze(list.slice()),
|
||||
seq: Object.freeze(seq),
|
||||
dist: Object.freeze(dist),
|
||||
pass: Object.freeze(
|
||||
Array.isArray(pattern.pass) && pattern.pass.length === n
|
||||
? pattern.pass.map((value) => value === 1)
|
||||
: new Array(n).fill(false),
|
||||
),
|
||||
});
|
||||
});
|
||||
const origins = new Map();
|
||||
const originOf = (date) => {
|
||||
let origin = origins.get(date);
|
||||
if (origin === undefined) {
|
||||
origin = serviceDayOriginMs(date, timezone);
|
||||
origins.set(date, origin);
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
const trips = new Map();
|
||||
const instances = [];
|
||||
let maxDurationMs = 0;
|
||||
for (const [tripId, value] of Object.entries(raw.trips)) {
|
||||
const pattern = patterns[value?.pattern];
|
||||
const profile = raw.profiles[value?.profile];
|
||||
if (!pattern || !Array.isArray(profile)) continue;
|
||||
if (profile.length !== pattern.stops.length * 2) continue;
|
||||
const startSec = Number(value.startSec);
|
||||
if (!Number.isFinite(startSec)) continue;
|
||||
const trip = Object.freeze({
|
||||
id: tripId,
|
||||
pattern,
|
||||
offsets: profile,
|
||||
startSec,
|
||||
routeId: value.route == null ? null : String(value.route),
|
||||
service: String(value.service ?? ''),
|
||||
headsign: value.headsign ? String(value.headsign) : null,
|
||||
name: value.name ? String(value.name) : null,
|
||||
direction: Number.isInteger(value.direction) ? value.direction : null,
|
||||
});
|
||||
trips.set(tripId, trip);
|
||||
const dates = raw.services[trip.service];
|
||||
if (!Array.isArray(dates)) continue;
|
||||
const durationMs = profile[profile.length - 1] * 1000;
|
||||
if (durationMs > maxDurationMs) maxDurationMs = durationMs;
|
||||
for (const date of dates) {
|
||||
if (!parseServiceDate(date)) continue;
|
||||
const startMs = originOf(date) + startSec * 1000;
|
||||
if (Number.isFinite(startMs))
|
||||
instances.push({
|
||||
trip,
|
||||
serviceDate: date,
|
||||
startMs,
|
||||
endMs: startMs + durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
instances.sort((a, b) => a.startMs - b.startMs);
|
||||
const runsByTrip = new Map();
|
||||
for (const instance of instances) {
|
||||
let list = runsByTrip.get(instance.trip.id);
|
||||
if (!list) runsByTrip.set(instance.trip.id, (list = []));
|
||||
list.push(instance);
|
||||
}
|
||||
return Object.freeze({
|
||||
feedId: raw.feedId,
|
||||
/**
|
||||
* The `builtAt` of the path pack this timetable's shape indices point
|
||||
* into (written by the same build), or null in packs that predate it.
|
||||
*/
|
||||
pathsBuiltAt:
|
||||
typeof raw.pathsBuiltAt === 'string' ? raw.pathsBuiltAt : null,
|
||||
timezone,
|
||||
window: raw.window ?? null,
|
||||
routes: raw.routes && typeof raw.routes === 'object' ? raw.routes : {},
|
||||
stops: Object.freeze(stops),
|
||||
patterns: Object.freeze(patterns),
|
||||
trips,
|
||||
stopIndexById,
|
||||
instances: Object.freeze(instances),
|
||||
runsByTrip,
|
||||
instanceStarts: Float64Array.from(
|
||||
instances,
|
||||
(instance) => instance.startMs,
|
||||
),
|
||||
maxDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When the last run a decoded schedule pack holds arrives (epoch ms): past
|
||||
* it the pack's window has ended, and it names no train that can still be
|
||||
* running. Each trip counts on the latest date of its service. Read from the
|
||||
* raw pack, without indexing it (the server checks a pack it reads only for
|
||||
* its routes). Null when the pack names no window (`window.to`), so its end
|
||||
* is not known; -Infinity when it holds no run.
|
||||
* @param {object} raw The JSON body of `<feedId>.schedule.json`.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function schedulePackEndMs(raw) {
|
||||
if (!parseServiceDate(raw?.window?.to)) return null;
|
||||
const trips = raw?.trips && typeof raw.trips === 'object' ? raw.trips : {};
|
||||
const services =
|
||||
raw?.services && typeof raw.services === 'object' ? raw.services : {};
|
||||
const profiles = Array.isArray(raw?.profiles) ? raw.profiles : [];
|
||||
const zone = isTimeZone(raw?.timezone) ? raw.timezone : 'UTC';
|
||||
/** Service id → the origin of its latest date (-Infinity: no date). */
|
||||
const lastOrigin = new Map();
|
||||
const latestOrigin = (service) => {
|
||||
let origin = lastOrigin.get(service);
|
||||
if (origin !== undefined) return origin;
|
||||
const dates = Object.hasOwn(services, service) ? services[service] : null;
|
||||
let latest = -Infinity;
|
||||
let latestDate = null;
|
||||
for (const date of Array.isArray(dates) ? dates : []) {
|
||||
const day = dayNumberOf(date);
|
||||
if (day > latest) {
|
||||
latest = day;
|
||||
latestDate = date;
|
||||
}
|
||||
}
|
||||
origin = latestDate === null ? NaN : serviceDayOriginMs(latestDate, zone);
|
||||
if (!Number.isFinite(origin)) origin = -Infinity;
|
||||
lastOrigin.set(service, origin);
|
||||
return origin;
|
||||
};
|
||||
let end = -Infinity;
|
||||
for (const trip of Object.values(trips)) {
|
||||
const profile = profiles[trip?.profile];
|
||||
const startSec = Number(trip?.startSec);
|
||||
if (!Array.isArray(profile) || !Number.isFinite(startSec)) continue;
|
||||
const origin = latestOrigin(String(trip.service ?? ''));
|
||||
if (origin === -Infinity) continue;
|
||||
const durationSec = Number(profile[profile.length - 1]) || 0;
|
||||
end = Math.max(end, origin + (startSec + durationSec) * 1000);
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** First index whose start is greater than `ms`. */
|
||||
function upperBound(starts, ms) {
|
||||
let lo = 0;
|
||||
let hi = starts.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (starts[mid] <= ms) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** The public view of one run of a trip. */
|
||||
function runOf(instance) {
|
||||
const { trip } = instance;
|
||||
return {
|
||||
tripId: trip.id,
|
||||
serviceDate: instance.serviceDate,
|
||||
routeId: trip.routeId,
|
||||
headsign: trip.headsign,
|
||||
name: trip.name,
|
||||
direction: trip.direction,
|
||||
pattern: trip.pattern.index,
|
||||
shape: trip.pattern.shape,
|
||||
scheduledStartMs: instance.startMs,
|
||||
scheduledEndMs: instance.endMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** The delay entry for a run, honouring a `startDate` pin. */
|
||||
function delayFor(delays, run) {
|
||||
const entry =
|
||||
delays && Object.hasOwn(delays, run.tripId) ? delays[run.tripId] : null;
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
if (entry.startDate != null) {
|
||||
const pinned = parseServiceDate(entry.startDate);
|
||||
const own = parseServiceDate(run.serviceDate);
|
||||
if (
|
||||
!pinned ||
|
||||
!own ||
|
||||
pinned.year !== own.year ||
|
||||
pinned.month !== own.month ||
|
||||
pinned.day !== own.day
|
||||
)
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
const toMs = (time) => {
|
||||
const value = Number(time);
|
||||
if (!Number.isFinite(value) || value <= 0) return NaN;
|
||||
return value > 1e11 ? value : value * 1000;
|
||||
};
|
||||
|
||||
/** The pattern index each stop update applies to, in order. */
|
||||
function matchUpdates(schedule, pattern, updates) {
|
||||
const byIndex = new Map();
|
||||
let from = 0;
|
||||
for (const update of updates) {
|
||||
if (!update || typeof update !== 'object') continue;
|
||||
let found = -1;
|
||||
for (let pass = 0; pass < 2 && found < 0; pass++) {
|
||||
const start = pass === 0 ? from : 0;
|
||||
for (let k = start; k < pattern.stops.length; k++) {
|
||||
if (stopMatches(schedule, pattern, k, update)) {
|
||||
found = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found < 0) continue;
|
||||
byIndex.set(found, update);
|
||||
from = found + 1;
|
||||
}
|
||||
return byIndex;
|
||||
}
|
||||
|
||||
function stopMatches(schedule, pattern, k, update) {
|
||||
if (update.stopSequence != null && update.stopSequence !== '')
|
||||
return Number(update.stopSequence) === pattern.seq[k];
|
||||
if (update.stopId == null) return false;
|
||||
const id = String(update.stopId);
|
||||
const stop = schedule.stops[pattern.stops[k]];
|
||||
if (stop.id === id) return true;
|
||||
if (stop.parent !== null && schedule.stops[stop.parent].id === id)
|
||||
return true;
|
||||
const other = schedule.stopIndexById.get(id);
|
||||
if (other === undefined) return false;
|
||||
const otherParent = schedule.stops[other].parent;
|
||||
return otherParent !== null && otherParent === (stop.parent ?? stop.index);
|
||||
}
|
||||
|
||||
/** Whether a stop update carries this StopTimeUpdate relationship. */
|
||||
function stopRelationshipIs(update, relationship) {
|
||||
return (
|
||||
update.relationship === relationship ||
|
||||
update.scheduleRelationship === relationship
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled and expected times of every stop of a run.
|
||||
* @returns {{schedArr: number[], schedDep: number[], arr: number[],
|
||||
* dep: number[], skipped: boolean[], hasRealtime: boolean}}
|
||||
*/
|
||||
function runTimes(schedule, instance, delays) {
|
||||
const { trip } = instance;
|
||||
const n = trip.pattern.stops.length;
|
||||
const schedArr = new Array(n);
|
||||
const schedDep = new Array(n);
|
||||
for (let k = 0; k < n; k++) {
|
||||
schedArr[k] = instance.startMs + trip.offsets[2 * k] * 1000;
|
||||
schedDep[k] = instance.startMs + trip.offsets[2 * k + 1] * 1000;
|
||||
}
|
||||
const arr = schedArr.slice();
|
||||
const dep = schedDep.slice();
|
||||
const skipped = new Array(n).fill(false);
|
||||
const entry = delayFor(delays, {
|
||||
tripId: trip.id,
|
||||
serviceDate: instance.serviceDate,
|
||||
});
|
||||
if (!entry)
|
||||
return { schedArr, schedDep, arr, dep, skipped, hasRealtime: false };
|
||||
const updates = matchUpdates(
|
||||
schedule,
|
||||
trip.pattern,
|
||||
Array.isArray(entry.stops) ? entry.stops : [],
|
||||
);
|
||||
let carried = Number.isFinite(Number(entry.delaySec))
|
||||
? Number(entry.delaySec)
|
||||
: 0;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const update = updates.get(k);
|
||||
let arrDelay = carried;
|
||||
let depDelay = carried;
|
||||
if (update && stopRelationshipIs(update, 'NO_DATA')) {
|
||||
// No prediction (GTFS-RT NO_DATA): the delay carried so far no longer
|
||||
// holds, here or at the stops after it.
|
||||
carried = 0;
|
||||
arrDelay = 0;
|
||||
depDelay = 0;
|
||||
} else if (update) {
|
||||
if (update.skipped === true || stopRelationshipIs(update, 'SKIPPED'))
|
||||
skipped[k] = true;
|
||||
const a = update.arrival ?? null;
|
||||
const d = update.departure ?? null;
|
||||
const aTime = toMs(a?.time);
|
||||
const dTime = toMs(d?.time);
|
||||
const aDelay = Number(a?.delay);
|
||||
const dDelay = Number(d?.delay);
|
||||
let arrivalGiven = true;
|
||||
if (Number.isFinite(aTime)) arrDelay = (aTime - schedArr[k]) / 1000;
|
||||
else if (a && a.delay != null && Number.isFinite(aDelay))
|
||||
arrDelay = aDelay;
|
||||
else arrivalGiven = false;
|
||||
if (Number.isFinite(dTime)) depDelay = (dTime - schedDep[k]) / 1000;
|
||||
else if (d && d.delay != null && Number.isFinite(dDelay))
|
||||
depDelay = dDelay;
|
||||
else if (arrivalGiven) depDelay = arrDelay;
|
||||
else depDelay = carried;
|
||||
if (!arrivalGiven && (Number.isFinite(dTime) || (d && d.delay != null)))
|
||||
arrDelay = Math.min(carried, depDelay);
|
||||
carried = depDelay;
|
||||
}
|
||||
arr[k] = schedArr[k] + arrDelay * 1000;
|
||||
dep[k] = schedDep[k] + depDelay * 1000;
|
||||
}
|
||||
// Predictions are not always consistent; time never runs backwards.
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (k > 0 && arr[k] < dep[k - 1]) arr[k] = dep[k - 1];
|
||||
if (dep[k] < arr[k]) dep[k] = arr[k];
|
||||
}
|
||||
return { schedArr, schedDep, arr, dep, skipped, hasRealtime: true };
|
||||
}
|
||||
|
||||
/** The instance of `trip` (a run object or a trip id) meant at `nowMs`. */
|
||||
function resolveInstance(schedule, trip, nowMs, delays) {
|
||||
const pinned = trip && typeof trip === 'object';
|
||||
const runs = schedule.runsByTrip.get(
|
||||
String((pinned ? trip.tripId : trip) ?? ''),
|
||||
);
|
||||
if (!runs) return null;
|
||||
if (pinned && trip.serviceDate != null) {
|
||||
const match = runs.find(
|
||||
(instance) => instance.serviceDate === trip.serviceDate,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const instance of runs) {
|
||||
const times = runTimes(schedule, instance, delays);
|
||||
const start = times.dep[0];
|
||||
const end = times.arr[times.arr.length - 1];
|
||||
// Running now wins; else the nearest run, upcoming before past.
|
||||
const score =
|
||||
nowMs >= start && nowMs <= end
|
||||
? -1
|
||||
: nowMs < start
|
||||
? start - nowMs
|
||||
: (nowMs - end) * 2;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = instance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains the timetable has running at `nowMs`: from the departure at the
|
||||
* first stop to the arrival at the last, shifted by realtime delays.
|
||||
* @param {object} schedule From createSchedule.
|
||||
* @param {number} nowMs
|
||||
* @param {{delays?: object, beforeMs?: number, afterMs?: number}} [options]
|
||||
* `beforeMs` / `afterMs` widen the window (a train waiting at its origin,
|
||||
* one just arrived).
|
||||
* @returns {object[]} Runs: {tripId, serviceDate, routeId, headsign, name,
|
||||
* direction, pattern, shape, scheduledStartMs, scheduledEndMs,
|
||||
* expectedStartMs, expectedEndMs}.
|
||||
*/
|
||||
export function activeTrips(
|
||||
schedule,
|
||||
nowMs,
|
||||
{ delays = null, beforeMs = 0, afterMs = 0 } = {},
|
||||
) {
|
||||
if (!schedule || !Number.isFinite(nowMs)) return [];
|
||||
const lead = Math.max(0, beforeMs);
|
||||
const tail = Math.max(0, afterMs);
|
||||
const last = upperBound(
|
||||
schedule.instanceStarts,
|
||||
nowMs + lead + EARLY_SLACK_MS,
|
||||
);
|
||||
const floor = nowMs - tail - LATE_SLACK_MS - schedule.maxDurationMs;
|
||||
const out = [];
|
||||
for (let i = last - 1; i >= 0; i--) {
|
||||
const instance = schedule.instances[i];
|
||||
if (instance.startMs < floor) break;
|
||||
const run = runOf(instance);
|
||||
let start = instance.startMs + instance.trip.offsets[1] * 1000;
|
||||
let end = instance.endMs;
|
||||
const entry = delayFor(delays, run);
|
||||
if (entry && CANCELED.has(entry.relationship)) continue;
|
||||
if (entry) {
|
||||
const times = runTimes(schedule, instance, delays);
|
||||
start = times.dep[0];
|
||||
end = times.arr[times.arr.length - 1];
|
||||
} else if (instance.endMs < nowMs - tail) continue;
|
||||
if (nowMs < start - lead || nowMs > end + tail) continue;
|
||||
run.expectedStartMs = start;
|
||||
run.expectedEndMs = end;
|
||||
out.push(run);
|
||||
}
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the timetable puts a train at `nowMs`, along its pattern's shape.
|
||||
* Between two stops the arc runs linearly in time from the departure to the
|
||||
* next arrival; during a dwell it holds at the stop. Before the first
|
||||
* departure it waits at the origin, after the last arrival at the terminus.
|
||||
*
|
||||
* @param {object} schedule From createSchedule.
|
||||
* @param {object|string} trip A run from activeTrips, or a trip id.
|
||||
* @param {number} nowMs
|
||||
* @param {object} [delays]
|
||||
* @returns {{tripId: string, serviceDate: string, shape: number|null,
|
||||
* s: number, prevStop: number, nextStop: number,
|
||||
* prevStopId: string|null, nextStopId: string|null, dwell: boolean,
|
||||
* state: 'before'|'running'|'arrived', fraction: number, delaySec: number,
|
||||
* lat?: number, lon?: number}|null} Stop fields are pattern indices (-1 for
|
||||
* none). `lat`/`lon` interpolate between the stops and are set only when
|
||||
* the pattern has no shape.
|
||||
*/
|
||||
export function scheduledPosition(schedule, trip, nowMs, delays = null) {
|
||||
if (!schedule || !Number.isFinite(nowMs)) return null;
|
||||
const instance = resolveInstance(schedule, trip, nowMs, delays);
|
||||
if (!instance) return null;
|
||||
const { pattern } = instance.trip;
|
||||
const times = runTimes(schedule, instance, delays);
|
||||
// Skipped stops are passed without a stop: their times do not pace the run.
|
||||
const nodes = [];
|
||||
for (let k = 0; k < pattern.stops.length; k++)
|
||||
if (!times.skipped[k]) nodes.push(k);
|
||||
if (nodes.length < 2)
|
||||
nodes.splice(0, nodes.length, 0, pattern.stops.length - 1);
|
||||
const result = {
|
||||
tripId: instance.trip.id,
|
||||
serviceDate: instance.serviceDate,
|
||||
shape: pattern.shape,
|
||||
s: 0,
|
||||
prevStop: -1,
|
||||
nextStop: -1,
|
||||
prevStopId: null,
|
||||
nextStopId: null,
|
||||
dwell: false,
|
||||
state: 'running',
|
||||
fraction: 0,
|
||||
delaySec: 0,
|
||||
};
|
||||
const first = nodes[0];
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
let at = -1;
|
||||
let from = -1;
|
||||
let to = -1;
|
||||
if (nowMs < times.dep[first]) {
|
||||
result.state = 'before';
|
||||
at = first;
|
||||
result.prevStop = -1;
|
||||
result.nextStop = first;
|
||||
} else if (nowMs >= times.arr[lastNode]) {
|
||||
result.state = 'arrived';
|
||||
at = lastNode;
|
||||
result.prevStop = lastNode;
|
||||
result.nextStop = -1;
|
||||
} else {
|
||||
for (let j = 0; j < nodes.length; j++) {
|
||||
const k = nodes[j];
|
||||
if (nowMs >= times.arr[k] && nowMs < times.dep[k]) {
|
||||
at = k;
|
||||
result.prevStop = k;
|
||||
result.nextStop = j + 1 < nodes.length ? nodes[j + 1] : -1;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
j + 1 < nodes.length &&
|
||||
nowMs >= times.dep[k] &&
|
||||
nowMs < times.arr[nodes[j + 1]]
|
||||
) {
|
||||
from = k;
|
||||
to = nodes[j + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (at >= 0) {
|
||||
result.dwell = true;
|
||||
result.s = pattern.dist[at];
|
||||
result.fraction = 0;
|
||||
result.delaySec = Math.round(
|
||||
(result.state === 'arrived'
|
||||
? times.arr[at] - times.schedArr[at]
|
||||
: times.dep[at] - times.schedDep[at]) / 1000,
|
||||
);
|
||||
} else {
|
||||
const span = times.arr[to] - times.dep[from];
|
||||
const fraction =
|
||||
span > 0 ? Math.min(1, Math.max(0, (nowMs - times.dep[from]) / span)) : 1;
|
||||
result.prevStop = from;
|
||||
result.nextStop = to;
|
||||
result.fraction = fraction;
|
||||
result.s =
|
||||
pattern.dist[from] + (pattern.dist[to] - pattern.dist[from]) * fraction;
|
||||
result.delaySec = Math.round((times.arr[to] - times.schedArr[to]) / 1000);
|
||||
}
|
||||
if (result.prevStop >= 0)
|
||||
result.prevStopId = schedule.stops[pattern.stops[result.prevStop]].id;
|
||||
if (result.nextStop >= 0)
|
||||
result.nextStopId = schedule.stops[pattern.stops[result.nextStop]].id;
|
||||
if (pattern.shape === null) {
|
||||
const a =
|
||||
schedule.stops[
|
||||
pattern.stops[result.prevStop >= 0 ? result.prevStop : result.nextStop]
|
||||
];
|
||||
const b = result.dwell ? a : schedule.stops[pattern.stops[result.nextStop]];
|
||||
result.lat = a.lat + (b.lat - a.lat) * result.fraction;
|
||||
result.lon = a.lon + (b.lon - a.lon) * result.fraction;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run's stop list for a train card.
|
||||
*
|
||||
* `state` is 'passed' once the train has left (or, at the terminus, reached)
|
||||
* the stop, 'next' for the first stop not yet passed (with `atStop` while the
|
||||
* train dwells there) and 'upcoming' after it. `relative` is the chip from
|
||||
* formatRelative: for a passed stop the departure (terminus: arrival), for
|
||||
* the stop the train stands at its departure, else the arrival.
|
||||
*
|
||||
* @param {object} schedule
|
||||
* @param {object|string} trip A run from activeTrips, or a trip id.
|
||||
* @param {number} nowMs
|
||||
* @param {object} [delays]
|
||||
* @returns {object[]} Per stop: {index, stopId, name, platform, parentId,
|
||||
* parentName, lat, lon, timezone, scheduledArrivalMs,
|
||||
* scheduledDepartureMs, expectedArrivalMs, expectedDepartureMs,
|
||||
* arrivalDelaySec, departureDelaySec, scheduledArrival,
|
||||
* scheduledDeparture, expectedArrival, expectedDeparture (local 'HH:MM'),
|
||||
* passThrough, skipped, realtime, state, atStop, relative, relativeMs}.
|
||||
*/
|
||||
export function tripStops(schedule, trip, nowMs, delays = null) {
|
||||
if (!schedule || !Number.isFinite(nowMs)) return [];
|
||||
const instance = resolveInstance(schedule, trip, nowMs, delays);
|
||||
if (!instance) return [];
|
||||
const { pattern } = instance.trip;
|
||||
const times = runTimes(schedule, instance, delays);
|
||||
const n = pattern.stops.length;
|
||||
const out = [];
|
||||
let nextFound = false;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const stop = schedule.stops[pattern.stops[k]];
|
||||
const parent = stop.parent === null ? null : schedule.stops[stop.parent];
|
||||
const zone = stop.timezone;
|
||||
const terminus = k === n - 1;
|
||||
const leftAt = terminus ? times.arr[k] : times.dep[k];
|
||||
let state;
|
||||
let atStop = false;
|
||||
if (nowMs >= leftAt) state = 'passed';
|
||||
else if (!nextFound) {
|
||||
state = 'next';
|
||||
nextFound = true;
|
||||
atStop = nowMs >= times.arr[k] && !times.skipped[k];
|
||||
} else state = 'upcoming';
|
||||
const eventMs =
|
||||
state === 'passed'
|
||||
? leftAt
|
||||
: atStop || k === 0
|
||||
? times.dep[k]
|
||||
: times.arr[k];
|
||||
out.push({
|
||||
index: k,
|
||||
stopId: stop.id,
|
||||
name: stop.name || parent?.name || stop.id,
|
||||
platform: stop.platform,
|
||||
parentId: parent?.id ?? null,
|
||||
parentName: parent?.name ?? null,
|
||||
lat: stop.lat,
|
||||
lon: stop.lon,
|
||||
timezone: zone,
|
||||
scheduledArrivalMs: times.schedArr[k],
|
||||
scheduledDepartureMs: times.schedDep[k],
|
||||
expectedArrivalMs: times.arr[k],
|
||||
expectedDepartureMs: times.dep[k],
|
||||
arrivalDelaySec: Math.round((times.arr[k] - times.schedArr[k]) / 1000),
|
||||
departureDelaySec: Math.round((times.dep[k] - times.schedDep[k]) / 1000),
|
||||
scheduledArrival: formatClock(times.schedArr[k], zone),
|
||||
scheduledDeparture: formatClock(times.schedDep[k], zone),
|
||||
expectedArrival: formatClock(times.arr[k], zone),
|
||||
expectedDeparture: formatClock(times.dep[k], zone),
|
||||
passThrough: pattern.pass[k],
|
||||
skipped: times.skipped[k],
|
||||
realtime: times.hasRealtime,
|
||||
state,
|
||||
atStop,
|
||||
relativeMs: eventMs - nowMs,
|
||||
relative: formatRelative(eventMs - nowMs),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
activeTrips,
|
||||
createSchedule,
|
||||
formatRelative,
|
||||
liveDelaySec,
|
||||
schedulePackEndMs,
|
||||
scheduledPosition,
|
||||
tripStops,
|
||||
} from './railSchedule.js';
|
||||
import {
|
||||
formatClock,
|
||||
localDate,
|
||||
serviceDayOriginMs,
|
||||
zonedTimeToEpochMs,
|
||||
} from './railTimeZones.js';
|
||||
|
||||
const MIN = 60_000;
|
||||
const H = 3_600_000;
|
||||
|
||||
/**
|
||||
* Four stops on one shape, New York time except the last (Chicago). Run
|
||||
* times: A 0, B arrive +5 min / leave +6 min, C +15 min, D +25 min.
|
||||
*/
|
||||
function pack(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
feedId: 'test-feed',
|
||||
timezone: 'America/New_York',
|
||||
window: { from: '2026-10-31', to: '2026-11-03' },
|
||||
routes: { R: { type: 2, short: 'R' } },
|
||||
stops: [
|
||||
{ id: 'A', name: 'Alpha', lat: 40, lon: -74 },
|
||||
{
|
||||
id: 'B1',
|
||||
name: 'Track 1',
|
||||
lat: 40,
|
||||
lon: -73.99,
|
||||
parent: 4,
|
||||
platform: '1',
|
||||
},
|
||||
{ id: 'C', name: 'Charlie', lat: 40, lon: -73.97 },
|
||||
{ id: 'D', name: 'Delta', lat: 40, lon: -73.94, tz: 'America/Chicago' },
|
||||
{ id: 'B', name: 'Bravo', lat: 40, lon: -73.99 },
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
shape: 0,
|
||||
stops: [0, 1, 2, 3],
|
||||
seq: [1, 2, 3, 4],
|
||||
dist: [0, 1000, 3000, 6000],
|
||||
},
|
||||
{ shape: null, stops: [0, 2], seq: [1, 2], dist: [0, 2500] },
|
||||
],
|
||||
profiles: [
|
||||
[0, 0, 300, 360, 900, 900, 1500, 1500],
|
||||
[0, 0, 600, 600],
|
||||
],
|
||||
services: {
|
||||
SAT: ['2026-10-31'],
|
||||
SUN: ['2026-11-01'],
|
||||
DAILY: ['2026-10-31', '2026-11-01', '2026-11-02'],
|
||||
},
|
||||
trips: {
|
||||
morning: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: 8 * 3600,
|
||||
route: 'R',
|
||||
service: 'SUN',
|
||||
headsign: 'Delta',
|
||||
name: '101',
|
||||
direction: 0,
|
||||
},
|
||||
late: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: 25 * 3600,
|
||||
route: 'R',
|
||||
service: 'SAT',
|
||||
name: '199',
|
||||
},
|
||||
fallback: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: 1.5 * 3600,
|
||||
route: 'R',
|
||||
service: 'SUN',
|
||||
},
|
||||
shuttle: {
|
||||
pattern: 1,
|
||||
profile: 1,
|
||||
startSec: 12 * 3600,
|
||||
route: 'R',
|
||||
service: 'DAILY',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const at = (iso) => Date.parse(iso);
|
||||
/** 08:00 EST on 2026-11-01, the morning trip's departure. */
|
||||
const MORNING = at('2026-11-01T13:00:00Z');
|
||||
|
||||
test('relative chips read T−n ahead and T+n past, rounded like the Launches countdown', () => {
|
||||
assert.equal(formatRelative(4 * MIN), 'T−4');
|
||||
assert.equal(formatRelative(3.5 * MIN), 'T−4');
|
||||
assert.equal(formatRelative(30_000), 'T−1');
|
||||
assert.equal(formatRelative(0), 'T+0');
|
||||
assert.equal(formatRelative(-30_000), 'T+0');
|
||||
assert.equal(formatRelative(-3 * MIN), 'T+3');
|
||||
assert.equal(formatRelative(-3.5 * MIN), 'T+3');
|
||||
assert.equal(formatRelative(65 * MIN), 'T−1:05');
|
||||
assert.equal(formatRelative(-125 * MIN), 'T+2:05');
|
||||
assert.equal(formatRelative(NaN), '');
|
||||
});
|
||||
|
||||
test('service days count from noon minus 12 h, across both DST changes', () => {
|
||||
// Ordinary day: local midnight.
|
||||
assert.equal(
|
||||
serviceDayOriginMs('2026-10-31', 'America/New_York'),
|
||||
at('2026-10-31T04:00:00Z'),
|
||||
);
|
||||
// Fall back: 01:00 EDT, so 08:00:00 is 08:00 EST.
|
||||
const fall = serviceDayOriginMs('20261101', 'America/New_York');
|
||||
assert.equal(fall, at('2026-11-01T05:00:00Z'));
|
||||
assert.equal(formatClock(fall + 8 * H, 'America/New_York'), '08:00');
|
||||
// Spring forward: 23:00 EST the evening before, so 06:00:00 is 06:00 EDT.
|
||||
const spring = serviceDayOriginMs('2026-03-08', 'America/New_York');
|
||||
assert.equal(spring, at('2026-03-08T04:00:00Z'));
|
||||
assert.equal(formatClock(spring + 6 * H, 'America/New_York'), '06:00');
|
||||
assert.ok(Number.isNaN(serviceDayOriginMs('2026-02-30', 'UTC')));
|
||||
});
|
||||
|
||||
test('wall times resolve to instants: overlaps take the earlier, gaps move on', () => {
|
||||
assert.equal(
|
||||
zonedTimeToEpochMs('America/New_York', 2026, 11, 1, 1, 30),
|
||||
at('2026-11-01T05:30:00Z'),
|
||||
);
|
||||
assert.equal(
|
||||
zonedTimeToEpochMs('America/New_York', 2026, 3, 8, 2, 30),
|
||||
at('2026-03-08T07:30:00Z'),
|
||||
);
|
||||
assert.equal(
|
||||
zonedTimeToEpochMs('Europe/London', 2026, 7, 1, 12),
|
||||
at('2026-07-01T11:00:00Z'),
|
||||
);
|
||||
assert.equal(
|
||||
localDate(at('2026-11-01T03:59:00Z'), 'America/New_York'),
|
||||
'2026-10-31',
|
||||
);
|
||||
assert.equal(formatClock(at('2026-11-01T23:59:00Z'), 'Asia/Tokyo'), '08:59');
|
||||
});
|
||||
|
||||
test('a malformed pack is refused', () => {
|
||||
assert.throws(() => createSchedule(null), TypeError);
|
||||
assert.throws(() => createSchedule({ ...pack(), version: 2 }), TypeError);
|
||||
assert.throws(
|
||||
() => createSchedule(pack({ patterns: [{ shape: 0, stops: [0, 99] }] })),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('active trips come from service dates in the agency zone', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const ids = (now) =>
|
||||
activeTrips(schedule, now).map((run) => `${run.tripId}@${run.serviceDate}`);
|
||||
assert.deepEqual(ids(MORNING + 10 * MIN), ['morning@2026-11-01']);
|
||||
assert.deepEqual(ids(MORNING - MIN), []);
|
||||
assert.deepEqual(ids(MORNING + 26 * MIN), []);
|
||||
// Saturday's 25:00:00 trip runs at 01:00 EDT on Sunday, the first 01:00.
|
||||
assert.deepEqual(ids(at('2026-11-01T05:10:00Z')), ['late@2026-10-31']);
|
||||
// Sunday's 01:30:00 trip runs at 01:30 EST, the second 01:30.
|
||||
assert.deepEqual(ids(at('2026-11-01T06:40:00Z')), ['fallback@2026-11-01']);
|
||||
// A daily trip appears once per run; widening the window catches a train
|
||||
// waiting at its origin.
|
||||
assert.deepEqual(ids(at('2026-11-02T17:05:00Z')), ['shuttle@2026-11-02']);
|
||||
assert.deepEqual(
|
||||
activeTrips(schedule, MORNING - 2 * MIN, { beforeMs: 5 * MIN }).map(
|
||||
(run) => run.tripId,
|
||||
),
|
||||
['morning'],
|
||||
);
|
||||
const [run] = activeTrips(schedule, MORNING + MIN);
|
||||
assert.equal(run.name, '101');
|
||||
assert.equal(run.headsign, 'Delta');
|
||||
assert.equal(run.shape, 0);
|
||||
assert.equal(run.scheduledStartMs, MORNING);
|
||||
assert.equal(run.scheduledEndMs, MORNING + 25 * MIN);
|
||||
});
|
||||
|
||||
test('the timetable position runs linearly between stops and holds through a dwell', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const [run] = activeTrips(schedule, MORNING + MIN);
|
||||
const moving = scheduledPosition(schedule, run, MORNING + 150_000);
|
||||
assert.equal(moving.state, 'running');
|
||||
assert.equal(moving.dwell, false);
|
||||
assert.equal(moving.s, 500);
|
||||
assert.equal(moving.prevStop, 0);
|
||||
assert.equal(moving.nextStop, 1);
|
||||
assert.equal(moving.nextStopId, 'B1');
|
||||
assert.equal(
|
||||
moving.lat,
|
||||
undefined,
|
||||
'a shaped pattern leaves lat/lon to the path',
|
||||
);
|
||||
const dwell = scheduledPosition(schedule, run, MORNING + 330_000);
|
||||
assert.equal(dwell.dwell, true);
|
||||
assert.equal(dwell.s, 1000);
|
||||
assert.equal(dwell.prevStop, 1);
|
||||
assert.equal(dwell.nextStop, 2);
|
||||
const leg = scheduledPosition(schedule, 'morning', MORNING + 630_000);
|
||||
assert.equal(leg.s, 2000, 'halfway from B (leaves +6) to C (arrives +15)');
|
||||
const before = scheduledPosition(schedule, run, MORNING - MIN);
|
||||
assert.equal(before.state, 'before');
|
||||
assert.equal(before.s, 0);
|
||||
assert.equal(before.nextStop, 0);
|
||||
const after = scheduledPosition(schedule, run, MORNING + 30 * MIN);
|
||||
assert.equal(after.state, 'arrived');
|
||||
assert.equal(after.s, 6000);
|
||||
assert.equal(after.nextStop, -1);
|
||||
});
|
||||
|
||||
test('a pattern without a shape interpolates between its stops', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const noon = at('2026-11-01T17:00:00Z');
|
||||
const position = scheduledPosition(
|
||||
schedule,
|
||||
{ tripId: 'shuttle', serviceDate: '2026-11-01' },
|
||||
noon + 5 * MIN,
|
||||
);
|
||||
assert.equal(position.shape, null);
|
||||
assert.equal(position.s, 1250);
|
||||
assert.ok(Math.abs(position.lon - -73.985) < 1e-9);
|
||||
assert.equal(position.lat, 40);
|
||||
});
|
||||
|
||||
test('the stop list shows station-local 24 h clocks, states and chips', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const stops = tripStops(schedule, 'morning', MORNING + 320_000);
|
||||
assert.deepEqual(
|
||||
stops.map((stop) => [stop.stopId, stop.state, stop.atStop, stop.relative]),
|
||||
[
|
||||
['A', 'passed', false, 'T+5'],
|
||||
['B1', 'next', true, 'T−1'],
|
||||
['C', 'upcoming', false, 'T−10'],
|
||||
['D', 'upcoming', false, 'T−20'],
|
||||
],
|
||||
);
|
||||
assert.equal(stops[0].scheduledDeparture, '08:00');
|
||||
assert.equal(stops[1].scheduledArrival, '08:05');
|
||||
assert.equal(stops[1].scheduledDeparture, '08:06');
|
||||
assert.equal(stops[1].name, 'Track 1');
|
||||
assert.equal(stops[1].parentName, 'Bravo');
|
||||
assert.equal(stops[1].platform, '1');
|
||||
assert.equal(stops[1].timezone, 'America/New_York');
|
||||
// Delta keeps Chicago time: 08:25 Eastern is 07:25 Central.
|
||||
assert.equal(stops[3].timezone, 'America/Chicago');
|
||||
assert.equal(stops[3].scheduledArrival, '07:25');
|
||||
assert.equal(stops[3].scheduledArrivalMs, MORNING + 25 * MIN);
|
||||
assert.equal(stops[3].realtime, false);
|
||||
});
|
||||
|
||||
test('times past 24:00:00 land on the next calendar day', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const stops = tripStops(schedule, 'late', at('2026-11-01T05:00:00Z'));
|
||||
assert.equal(stops[0].scheduledDepartureMs, at('2026-11-01T05:00:00Z'));
|
||||
assert.equal(stops[0].scheduledDeparture, '01:00');
|
||||
assert.equal(stops[1].scheduledArrival, '01:05');
|
||||
// The fall-back trip at 01:30:00 runs an hour of real time later.
|
||||
const fallback = tripStops(schedule, 'fallback', at('2026-11-01T06:30:00Z'));
|
||||
assert.equal(fallback[0].scheduledDepartureMs, at('2026-11-01T06:30:00Z'));
|
||||
assert.equal(fallback[0].scheduledDeparture, '01:30');
|
||||
});
|
||||
|
||||
test('a stop delay carries on to later stops until the next update', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const delays = {
|
||||
morning: {
|
||||
stops: [
|
||||
{ stopId: 'B1', arrival: { delay: 120 } },
|
||||
{ stopSequence: 4, arrival: { time: (MORNING + 26 * MIN) / 1000 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const stops = tripStops(schedule, 'morning', MORNING, delays);
|
||||
assert.deepEqual(
|
||||
stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]),
|
||||
[
|
||||
[0, 0],
|
||||
[120, 120],
|
||||
[120, 120],
|
||||
[60, 60],
|
||||
],
|
||||
);
|
||||
assert.equal(stops[1].expectedArrival, '08:07');
|
||||
assert.equal(stops[1].scheduledArrival, '08:05');
|
||||
assert.equal(stops[1].realtime, true);
|
||||
// Between A (left on time) and B (arrives +7) the run is slower.
|
||||
const position = scheduledPosition(
|
||||
schedule,
|
||||
'morning',
|
||||
MORNING + 210_000,
|
||||
delays,
|
||||
);
|
||||
assert.equal(position.s, 500);
|
||||
assert.equal(position.delaySec, 120);
|
||||
// A late train is still active after its timetable ends.
|
||||
const late = { morning: { delaySec: 600 } };
|
||||
assert.deepEqual(
|
||||
activeTrips(schedule, MORNING + 30 * MIN).map((run) => run.tripId),
|
||||
[],
|
||||
);
|
||||
const [run] = activeTrips(schedule, MORNING + 30 * MIN, { delays: late });
|
||||
assert.equal(run.tripId, 'morning');
|
||||
assert.equal(run.expectedEndMs, MORNING + 35 * MIN);
|
||||
});
|
||||
|
||||
test('a delay pinned to a start date leaves other runs alone; parent ids match platforms', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const pinned = { morning: { startDate: '20261031', delaySec: 300 } };
|
||||
assert.equal(
|
||||
tripStops(schedule, 'morning', MORNING, pinned)[2].arrivalDelaySec,
|
||||
0,
|
||||
);
|
||||
const own = {
|
||||
morning: {
|
||||
startDate: '2026-11-01',
|
||||
stops: [{ stopId: 'B', departure: { delay: 60 } }],
|
||||
},
|
||||
};
|
||||
const stops = tripStops(schedule, 'morning', MORNING, own);
|
||||
assert.equal(stops[1].departureDelaySec, 60);
|
||||
assert.equal(stops[2].arrivalDelaySec, 60);
|
||||
});
|
||||
|
||||
test('a skipped stop is passed without a dwell', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
// The record shape src/data/railTripUpdates.js serves.
|
||||
const delays = {
|
||||
morning: {
|
||||
tripId: 'morning',
|
||||
startDate: '20261101',
|
||||
delaySec: null,
|
||||
stops: [
|
||||
{
|
||||
stopId: null,
|
||||
stopSequence: 2,
|
||||
arrival: null,
|
||||
departure: null,
|
||||
relationship: 'SKIPPED',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const position = scheduledPosition(
|
||||
schedule,
|
||||
'morning',
|
||||
MORNING + 450_000,
|
||||
delays,
|
||||
);
|
||||
assert.equal(position.dwell, false);
|
||||
assert.equal(position.prevStop, 0);
|
||||
assert.equal(position.nextStop, 2);
|
||||
assert.equal(position.s, 1500, 'halfway from A (+0) to C (+15)');
|
||||
assert.equal(
|
||||
tripStops(schedule, 'morning', MORNING, delays)[1].skipped,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a canceled run leaves the active list', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const now = MORNING + 10 * MIN;
|
||||
const canceled = { morning: { relationship: 'CANCELED' } };
|
||||
assert.equal(activeTrips(schedule, now).length, 1);
|
||||
assert.equal(activeTrips(schedule, now, { delays: canceled }).length, 0);
|
||||
});
|
||||
|
||||
test('NO_DATA at a stop ends the delay carried to it: the timetable applies again', () => {
|
||||
const schedule = createSchedule(pack());
|
||||
const delays = {
|
||||
morning: {
|
||||
delaySec: 300,
|
||||
stops: [
|
||||
{ stopId: 'B1', arrival: { delay: 240 } },
|
||||
{ stopId: 'C', relationship: 'NO_DATA' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const stops = tripStops(schedule, 'morning', MORNING, delays);
|
||||
assert.deepEqual(
|
||||
stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]),
|
||||
[
|
||||
[300, 300],
|
||||
[240, 240],
|
||||
// No prediction from C on: its time and D's are the timetable's.
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
);
|
||||
assert.equal(stops[2].expectedArrival, stops[2].scheduledArrival);
|
||||
// A later update after NO_DATA counts again.
|
||||
const later = {
|
||||
morning: {
|
||||
stops: [
|
||||
{ stopId: 'B1', arrival: { delay: 240 } },
|
||||
{ stopId: 'C', scheduleRelationship: 'NO_DATA' },
|
||||
{ stopSequence: 4, arrival: { delay: 60 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
tripStops(schedule, 'morning', MORNING, later).map(
|
||||
(stop) => stop.arrivalDelaySec,
|
||||
),
|
||||
[0, 240, 0, 60],
|
||||
);
|
||||
});
|
||||
|
||||
test('a pack ends when the last run it holds arrives', () => {
|
||||
// The latest run: the shuttle on its last date, 2 November, at noon New
|
||||
// York time for 10 minutes (31 October's 25:00 train arrives earlier).
|
||||
const end = schedulePackEndMs(pack());
|
||||
assert.equal(
|
||||
end,
|
||||
serviceDayOriginMs('2026-11-02', 'America/New_York') + 12 * H + 10 * MIN,
|
||||
);
|
||||
// Without a window the end is not known; with no run there is none.
|
||||
assert.equal(schedulePackEndMs(pack({ window: undefined })), null);
|
||||
assert.equal(schedulePackEndMs(pack({ trips: {} })), -Infinity);
|
||||
assert.equal(schedulePackEndMs(null), null);
|
||||
});
|
||||
|
||||
test('a live report states its own delay; a day or more either way is no delay', () => {
|
||||
assert.equal(liveDelaySec({ delaySec: 540 }), 540);
|
||||
assert.equal(liveDelaySec({ delaySec: -120 }), -120);
|
||||
assert.equal(liveDelaySec({ delaySec: 0 }), 0);
|
||||
for (const value of [null, undefined, '540', NaN, Infinity, 86_401, -86_401])
|
||||
assert.equal(liveDelaySec({ delaySec: value }), null, String(value));
|
||||
assert.equal(liveDelaySec(null), null);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @module railScheduleIndex
|
||||
* @description Server-side lookup over a schedule pack (the
|
||||
* `<feedId>.schedule.json` scripts/build-rail-paths.mjs writes) for a
|
||||
* realtime source that names a train by its number (Irish Rail's train
|
||||
* codes): `createRunIndex`, the dated runs of a pack's trips, so a reported
|
||||
* train takes the trip it is on (its line and stops). The train's position
|
||||
* is never taken from the timetable.
|
||||
*
|
||||
* Pure: no I/O. Times are epoch milliseconds; a run starts at the service
|
||||
* day's GTFS origin (noon minus 12 h in the pack's time zone) plus the
|
||||
* trip's start, and lasts its profile's last offset, as
|
||||
* src/data/railSchedule.js reads them.
|
||||
*/
|
||||
|
||||
import { serviceDayOriginMs } from './railTimeZones.js';
|
||||
|
||||
function packTrips(raw) {
|
||||
return raw?.trips && typeof raw.trips === 'object' ? raw.trips : {};
|
||||
}
|
||||
|
||||
function packServices(raw) {
|
||||
return raw?.services && typeof raw.services === 'object' ? raw.services : {};
|
||||
}
|
||||
|
||||
function serviceDates(services, service) {
|
||||
const dates = services[service];
|
||||
return Array.isArray(dates) ? dates : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated runs of a pack's trips. Built lazily per trip, so a source that
|
||||
* looks up a few hundred train numbers never expands the whole pack. It
|
||||
* keeps the parts of the pack it reads (trips, profiles, services), not the
|
||||
* pack itself.
|
||||
* @param {object} raw Decoded schedule pack.
|
||||
* @returns {{runsOf: (tripId: string) => Array<{tripId: string, date: string,
|
||||
* startMs: number, endMs: number}>,
|
||||
* tripsNamed: (name: string) => string[],
|
||||
* activeTrip: (name: string, nowMs: number,
|
||||
* options?: {slackMs?: number, delayMs?: number}) =>
|
||||
* ({tripId: string, date: string, startMs: number, endMs: number}|null)}}
|
||||
*/
|
||||
export function createRunIndex(raw) {
|
||||
const trips = packTrips(raw);
|
||||
const services = packServices(raw);
|
||||
const zone = typeof raw?.timezone === 'string' ? raw.timezone : 'UTC';
|
||||
const profiles = Array.isArray(raw?.profiles) ? raw.profiles : [];
|
||||
const origins = new Map();
|
||||
const originOf = (date) => {
|
||||
let origin = origins.get(date);
|
||||
if (origin === undefined) {
|
||||
origin = serviceDayOriginMs(date, zone);
|
||||
origins.set(date, origin);
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
let names = null;
|
||||
const runs = new Map();
|
||||
function runsOf(tripId) {
|
||||
let list = runs.get(tripId);
|
||||
if (list) return list;
|
||||
list = [];
|
||||
const trip = Object.hasOwn(trips, tripId) ? trips[tripId] : null;
|
||||
const profile = trip ? profiles[trip.profile] : null;
|
||||
if (trip && Array.isArray(profile) && Number.isFinite(trip.startSec)) {
|
||||
const durationMs = (Number(profile[profile.length - 1]) || 0) * 1000;
|
||||
for (const date of serviceDates(services, trip.service)) {
|
||||
const origin = originOf(date);
|
||||
if (!Number.isFinite(origin)) continue;
|
||||
const startMs = origin + trip.startSec * 1000;
|
||||
list.push({ tripId, date, startMs, endMs: startMs + durationMs });
|
||||
}
|
||||
}
|
||||
runs.set(tripId, list);
|
||||
return list;
|
||||
}
|
||||
return {
|
||||
runsOf,
|
||||
tripsNamed(name) {
|
||||
if (!names) {
|
||||
names = new Map();
|
||||
for (const [tripId, trip] of Object.entries(trips)) {
|
||||
if (typeof trip?.name !== 'string' || !trip.name) continue;
|
||||
const list = names.get(trip.name);
|
||||
if (list) list.push(tripId);
|
||||
else names.set(trip.name, [tripId]);
|
||||
}
|
||||
}
|
||||
return names.get(String(name ?? '')) ?? [];
|
||||
},
|
||||
/**
|
||||
* The run of a train number that is under way at `nowMs` (or within
|
||||
* `slackMs` of its start or end); the one whose span is nearest when
|
||||
* several qualify, null when none or when two tie. A train running
|
||||
* `delayMs` late (negative: early) is where its timetable was that long
|
||||
* ago, so its runs are compared at `nowMs - delayMs`.
|
||||
*/
|
||||
activeTrip(name, nowMs, { slackMs = 10 * 60_000, delayMs = 0 } = {}) {
|
||||
const at = nowMs - (Number.isFinite(delayMs) ? delayMs : 0);
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
let tie = false;
|
||||
for (const tripId of this.tripsNamed(name)) {
|
||||
for (const run of runsOf(tripId)) {
|
||||
if (at < run.startMs - slackMs || at > run.endMs + slackMs) continue;
|
||||
const score =
|
||||
at < run.startMs
|
||||
? run.startMs - at
|
||||
: at > run.endMs
|
||||
? at - run.endMs
|
||||
: 0;
|
||||
if (score < bestScore) {
|
||||
best = run;
|
||||
bestScore = score;
|
||||
tie = false;
|
||||
} else if (score === bestScore && best && run.tripId !== best.tripId)
|
||||
tie = true;
|
||||
}
|
||||
}
|
||||
return tie ? null : best;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @module railSiriVm
|
||||
* @description Adapter for SIRI Vehicle Monitoring (SIRI Lite `vm`, as
|
||||
* Entur publishes it at `api.entur.io/realtime/v1/rest/vm`), turning each
|
||||
* `<VehicleActivity>` into the train records the GTFS-RT path produces.
|
||||
*
|
||||
* Only the handful of elements a position needs are read (RecordedAtTime,
|
||||
* ValidUntilTime, LineRef, VehicleMode, VehicleLocation, Bearing, Delay,
|
||||
* VehicleJourneyRef, VehicleRef), with a small scanner instead of an XML
|
||||
* library: no DTDs, no entity definitions, attributes ignored, an optional
|
||||
* namespace prefix on any element. A document that declares a DTD or
|
||||
* entities is refused, and every size is capped (whole text, activity count,
|
||||
* field length).
|
||||
*
|
||||
* The positions are the operator's own GPS reports, each with the time it
|
||||
* was recorded, so records carry `timestampSource: 'vehicle'`. An activity
|
||||
* whose `ValidUntilTime` has passed is dropped, as is one whose
|
||||
* `VehicleMode` says it is not a train (Entur lists replacement buses under
|
||||
* the train lines).
|
||||
*
|
||||
* Pure: no Node built-ins, safe for node:test and any server runtime.
|
||||
*/
|
||||
|
||||
import { isPlausibleVehiclePosition } from './gtfsRealtime.js';
|
||||
import { decodeXmlText } from './irishRail.js';
|
||||
|
||||
/** Longest document read (characters). SJ Nord's is about 7 KB. */
|
||||
export const SIRI_VM_MAX_TEXT_CHARS = 2 * 1024 * 1024;
|
||||
/** Most activities read from one document. */
|
||||
export const SIRI_VM_MAX_ACTIVITIES = 5000;
|
||||
/** Longest accepted field (characters). */
|
||||
export const SIRI_VM_MAX_FIELD_CHARS = 256;
|
||||
|
||||
/** Error for a document this adapter will not read. */
|
||||
export class SiriVmFormatError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'SiriVmFormatError';
|
||||
}
|
||||
}
|
||||
|
||||
/** `<Name>` or `<prefix:Name>` (no attributes kept), up to its close. */
|
||||
function readField(block, name) {
|
||||
const match = new RegExp(
|
||||
`<(?:[A-Za-z_][\\w.-]*:)?${name}(?:\\s[^>]*)?>([^<]*)</(?:[A-Za-z_][\\w.-]*:)?${name}>`,
|
||||
).exec(block);
|
||||
if (!match || match[1].length > SIRI_VM_MAX_FIELD_CHARS * 6) return null;
|
||||
const value = decodeXmlText(match[1]).trim();
|
||||
return value.length <= SIRI_VM_MAX_FIELD_CHARS ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds in an ISO 8601 duration as SIRI writes a delay (`PT0S`,
|
||||
* `PT2M30S`, `-PT45S`, `P1DT1H`), or null.
|
||||
* @param {string|null} text
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function parseIsoDurationSec(text) {
|
||||
const match =
|
||||
/^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(
|
||||
String(text ?? '').trim(),
|
||||
);
|
||||
if (!match || !(match[2] || match[3] || match[4] || match[5])) return null;
|
||||
const [, sign, d = 0, h = 0, m = 0, s = 0] = match;
|
||||
const total =
|
||||
Number(d) * 86_400 + Number(h) * 3600 + Number(m) * 60 + Number(s);
|
||||
return Math.round(sign ? -total : total);
|
||||
}
|
||||
|
||||
/** Epoch seconds of an ISO date-time with its offset, or null. */
|
||||
function epochSeconds(text) {
|
||||
const ms = Date.parse(String(text ?? ''));
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a SIRI VM document into raw activity rows.
|
||||
* @param {string} text
|
||||
* @returns {{rows: Array<Record<string, string|null>>, truncated: boolean,
|
||||
* responseTimestamp: string|null}}
|
||||
*/
|
||||
export function parseSiriVmXml(text) {
|
||||
if (typeof text !== 'string') throw new SiriVmFormatError('not text');
|
||||
if (text.length > SIRI_VM_MAX_TEXT_CHARS)
|
||||
throw new SiriVmFormatError(`document is ${text.length} characters`);
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(text))
|
||||
throw new SiriVmFormatError('document declares a DTD');
|
||||
if (!/<(?:[A-Za-z_][\w.-]*:)?VehicleMonitoringDelivery[\s>]/.test(text))
|
||||
throw new SiriVmFormatError('not a vehicle monitoring document');
|
||||
const open = /<(?:[A-Za-z_][\w.-]*:)?VehicleActivity(?:\s[^>]*)?>/g;
|
||||
const close = /<\/(?:[A-Za-z_][\w.-]*:)?VehicleActivity>/g;
|
||||
const rows = [];
|
||||
let truncated = false;
|
||||
let found;
|
||||
while ((found = open.exec(text))) {
|
||||
close.lastIndex = open.lastIndex;
|
||||
const end = close.exec(text);
|
||||
if (!end) break;
|
||||
if (rows.length >= SIRI_VM_MAX_ACTIVITIES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const block = text.slice(open.lastIndex, end.index);
|
||||
rows.push({
|
||||
recordedAt: readField(block, 'RecordedAtTime'),
|
||||
validUntil: readField(block, 'ValidUntilTime'),
|
||||
lineRef: readField(block, 'LineRef'),
|
||||
mode: readField(block, 'VehicleMode'),
|
||||
latitude: readField(block, 'Latitude'),
|
||||
longitude: readField(block, 'Longitude'),
|
||||
bearing: readField(block, 'Bearing'),
|
||||
delay: readField(block, 'Delay'),
|
||||
journeyRef:
|
||||
readField(block, 'VehicleJourneyRef') ??
|
||||
readField(block, 'DatedVehicleJourneyRef'),
|
||||
vehicleRef: readField(block, 'VehicleRef'),
|
||||
lineName: readField(block, 'PublishedLineName'),
|
||||
});
|
||||
open.lastIndex = end.index + end[0].length;
|
||||
}
|
||||
const head = text.slice(0, 4096);
|
||||
return {
|
||||
rows,
|
||||
truncated,
|
||||
responseTimestamp: readField(head, 'ResponseTimestamp'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a SIRI VM document into unclassified train records in the shape the
|
||||
* GTFS-RT path produces (src/data/gtfsRealtime.js), plus `delaySec`.
|
||||
*
|
||||
* `routeId` is the `LineRef` (Entur: the same `<codespace>:Line:<n>` ids as
|
||||
* the static GTFS), `tripId` the `VehicleJourneyRef` (Entur: a
|
||||
* `DatedServiceJourney`, not the GTFS trip id; the timetable matches the
|
||||
* train to a run by line and position instead). One record per vehicle:
|
||||
* a vehicle listed twice keeps its latest report.
|
||||
*
|
||||
* @param {string} text The XML document.
|
||||
* @param {number} fetchedAtS Fetch time (epoch seconds), used when an
|
||||
* activity carries no RecordedAtTime and to drop expired activities.
|
||||
* @returns {{entityCount: number, truncated: boolean,
|
||||
* feedTimestamp: number|null, vehicles: object[]}}
|
||||
*/
|
||||
export function decodeSiriVehicleMonitoring(text, fetchedAtS) {
|
||||
const { rows, truncated, responseTimestamp } = parseSiriVmXml(text);
|
||||
const byId = new Map();
|
||||
for (const row of rows) {
|
||||
if (row.mode && !/^rail$/i.test(row.mode)) continue;
|
||||
const lat = Number.parseFloat(row.latitude ?? '');
|
||||
const lon = Number.parseFloat(row.longitude ?? '');
|
||||
if (!isPlausibleVehiclePosition(lat, lon)) continue;
|
||||
const validUntil = epochSeconds(row.validUntil);
|
||||
if (validUntil !== null && validUntil < fetchedAtS) continue;
|
||||
const id = row.vehicleRef || row.journeyRef;
|
||||
if (!id || !/^[\w:.-]{1,128}$/.test(id)) continue;
|
||||
const recorded = epochSeconds(row.recordedAt);
|
||||
const bearing = Number.parseFloat(row.bearing ?? '');
|
||||
const record = {
|
||||
id,
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lon: Number(lon.toFixed(6)),
|
||||
bearing:
|
||||
Number.isFinite(bearing) && bearing >= 0 && bearing <= 360
|
||||
? bearing
|
||||
: null,
|
||||
speedMps: null,
|
||||
timestamp: recorded ?? fetchedAtS,
|
||||
timestampSource: recorded === null ? 'fetch' : 'vehicle',
|
||||
routeId: row.lineRef || null,
|
||||
tripId: row.journeyRef || null,
|
||||
directionId: null,
|
||||
label: row.lineName || null,
|
||||
stopId: null,
|
||||
status: null,
|
||||
occupancy: null,
|
||||
delaySec: parseIsoDurationSec(row.delay),
|
||||
};
|
||||
const known = byId.get(id);
|
||||
if (!known || record.timestamp >= known.timestamp) byId.set(id, record);
|
||||
}
|
||||
return {
|
||||
entityCount: rows.length,
|
||||
truncated,
|
||||
feedTimestamp: epochSeconds(responseTimestamp),
|
||||
vehicles: [...byId.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SIRI_VM_MAX_ACTIVITIES,
|
||||
SiriVmFormatError,
|
||||
decodeSiriVehicleMonitoring,
|
||||
parseIsoDurationSec,
|
||||
parseSiriVmXml,
|
||||
} from './railSiriVm.js';
|
||||
import { buildRailSnapshot } from './railProxy.js';
|
||||
import { getRegisteredRailFeed } from './railFeeds.js';
|
||||
|
||||
const NOW_S = Date.parse('2026-11-09T16:27:00Z') / 1000;
|
||||
|
||||
/** One `<VehicleActivity>`, shaped like Entur's SIRI Lite answer. */
|
||||
function activity({
|
||||
recorded = '2026-11-09T17:26:17+01:00',
|
||||
validUntil = '2026-11-09T17:29:17+01:00',
|
||||
line = 'SJN:Line:25',
|
||||
lat = '63.42107333333333',
|
||||
lon = '10.377486666666666',
|
||||
delay = 'PT0S',
|
||||
journey = 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09',
|
||||
vehicle = '957605930081',
|
||||
extra = '',
|
||||
} = {}) {
|
||||
return `<VehicleActivity><RecordedAtTime>${recorded}</RecordedAtTime><ValidUntilTime>${validUntil}</ValidUntilTime><MonitoredVehicleJourney><LineRef>${line}</LineRef><DataSource>SJN</DataSource>${extra}<VehicleLocation><Longitude>${lon}</Longitude><Latitude>${lat}</Latitude></VehicleLocation><Delay>${delay}</Delay><VehicleJourneyRef>${journey}</VehicleJourneyRef><VehicleRef>${vehicle}</VehicleRef></MonitoredVehicleJourney></VehicleActivity>`;
|
||||
}
|
||||
|
||||
function document(...activities) {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Siri xmlns="http://www.siri.org.uk/siri" version="2.0"><ServiceDelivery><ResponseTimestamp>2026-11-09T17:26:46.11584667+01:00</ResponseTimestamp><ProducerRef>ENT</ProducerRef><VehicleMonitoringDelivery version="2.0"><ResponseTimestamp>2026-11-09T17:26:46.115848853+01:00</ResponseTimestamp>${activities.join('')}</VehicleMonitoringDelivery></ServiceDelivery></Siri>`;
|
||||
}
|
||||
|
||||
test('SIRI delays are ISO 8601 durations', () => {
|
||||
assert.equal(parseIsoDurationSec('PT0S'), 0);
|
||||
assert.equal(parseIsoDurationSec('PT2M30S'), 150);
|
||||
assert.equal(parseIsoDurationSec('-PT45S'), -45);
|
||||
assert.equal(parseIsoDurationSec('PT1H'), 3600);
|
||||
assert.equal(parseIsoDurationSec('P1DT1M'), 86_460);
|
||||
assert.equal(parseIsoDurationSec('PT1.6S'), 2);
|
||||
assert.equal(parseIsoDurationSec('P'), null);
|
||||
assert.equal(parseIsoDurationSec('soon'), null);
|
||||
assert.equal(parseIsoDurationSec(null), null);
|
||||
});
|
||||
|
||||
test('a vehicle activity becomes a train record with its own report time', () => {
|
||||
const decoded = decodeSiriVehicleMonitoring(
|
||||
document(activity({ delay: 'PT3M' })),
|
||||
NOW_S,
|
||||
);
|
||||
assert.equal(decoded.entityCount, 1);
|
||||
assert.equal(decoded.truncated, false);
|
||||
assert.equal(
|
||||
decoded.feedTimestamp,
|
||||
Math.floor(Date.parse('2026-11-09T16:26:46.115Z') / 1000),
|
||||
);
|
||||
assert.deepEqual(decoded.vehicles, [
|
||||
{
|
||||
id: '957605930081',
|
||||
lat: 63.421073,
|
||||
lon: 10.377487,
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: Date.parse('2026-11-09T16:26:17Z') / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
routeId: 'SJN:Line:25',
|
||||
tripId: 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09',
|
||||
directionId: null,
|
||||
label: null,
|
||||
stopId: null,
|
||||
status: null,
|
||||
occupancy: null,
|
||||
delaySec: 180,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buses, expired reports, bad positions and duplicates are dropped', () => {
|
||||
const decoded = decodeSiriVehicleMonitoring(
|
||||
document(
|
||||
activity({ vehicle: 'bus', extra: '<VehicleMode>bus</VehicleMode>' }),
|
||||
activity({ vehicle: 'rail', extra: '<VehicleMode>rail</VehicleMode>' }),
|
||||
activity({ vehicle: 'old', validUntil: '2026-11-09T17:20:00+01:00' }),
|
||||
activity({ vehicle: 'nowhere', lat: '0', lon: '0' }),
|
||||
activity({ vehicle: 'twice', recorded: '2026-11-09T17:25:00+01:00' }),
|
||||
activity({ vehicle: 'twice', recorded: '2026-11-09T17:26:00+01:00' }),
|
||||
activity({ vehicle: 'bad id!' }),
|
||||
),
|
||||
NOW_S,
|
||||
);
|
||||
assert.equal(decoded.entityCount, 7);
|
||||
assert.deepEqual(
|
||||
decoded.vehicles.map((v) => v.id),
|
||||
['rail', 'twice'],
|
||||
);
|
||||
assert.equal(
|
||||
decoded.vehicles[1].timestamp,
|
||||
Date.parse('2026-11-09T16:26:00Z') / 1000,
|
||||
'the later of two reports',
|
||||
);
|
||||
});
|
||||
|
||||
test('a report without a time takes the fetch time, labelled so', () => {
|
||||
const xml = document(activity()).replace(
|
||||
/<RecordedAtTime>[^<]*<\/RecordedAtTime>/,
|
||||
'',
|
||||
);
|
||||
const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles;
|
||||
assert.equal(train.timestamp, NOW_S);
|
||||
assert.equal(train.timestampSource, 'fetch');
|
||||
});
|
||||
|
||||
test('namespace prefixes, bearings and published line names are read', () => {
|
||||
const xml = document(
|
||||
activity({ extra: '<Bearing>91.5</Bearing>' }).replace(
|
||||
/<(\/?)(VehicleActivity|LineRef|Latitude|Longitude)>/g,
|
||||
'<$1siri:$2>',
|
||||
),
|
||||
).replace(
|
||||
'<VehicleJourneyRef>',
|
||||
'<PublishedLineName>F6</PublishedLineName><VehicleJourneyRef>',
|
||||
);
|
||||
const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles;
|
||||
assert.equal(train.routeId, 'SJN:Line:25');
|
||||
assert.equal(train.bearing, 91.5);
|
||||
assert.equal(train.label, 'F6');
|
||||
});
|
||||
|
||||
test('documents that are not vehicle monitoring, or declare a DTD, are refused', () => {
|
||||
assert.throws(() => parseSiriVmXml(42), SiriVmFormatError);
|
||||
assert.throws(
|
||||
() => parseSiriVmXml('<html><body>Bad gateway</body></html>'),
|
||||
SiriVmFormatError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseSiriVmXml(`<!DOCTYPE x [<!ENTITY a "b">]>${document(activity())}`),
|
||||
SiriVmFormatError,
|
||||
);
|
||||
assert.equal(parseSiriVmXml(document()).rows.length, 0);
|
||||
});
|
||||
|
||||
test('the activity count is capped', () => {
|
||||
const many = Array.from(
|
||||
{ length: SIRI_VM_MAX_ACTIVITIES + 2 },
|
||||
(_, i) =>
|
||||
`<VehicleActivity><VehicleRef>v${i}</VehicleRef></VehicleActivity>`,
|
||||
);
|
||||
const parsed = parseSiriVmXml(document(...many));
|
||||
assert.equal(parsed.rows.length, SIRI_VM_MAX_ACTIVITIES);
|
||||
assert.equal(parsed.truncated, true);
|
||||
});
|
||||
|
||||
test('the rail proxy reads SJ Nord from SIRI VM and classifies its lines', () => {
|
||||
const feed = getRegisteredRailFeed('entur-sj-nord');
|
||||
assert.equal(feed.format, 'siri-vm');
|
||||
const bytes = new TextEncoder().encode(
|
||||
document(
|
||||
activity({ vehicle: 'a', line: 'SJN:Line:25' }),
|
||||
activity({ vehicle: 'b', line: 'SJN:Line:26' }),
|
||||
activity({ vehicle: 'c', line: 'SJN:Line:72' }),
|
||||
activity({ vehicle: 'd', line: 'SJN:Line:999' }),
|
||||
),
|
||||
);
|
||||
const snapshot = buildRailSnapshot(feed, bytes, NOW_S * 1000);
|
||||
assert.equal(snapshot.positions, 'gps');
|
||||
assert.equal(snapshot.entityCount, 4);
|
||||
assert.equal(snapshot.dropped, 1, 'an unknown line');
|
||||
assert.deepEqual(
|
||||
snapshot.trains.map((t) => [t.id, t.category, t.positionSource]),
|
||||
[
|
||||
['a', 'national', 'gps'],
|
||||
['b', 'regional', 'gps'],
|
||||
['c', 'international', 'gps'],
|
||||
],
|
||||
);
|
||||
assert.ok(Number.isFinite(snapshot.feedTimestamp));
|
||||
assert.throws(
|
||||
() => buildRailSnapshot(feed, new TextEncoder().encode('<html/>'), 0),
|
||||
(error) => error.code === 'UPSTREAM_UNREADABLE',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @module railTimeZones
|
||||
* @description Wall-clock arithmetic in IANA time zones for rail timetables,
|
||||
* built on Intl.DateTimeFormat only.
|
||||
*
|
||||
* GTFS measures stop times from "noon minus 12 hours" of the service day in
|
||||
* the agency's time zone: midnight on ordinary days, and the hour that keeps
|
||||
* every timetable time unambiguous on the two days a year the clocks change.
|
||||
* `serviceDayOriginMs` returns that instant; a stop time of `sec` seconds is
|
||||
* `origin + sec * 1000`, which also covers times past 24:00:00.
|
||||
*
|
||||
* Pure: no Cesium, no DOM, no Node built-ins.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const formatters = new Map();
|
||||
const clocks = new Map();
|
||||
|
||||
/** Whether `zone` is an IANA zone this runtime knows. */
|
||||
export function isTimeZone(zone) {
|
||||
if (typeof zone !== 'string' || !zone) return false;
|
||||
try {
|
||||
partsFormatter(zone);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function partsFormatter(zone) {
|
||||
let formatter = formatters.get(zone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
formatters.set(zone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall-clock fields of an instant in a zone.
|
||||
* @param {number} epochMs
|
||||
* @param {string} zone
|
||||
* @returns {{year: number, month: number, day: number, hour: number,
|
||||
* minute: number, second: number}}
|
||||
*/
|
||||
export function zonedParts(epochMs, zone) {
|
||||
const out = {};
|
||||
for (const part of partsFormatter(zone).formatToParts(epochMs))
|
||||
if (part.type !== 'literal') out[part.type] = Number(part.value);
|
||||
return {
|
||||
year: out.year,
|
||||
month: out.month,
|
||||
day: out.day,
|
||||
hour: out.hour % 24,
|
||||
minute: out.minute,
|
||||
second: out.second,
|
||||
};
|
||||
}
|
||||
|
||||
/** UTC offset of a zone at an instant, in ms (east positive). */
|
||||
export function zoneOffsetMs(epochMs, zone) {
|
||||
const p = zonedParts(epochMs, zone);
|
||||
const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
return wall - Math.floor(epochMs / 1000) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a wall-clock time occurs in a zone. In a gap (spring forward)
|
||||
* the result is shifted forward by the gap; in an overlap (fall back) the
|
||||
* earlier instant is returned.
|
||||
*/
|
||||
export function zonedTimeToEpochMs(
|
||||
zone,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour = 0,
|
||||
minute = 0,
|
||||
second = 0,
|
||||
) {
|
||||
const wall = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const first = zoneOffsetMs(wall, zone);
|
||||
let guess = wall - first;
|
||||
const offset2 = zoneOffsetMs(guess, zone);
|
||||
if (offset2 !== first) {
|
||||
const alternative = wall - offset2;
|
||||
// Prefer the candidate whose wall time round-trips; else the earlier.
|
||||
if (zoneOffsetMs(alternative, zone) === offset2)
|
||||
guess = Math.min(guess, alternative);
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
/** Parse 'YYYY-MM-DD' or 'YYYYMMDD' into numbers, or null. */
|
||||
export function parseServiceDate(text) {
|
||||
const match = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(String(text ?? '').trim());
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const check = new Date(Date.UTC(year, month - 1, day));
|
||||
if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day)
|
||||
return null;
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' of a UTC-day number (days since 1970-01-01). */
|
||||
export function dateFromDayNumber(dayNumber) {
|
||||
return new Date(dayNumber * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** UTC-day number of a service date, or NaN. */
|
||||
export function dayNumberOf(text) {
|
||||
const date = parseServiceDate(text);
|
||||
return date ? Date.UTC(date.year, date.month - 1, date.day) / DAY_MS : NaN;
|
||||
}
|
||||
|
||||
/** The local calendar date ('YYYY-MM-DD') of an instant in a zone. */
|
||||
export function localDate(epochMs, zone) {
|
||||
const p = zonedParts(epochMs, zone);
|
||||
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant GTFS times of a service date count from: noon minus 12 hours,
|
||||
* local to `zone`.
|
||||
* @param {string} date 'YYYY-MM-DD' or 'YYYYMMDD'.
|
||||
* @param {string} zone IANA zone.
|
||||
* @returns {number} Epoch ms, or NaN for a malformed date.
|
||||
*/
|
||||
export function serviceDayOriginMs(date, zone) {
|
||||
const parsed = parseServiceDate(date);
|
||||
if (!parsed) return NaN;
|
||||
return (
|
||||
zonedTimeToEpochMs(zone, parsed.year, parsed.month, parsed.day, 12) -
|
||||
12 * 3_600_000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A 24-hour 'HH:MM' clock string of an instant in a zone.
|
||||
* @param {number} epochMs
|
||||
* @param {string} zone
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatClock(epochMs, zone) {
|
||||
if (!Number.isFinite(epochMs)) return '';
|
||||
let formatter = clocks.get(zone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
clocks.set(zone, formatter);
|
||||
}
|
||||
let hour = '';
|
||||
let minute = '';
|
||||
for (const part of formatter.formatToParts(epochMs)) {
|
||||
if (part.type === 'hour') hour = part.value;
|
||||
else if (part.type === 'minute') minute = part.value;
|
||||
}
|
||||
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @module railTripUpdates
|
||||
* @description Minimal, bounded GTFS-Realtime decoder for TripUpdates feeds,
|
||||
* for the Rail layer's live delays.
|
||||
*
|
||||
* Same approach as the VehiclePositions decoder (src/data/gtfsRealtime.js):
|
||||
* `pbf` straight off the wire, field numbers as the contract, no generated
|
||||
* code. Only what a delay readout needs is read; vehicle positions, alerts and
|
||||
* every extension are skipped by pbf.
|
||||
*
|
||||
* Field numbers (gtfs-realtime.proto, v2.0):
|
||||
* FeedEntity 3 trip_update
|
||||
* TripUpdate 1 trip, 2 stop_time_update[], 3 vehicle, 4 timestamp,
|
||||
* 5 delay
|
||||
* StopTimeUpdate 1 stop_sequence, 2 arrival, 3 departure, 4 stop_id,
|
||||
* 5 schedule_relationship
|
||||
* StopTimeEvent 1 delay (int32), 2 time (int64), 3 uncertainty
|
||||
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
|
||||
* 4 schedule_relationship, 5 route_id, 6 direction_id
|
||||
*
|
||||
* Decoding is bounded the way the vehicle decoder is: entities past the cap
|
||||
* are left unread, stop updates past the per-trip cap are left unread, and an
|
||||
* over-long string drops the record it belongs to instead of truncating it.
|
||||
*
|
||||
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
|
||||
*/
|
||||
|
||||
import { PbfReader } from 'pbf';
|
||||
import {
|
||||
GTFS_INCREMENTALITY_FULL_DATASET,
|
||||
GTFS_MAX_ENTITIES,
|
||||
GTFS_MAX_STRING_CHARS,
|
||||
} from './gtfsRealtime.js';
|
||||
|
||||
/** Stop updates kept per trip. The longest rail trips call at ~100 stops. */
|
||||
export const RAIL_TRIP_UPDATE_MAX_STOPS = 200;
|
||||
/** Trips kept per decode, before the rail filter. */
|
||||
export const RAIL_TRIP_UPDATE_MAX_TRIPS = 20_000;
|
||||
/**
|
||||
* Plausible delay range (seconds). A day either way covers every honest
|
||||
* report; a value outside it is a unit error, not a late train.
|
||||
*/
|
||||
export const RAIL_TRIP_UPDATE_MAX_DELAY_S = 86_400;
|
||||
|
||||
/** TripDescriptor.ScheduleRelationship enum → label. */
|
||||
const TRIP_RELATIONSHIP = Object.freeze({
|
||||
0: 'SCHEDULED',
|
||||
1: 'ADDED',
|
||||
2: 'UNSCHEDULED',
|
||||
3: 'CANCELED',
|
||||
5: 'REPLACEMENT',
|
||||
6: 'DUPLICATED',
|
||||
7: 'DELETED',
|
||||
8: 'NEW',
|
||||
});
|
||||
|
||||
/** StopTimeUpdate.ScheduleRelationship enum → label. */
|
||||
const STOP_RELATIONSHIP = Object.freeze({
|
||||
0: 'SCHEDULED',
|
||||
1: 'SKIPPED',
|
||||
2: 'NO_DATA',
|
||||
3: 'UNSCHEDULED',
|
||||
});
|
||||
|
||||
function readBoundedString(pbf, target) {
|
||||
const value = pbf.readString();
|
||||
if (value.length <= GTFS_MAX_STRING_CHARS) return value;
|
||||
target.oversize = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readFeedHeader(tag, header, pbf) {
|
||||
if (tag === 1) header.version = readBoundedString(pbf, header);
|
||||
else if (tag === 2) header.incrementality = pbf.readVarint();
|
||||
else if (tag === 3) header.timestamp = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readTripDescriptor(tag, trip, pbf) {
|
||||
if (tag === 1) trip.tripId = readBoundedString(pbf, trip);
|
||||
else if (tag === 2) trip.startTime = readBoundedString(pbf, trip);
|
||||
else if (tag === 3) trip.startDate = readBoundedString(pbf, trip);
|
||||
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
|
||||
else if (tag === 5) trip.routeId = readBoundedString(pbf, trip);
|
||||
else if (tag === 6) trip.directionId = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readVehicleDescriptor(tag, descriptor, pbf) {
|
||||
if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor);
|
||||
else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor);
|
||||
}
|
||||
|
||||
function readStopTimeEvent(tag, event, pbf) {
|
||||
// int32 and int64 are plain (not zig-zag) varints: negatives arrive as
|
||||
// ten-byte two's complement, which the signed read restores.
|
||||
if (tag === 1) event.delay = pbf.readVarint(true);
|
||||
else if (tag === 2) event.time = pbf.readVarint(true);
|
||||
}
|
||||
|
||||
function readStopTimeUpdate(tag, update, pbf) {
|
||||
if (tag === 1) update.stopSequence = pbf.readVarint();
|
||||
else if (tag === 2) update.arrival = pbf.readMessage(readStopTimeEvent, {});
|
||||
else if (tag === 3) update.departure = pbf.readMessage(readStopTimeEvent, {});
|
||||
else if (tag === 4) update.stopId = readBoundedString(pbf, update);
|
||||
else if (tag === 5) update.scheduleRelationship = pbf.readVarint();
|
||||
}
|
||||
|
||||
function readTripUpdate(tag, tripUpdate, pbf) {
|
||||
if (tag === 1) tripUpdate.trip = pbf.readMessage(readTripDescriptor, {});
|
||||
else if (tag === 2) {
|
||||
// Past the cap the stop is left unread; pbf skips it whole.
|
||||
if (tripUpdate.stops.length >= RAIL_TRIP_UPDATE_MAX_STOPS) {
|
||||
tripUpdate.stopsTruncated = true;
|
||||
return;
|
||||
}
|
||||
tripUpdate.stops.push(pbf.readMessage(readStopTimeUpdate, {}));
|
||||
} else if (tag === 3)
|
||||
tripUpdate.vehicle = pbf.readMessage(readVehicleDescriptor, {});
|
||||
else if (tag === 4) tripUpdate.timestamp = pbf.readVarint();
|
||||
else if (tag === 5) tripUpdate.delay = pbf.readVarint(true);
|
||||
}
|
||||
|
||||
function readFeedEntity(tag, entity, pbf) {
|
||||
if (tag === 1) entity.id = readBoundedString(pbf, entity);
|
||||
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
|
||||
else if (tag === 3)
|
||||
entity.tripUpdate = pbf.readMessage(readTripUpdate, { stops: [] });
|
||||
// 4 (vehicle) and 5 (alert) are skipped: a combined feed (the MTA's) costs
|
||||
// one length varint per vehicle here.
|
||||
}
|
||||
|
||||
function readFeedMessage(tag, message, pbf) {
|
||||
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
|
||||
else if (tag === 2) {
|
||||
if (message.entityCount >= GTFS_MAX_ENTITIES) {
|
||||
message.truncated = true;
|
||||
return;
|
||||
}
|
||||
message.entityCount += 1;
|
||||
const entity = pbf.readMessage(readFeedEntity, {});
|
||||
if (!entity.tripUpdate) return;
|
||||
if (message.tripUpdates.length >= RAIL_TRIP_UPDATE_MAX_TRIPS) {
|
||||
message.truncated = true;
|
||||
return;
|
||||
}
|
||||
message.tripUpdates.push(entity);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function plausibleDelay(value) {
|
||||
return Number.isInteger(value) &&
|
||||
Math.abs(value) <= RAIL_TRIP_UPDATE_MAX_DELAY_S
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Epoch seconds between 2000 and 2100, else null. */
|
||||
function plausibleTime(value) {
|
||||
return Number.isInteger(value) && value > 946_684_800 && value < 4_102_444_800
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeEvent(event) {
|
||||
if (!event) return null;
|
||||
const time = plausibleTime(event.time);
|
||||
const delay = plausibleDelay(event.delay);
|
||||
if (time === null && delay === null) return null;
|
||||
return { time, delay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one decoded FeedEntity into the trip record `/api/rail/updates`
|
||||
* serves, or null when it carries no usable trip.
|
||||
*
|
||||
* `delaySec` is the trip's own delay when the operator states one, else the
|
||||
* delay at the first stop update that carries one (feeds list the stops still
|
||||
* ahead, so that is the delay the train is running with now). Feeds that only
|
||||
* publish predicted times (MBTA) have no delay to report: null, not zero.
|
||||
* @param {object} entity Decoded FeedEntity.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function normalizeTripUpdateEntity(entity) {
|
||||
if (!entity || entity.isDeleted === true) return null;
|
||||
const update = entity.tripUpdate;
|
||||
if (!update || entity.oversize || update.trip?.oversize) return null;
|
||||
if (update.vehicle?.oversize) return null;
|
||||
const tripId = nonEmptyString(update.trip?.tripId);
|
||||
const routeId = nonEmptyString(update.trip?.routeId);
|
||||
if (!tripId && !routeId) return null;
|
||||
const stops = [];
|
||||
for (const stop of update.stops) {
|
||||
if (stop.oversize) continue;
|
||||
const arrival = normalizeEvent(stop.arrival);
|
||||
const departure = normalizeEvent(stop.departure);
|
||||
const relationship = STOP_RELATIONSHIP[stop.scheduleRelationship] || null;
|
||||
const stopId = nonEmptyString(stop.stopId);
|
||||
const stopSequence = Number.isInteger(stop.stopSequence)
|
||||
? stop.stopSequence
|
||||
: null;
|
||||
if (stopId === null && stopSequence === null) continue;
|
||||
const record = { stopId, stopSequence, arrival, departure };
|
||||
if (relationship && relationship !== 'SCHEDULED')
|
||||
record.relationship = relationship;
|
||||
stops.push(record);
|
||||
}
|
||||
let delaySec = plausibleDelay(update.delay);
|
||||
if (delaySec === null) {
|
||||
for (const stop of stops) {
|
||||
const delay = stop.arrival?.delay ?? stop.departure?.delay ?? null;
|
||||
if (delay !== null) {
|
||||
delaySec = delay;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const relationship =
|
||||
TRIP_RELATIONSHIP[update.trip?.scheduleRelationship] || 'SCHEDULED';
|
||||
const timestamp =
|
||||
Number.isFinite(update.timestamp) && update.timestamp > 0
|
||||
? update.timestamp
|
||||
: null;
|
||||
return {
|
||||
tripId,
|
||||
routeId,
|
||||
startDate: nonEmptyString(update.trip?.startDate),
|
||||
startTime: nonEmptyString(update.trip?.startTime),
|
||||
directionId: Number.isInteger(update.trip?.directionId)
|
||||
? update.trip.directionId
|
||||
: null,
|
||||
vehicleId: nonEmptyString(update.vehicle?.id),
|
||||
relationship,
|
||||
timestamp,
|
||||
delaySec,
|
||||
stopsTruncated: update.stopsTruncated === true,
|
||||
stops,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a TripUpdates FeedMessage, at most GTFS_MAX_ENTITIES entities.
|
||||
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
|
||||
* @returns {{ version: string|null, timestamp: number|null,
|
||||
* incrementality: number, entityCount: number, truncated: boolean,
|
||||
* trips: object[] }}
|
||||
*/
|
||||
export function decodeTripUpdates(bytes) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const message = new PbfReader(view).readFields(readFeedMessage, {
|
||||
header: {},
|
||||
tripUpdates: [],
|
||||
entityCount: 0,
|
||||
truncated: false,
|
||||
});
|
||||
const trips = [];
|
||||
for (const entity of message.tripUpdates) {
|
||||
const trip = normalizeTripUpdateEntity(entity);
|
||||
if (trip) trips.push(trip);
|
||||
}
|
||||
const header = message.header || {};
|
||||
return {
|
||||
version: header.oversize ? null : nonEmptyString(header.version),
|
||||
timestamp:
|
||||
Number.isFinite(header.timestamp) && header.timestamp > 0
|
||||
? header.timestamp
|
||||
: null,
|
||||
incrementality: Number.isFinite(header.incrementality)
|
||||
? header.incrementality
|
||||
: GTFS_INCREMENTALITY_FULL_DATASET,
|
||||
entityCount: message.entityCount,
|
||||
truncated: message.truncated === true,
|
||||
trips,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { GTFS_MAX_ENTITIES } from './gtfsRealtime.js';
|
||||
import {
|
||||
RAIL_TRIP_UPDATE_MAX_STOPS,
|
||||
RAIL_TRIP_UPDATE_MAX_TRIPS,
|
||||
decodeTripUpdates,
|
||||
normalizeTripUpdateEntity,
|
||||
} from './railTripUpdates.js';
|
||||
|
||||
const NOW_S = 1_794_000_000;
|
||||
|
||||
function event(writer, tag, { delay, time } = {}) {
|
||||
const message = new PbfWriter();
|
||||
if (delay !== undefined) message.writeVarintField(1, delay);
|
||||
if (time !== undefined) message.writeVarintField(2, time);
|
||||
writer.writeBytesField(tag, message.finish());
|
||||
}
|
||||
|
||||
/**
|
||||
* A FeedMessage with trip updates. Each trip:
|
||||
* `{id, tripId, routeId, startDate, delay, relationship, vehicleId, stops}`,
|
||||
* each stop `{stopId, sequence, arrival, departure, relationship}`.
|
||||
*/
|
||||
function encodeTripUpdates({ incrementality = null, trips = [], extra = [] }) {
|
||||
const writer = new PbfWriter();
|
||||
const header = new PbfWriter();
|
||||
header.writeStringField(1, '2.0');
|
||||
if (incrementality !== null) header.writeVarintField(2, incrementality);
|
||||
header.writeVarintField(3, NOW_S);
|
||||
writer.writeBytesField(1, header.finish());
|
||||
for (const trip of trips) {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, trip.id ?? trip.tripId ?? 'e');
|
||||
if (trip.deleted) entity.writeBooleanField(2, true);
|
||||
const update = new PbfWriter();
|
||||
const descriptor = new PbfWriter();
|
||||
if (trip.tripId) descriptor.writeStringField(1, trip.tripId);
|
||||
if (trip.startDate) descriptor.writeStringField(3, trip.startDate);
|
||||
if (trip.relationship !== undefined)
|
||||
descriptor.writeVarintField(4, trip.relationship);
|
||||
if (trip.routeId) descriptor.writeStringField(5, trip.routeId);
|
||||
update.writeBytesField(1, descriptor.finish());
|
||||
for (const stop of trip.stops || []) {
|
||||
const stu = new PbfWriter();
|
||||
if (stop.sequence !== undefined) stu.writeVarintField(1, stop.sequence);
|
||||
if (stop.arrival) event(stu, 2, stop.arrival);
|
||||
if (stop.departure) event(stu, 3, stop.departure);
|
||||
if (stop.stopId) stu.writeStringField(4, stop.stopId);
|
||||
if (stop.relationship !== undefined)
|
||||
stu.writeVarintField(5, stop.relationship);
|
||||
update.writeBytesField(2, stu.finish());
|
||||
}
|
||||
if (trip.vehicleId) {
|
||||
const vehicle = new PbfWriter();
|
||||
vehicle.writeStringField(1, trip.vehicleId);
|
||||
update.writeBytesField(3, vehicle.finish());
|
||||
}
|
||||
update.writeVarintField(4, NOW_S - 5);
|
||||
if (trip.delay !== undefined) update.writeVarintField(5, trip.delay);
|
||||
entity.writeBytesField(3, update.finish());
|
||||
writer.writeBytesField(2, entity.finish());
|
||||
}
|
||||
for (const bytes of extra) writer.writeBytesField(2, bytes);
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
/** An entity that carries only a vehicle position (a combined feed). */
|
||||
function vehicleOnlyEntity() {
|
||||
const entity = new PbfWriter();
|
||||
entity.writeStringField(1, 'veh');
|
||||
const vehicle = new PbfWriter();
|
||||
const position = new PbfWriter();
|
||||
position.writeFloatField(1, 40.7);
|
||||
position.writeFloatField(2, -73.9);
|
||||
vehicle.writeBytesField(2, position.finish());
|
||||
entity.writeBytesField(4, vehicle.finish());
|
||||
return entity.finish();
|
||||
}
|
||||
|
||||
test('trip updates decode with stop times, signed delays and the header', () => {
|
||||
const decoded = decodeTripUpdates(
|
||||
encodeTripUpdates({
|
||||
trips: [
|
||||
{
|
||||
tripId: 'GO202_26_1959',
|
||||
routeId: '4',
|
||||
startDate: '20261106',
|
||||
vehicleId: '7171',
|
||||
stops: [
|
||||
{ stopId: '179', sequence: 1, relationship: 2 },
|
||||
{
|
||||
stopId: '33',
|
||||
sequence: 2,
|
||||
arrival: { time: NOW_S + 60, delay: 58 },
|
||||
departure: { time: NOW_S + 90, delay: -45 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
extra: [vehicleOnlyEntity()],
|
||||
}),
|
||||
);
|
||||
assert.equal(decoded.version, '2.0');
|
||||
assert.equal(decoded.timestamp, NOW_S);
|
||||
assert.equal(decoded.incrementality, 0);
|
||||
assert.equal(decoded.entityCount, 2, 'the vehicle entity is counted');
|
||||
assert.equal(decoded.trips.length, 1, 'but is not a trip');
|
||||
const [trip] = decoded.trips;
|
||||
assert.equal(trip.tripId, 'GO202_26_1959');
|
||||
assert.equal(trip.routeId, '4');
|
||||
assert.equal(trip.startDate, '20261106');
|
||||
assert.equal(trip.vehicleId, '7171');
|
||||
assert.equal(trip.relationship, 'SCHEDULED');
|
||||
assert.equal(trip.delaySec, 58, 'first stated stop delay');
|
||||
assert.deepEqual(trip.stops, [
|
||||
{
|
||||
stopId: '179',
|
||||
stopSequence: 1,
|
||||
arrival: null,
|
||||
departure: null,
|
||||
relationship: 'NO_DATA',
|
||||
},
|
||||
{
|
||||
stopId: '33',
|
||||
stopSequence: 2,
|
||||
arrival: { time: NOW_S + 60, delay: 58 },
|
||||
departure: { time: NOW_S + 90, delay: -45 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a trip-level delay wins; a times-only feed reports no delay, not zero', () => {
|
||||
const decoded = decodeTripUpdates(
|
||||
encodeTripUpdates({
|
||||
trips: [
|
||||
{
|
||||
tripId: 'a',
|
||||
routeId: 'CR-Lowell',
|
||||
delay: -120,
|
||||
stops: [{ stopId: 's', arrival: { delay: 30 } }],
|
||||
},
|
||||
{
|
||||
tripId: 'b',
|
||||
routeId: 'CR-Lowell',
|
||||
stops: [{ stopId: 's', arrival: { time: NOW_S + 300 } }],
|
||||
},
|
||||
{ tripId: 'c', routeId: 'CR-Lowell', relationship: 3 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const byId = new Map(decoded.trips.map((trip) => [trip.tripId, trip]));
|
||||
assert.equal(byId.get('a').delaySec, -120);
|
||||
assert.equal(byId.get('b').delaySec, null);
|
||||
assert.deepEqual(byId.get('b').stops[0].arrival, {
|
||||
time: NOW_S + 300,
|
||||
delay: null,
|
||||
});
|
||||
assert.equal(byId.get('c').relationship, 'CANCELED');
|
||||
assert.deepEqual(byId.get('c').stops, []);
|
||||
});
|
||||
|
||||
test('implausible values are dropped instead of carried', () => {
|
||||
const decoded = decodeTripUpdates(
|
||||
encodeTripUpdates({
|
||||
trips: [
|
||||
{
|
||||
tripId: 'x',
|
||||
routeId: 'r',
|
||||
delay: 999_999,
|
||||
stops: [
|
||||
{ stopId: 'a', arrival: { time: 12, delay: 10 } },
|
||||
{ stopId: 'b', arrival: { time: 9_999_999_999 } },
|
||||
{ sequence: 4 },
|
||||
{},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const [trip] = decoded.trips;
|
||||
assert.equal(trip.delaySec, 10, 'a delay of eleven days is not a delay');
|
||||
assert.deepEqual(trip.stops[0].arrival, { time: null, delay: 10 });
|
||||
assert.equal(trip.stops[1].arrival, null, 'a time past 2100 is no time');
|
||||
assert.equal(
|
||||
trip.stops.length,
|
||||
3,
|
||||
'a stop with no id or sequence is dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('stops past the per-trip cap are left unread and the trip says so', () => {
|
||||
const stops = Array.from(
|
||||
{ length: RAIL_TRIP_UPDATE_MAX_STOPS + 5 },
|
||||
(_, i) => ({ stopId: `s${i}`, sequence: i, arrival: { delay: 0 } }),
|
||||
);
|
||||
const [trip] = decodeTripUpdates(
|
||||
encodeTripUpdates({ trips: [{ tripId: 'long', routeId: 'r', stops }] }),
|
||||
).trips;
|
||||
assert.equal(trip.stops.length, RAIL_TRIP_UPDATE_MAX_STOPS);
|
||||
assert.equal(trip.stopsTruncated, true);
|
||||
});
|
||||
|
||||
test('over-long strings drop the record, deleted and empty entities are skipped', () => {
|
||||
const long = 'x'.repeat(300);
|
||||
const decoded = decodeTripUpdates(
|
||||
encodeTripUpdates({
|
||||
trips: [
|
||||
{ tripId: long, routeId: 'r' },
|
||||
{ tripId: 'gone', routeId: 'r', deleted: true },
|
||||
{ id: 'empty' },
|
||||
{
|
||||
tripId: 'ok',
|
||||
routeId: 'r',
|
||||
stops: [{ stopId: long, sequence: 1 }, { stopId: 'fine' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
decoded.trips.map((trip) => trip.tripId),
|
||||
['ok'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
decoded.trips[0].stops.map((stop) => stop.stopId),
|
||||
['fine'],
|
||||
);
|
||||
assert.equal(normalizeTripUpdateEntity(null), null);
|
||||
assert.equal(normalizeTripUpdateEntity({ id: 'v' }), null);
|
||||
});
|
||||
|
||||
test('every trip is decoded, up to the shared entity cap, whatever the caller asks', () => {
|
||||
// The decoder takes no per-caller filter (`keep`) or entity cap
|
||||
// (`maxEntities`): options like these change nothing.
|
||||
const trips = Array.from({ length: GTFS_MAX_ENTITIES + 1 }, (_, i) => ({
|
||||
tripId: `t${i}`,
|
||||
}));
|
||||
const decoded = decodeTripUpdates(encodeTripUpdates({ trips }), {
|
||||
keep: () => false,
|
||||
maxEntities: GTFS_MAX_ENTITIES * 2,
|
||||
});
|
||||
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
|
||||
assert.equal(decoded.truncated, true);
|
||||
assert.equal(
|
||||
decoded.trips.length,
|
||||
Math.min(GTFS_MAX_ENTITIES, RAIL_TRIP_UPDATE_MAX_TRIPS),
|
||||
);
|
||||
assert.equal(decoded.skipped, undefined);
|
||||
assert.equal(decoded.feedVersion, undefined);
|
||||
});
|
||||
|
||||
test('incrementality is reported so the proxy can refuse differential feeds', () => {
|
||||
assert.equal(
|
||||
decodeTripUpdates(encodeTripUpdates({ incrementality: 1 })).incrementality,
|
||||
1,
|
||||
);
|
||||
const empty = decodeTripUpdates(new Uint8Array(0));
|
||||
assert.equal(empty.incrementality, 0);
|
||||
assert.equal(empty.timestamp, null);
|
||||
assert.deepEqual(empty.trips, []);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
'rail',
|
||||
'directions',
|
||||
'ais',
|
||||
'military',
|
||||
|
||||
+186
-48
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"News feed unavailable": "موجز الأخبار غير متاح",
|
||||
"News feed unavailable": "مصدر بيانات الأخبار غير متاح",
|
||||
"Developing: {name}": "تطورات: {name}",
|
||||
"{count} events today": {
|
||||
"zero": "{count} حدث اليوم",
|
||||
@@ -39,13 +39,13 @@
|
||||
"Iran–Hormuz": "إيران–هرمز",
|
||||
"Yemen–Red Sea": "اليمن–البحر الأحمر",
|
||||
"Sudan": "السودان",
|
||||
"Sahel": "الساحل",
|
||||
"Sahel": "الساحل الأفريقي",
|
||||
"Elsewhere": "أماكن أخرى",
|
||||
"Strike": "ضربة",
|
||||
"Alert": "إنذار",
|
||||
"Air defence": "دفاع جوي",
|
||||
"Ground fighting": "قتال بري",
|
||||
"Casualties": "إصابات",
|
||||
"Casualties": "ضحايا",
|
||||
"Humanitarian": "إنساني",
|
||||
"Disaster": "كارثة",
|
||||
"Protest": "احتجاج",
|
||||
@@ -65,20 +65,20 @@
|
||||
"many": "{count} مصدرًا",
|
||||
"other": "{count} مصدر"
|
||||
},
|
||||
"News feed not configured on this server": "موجز الأخبار غير مهيأ على هذا الخادم",
|
||||
"News feed not configured on this server": "مصدر بيانات الأخبار غير مهيأ على هذا الخادم",
|
||||
"Malformed news hotspots response": "استجابة بؤر الأخبار غير صالحة",
|
||||
"Malformed news feed response": "استجابة موجز الأخبار غير صالحة",
|
||||
"Malformed news feed response": "استجابة مصدر بيانات الأخبار غير صالحة",
|
||||
"{place} (placed by model)": "{place} (حدده النموذج)",
|
||||
"English headline by the model": "عنوان إنجليزي من النموذج",
|
||||
"Not placed on the map": "غير موضوع على الخريطة",
|
||||
"Not placed on the map": "لم يُحدَّد موقعه على الخريطة",
|
||||
"Close": "إغلاق",
|
||||
"Any severity": "أي درجة خطورة",
|
||||
"{severity} and above": "{severity} فأعلى",
|
||||
"{severity} only": "{severity} فقط",
|
||||
"All categories": "كل الفئات",
|
||||
"Reading the news…": "جارٍ قراءة الأخبار…",
|
||||
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
|
||||
"Last {hours} h": "آخر {hours} ساعة",
|
||||
"News, last {hours} hours": "الأخبار، الساعات الأخيرة: {hours}",
|
||||
"Last {hours} h": "آخر {hours} س",
|
||||
"Time window": "الفترة الزمنية",
|
||||
"Conflicts": "النزاعات",
|
||||
"Local": "محلي",
|
||||
@@ -87,9 +87,9 @@
|
||||
"Hotspots": "بؤر ساخنة",
|
||||
"Severity": "الخطورة",
|
||||
"Category": "الفئة",
|
||||
"Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة",
|
||||
"Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} س",
|
||||
"Latest news": "آخر الأخبار",
|
||||
"No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة",
|
||||
"No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} س",
|
||||
"Load older news": "تحميل أخبار أقدم",
|
||||
"How to read the map": "كيف تقرأ الخريطة",
|
||||
"Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث",
|
||||
@@ -442,7 +442,7 @@
|
||||
"AVAILABLE MISSIONS": "المهام المتاحة",
|
||||
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
|
||||
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. أعد المحاولة لاحقًا.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
|
||||
"RADIO": "الراديو",
|
||||
"ENABLE": "تفعيل",
|
||||
@@ -536,12 +536,12 @@
|
||||
"PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع",
|
||||
"POSITION UNAVAILABLE": "الموضع غير متاح",
|
||||
"REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة",
|
||||
"CACHED · REFRESH FAILED": "مخزنة · فشل التحديث",
|
||||
"CACHED · REFRESH FAILED": "مخزّنة · فشل التحديث",
|
||||
"REGION UNAVAILABLE": "المنطقة غير متاحة",
|
||||
"NO NEWS IN THE LAST {hours} H": "لا أخبار خلال آخر {hours} س",
|
||||
"SOURCE": "المصدر",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{source} · CACHED {age}": "{source} · مخزنة {age}",
|
||||
"{source} · CACHED {age}": "{source} · مخزّنة {age}",
|
||||
"TIME UNKNOWN": "الوقت غير معروف",
|
||||
"{count}M AGO": "قبل {count} د",
|
||||
"{count}H AGO": "قبل {count} س",
|
||||
@@ -3385,7 +3385,7 @@
|
||||
"Reset map bearing to north": "إعادة اتجاه الخريطة إلى الشمال",
|
||||
"Malformed cyclone snapshot": "لقطة الأعاصير مشوّهة",
|
||||
"Cyclone request timed out": "انتهت مهلة طلب الأعاصير",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناظر التي زرتها خلال {minutes} د",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناطق التي استعرضتها خلال {minutes} د",
|
||||
"RADAR · {units}": "رادار · {units}",
|
||||
"Color: {units}": "اللون: {units}",
|
||||
"Left panels": "اللوحات اليمنى",
|
||||
@@ -3411,7 +3411,7 @@
|
||||
"Add the current view, style and layers as the next shot": "إضافة العرض الحالي والنمط والطبقات كلقطة تالية",
|
||||
"+ CAPTURE SHOT": "+ التقاط لقطة",
|
||||
"Replace the selected shot with the current view": "استبدال اللقطة المحددة بالعرض الحالي",
|
||||
"Click a shot to select it, double-click to rename.": "انقر لقطة لتحديدها، وانقر نقرًا مزدوجًا لإعادة تسميتها.",
|
||||
"Click a shot to select it, double-click to rename.": "انقر على لقطة لتحديدها، وانقر عليها نقرًا مزدوجًا لإعادة تسميتها.",
|
||||
"Play from this scene through the rest of the project": "تشغيل من هذا المشهد حتى نهاية المشروع",
|
||||
"Stop playback (Esc)": "إيقاف التشغيل (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "تشغيل اللقطة التي تلي المحددة، مع الانتقال إلى المشهد التالي عند الحاجة",
|
||||
@@ -3618,7 +3618,7 @@
|
||||
"Within {km} km of the view centre": "ضمن {km} كم من مركز العرض",
|
||||
"{region}, {country}": "{region}، {country}",
|
||||
"via {source}": "عبر {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "المحلي في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "قسم «محلي» في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض",
|
||||
"{provider} refused its key": "رفض {provider} مفتاحه",
|
||||
"Check {envVars} in Provider Settings": "تحقق من {envVars} في «إعدادات المزوّدين»",
|
||||
"{provider} key required": "مفتاح {provider} مطلوب",
|
||||
@@ -3633,7 +3633,7 @@
|
||||
"Date unknown": "التاريخ غير معروف",
|
||||
"Position: centre of the protected area": "الموقع: مركز المنطقة المحمية",
|
||||
"Position approximate (within about {km} km)": "الموقع تقريبي (في حدود {km} كم تقريبًا)",
|
||||
"Position surveyed (within about {m} m)": "الموقع مُسَح (في حدود {m} م تقريبًا)",
|
||||
"Position surveyed (within about {m} m)": "الموقع محدَّد بالمسح (في حدود {m} م تقريبًا)",
|
||||
"Vessel: {value}": "السفينة: {value}",
|
||||
"What happened: {value}": "ما حدث: {value}",
|
||||
"Depth: {m} m ({quality})": "العمق: {m} م ({quality})",
|
||||
@@ -3644,24 +3644,24 @@
|
||||
"Source: {value}": "المصدر: {value}",
|
||||
"Click the card to open EMODnet ↗": "انقر على البطاقة لفتح EMODnet ↗",
|
||||
"Click the card to open the source ↗": "انقر على البطاقة لفتح المصدر ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata غير متاح؛ يُعرض EMODnet فقط",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ يُعرض Wikidata فقط",
|
||||
"Shipwrecks unavailable": "بيانات حطام السفن غير متاحة",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · Wikidata (العالم)",
|
||||
"{count} wrecks": {
|
||||
"zero": "لا حطام ({count})",
|
||||
"one": "حطام واحد ({count})",
|
||||
"two": "حطامان ({count})",
|
||||
"few": "{count} حطامات",
|
||||
"many": "{count} حطامًا",
|
||||
"other": "{count} حطام"
|
||||
"zero": "{count} سفينة غارقة",
|
||||
"one": "سفينة غارقة واحدة ({count})",
|
||||
"two": "سفينتان غارقتان ({count})",
|
||||
"few": "{count} سفن غارقة",
|
||||
"many": "{count} سفينة غارقة",
|
||||
"other": "{count} سفينة غارقة"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "غرق سفينة",
|
||||
"Shipwreck": "حطام سفينة",
|
||||
"Fatalities: {count}": "الوفيات: {count}",
|
||||
"Survivors: {count}": "الناجون: {count}",
|
||||
"Click the card to open the article ↗": "انقر على البطاقة لفتح المقالة ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من Wikidata. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.",
|
||||
"All years": "كل السنوات",
|
||||
"Period": "الفترة",
|
||||
"From": "من",
|
||||
@@ -3689,9 +3689,9 @@
|
||||
"Other or unknown": "أخرى أو غير معروفة",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة",
|
||||
"Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح",
|
||||
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط",
|
||||
"No recent sightings in view": "لا توجد مشاهدات حديثة في العرض",
|
||||
"Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia",
|
||||
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعيًا (بدرجة بحثية) فقط",
|
||||
"No recent sightings in view": "لا توجد مشاهدات حديثة في نطاق العرض",
|
||||
"Acartia feed unavailable": "مصدر Acartia غير متاح",
|
||||
"Show all taxa": "إظهار جميع المجموعات التصنيفية",
|
||||
"BIRDS": "الطيور",
|
||||
"Show only birds": "إظهار الطيور فقط",
|
||||
@@ -3706,31 +3706,31 @@
|
||||
"All rights reserved": "جميع الحقوق محفوظة",
|
||||
"Cetacean": "حوتي",
|
||||
"Wildlife Sightings": "مشاهدات الحياة البرية",
|
||||
"Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة",
|
||||
"Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة",
|
||||
"Exit cockpit to fly to a sighting": "اخرج من القمرة للانتقال إلى مشاهدة",
|
||||
"Exit cockpit to fly to a sighting area": "اخرج من القمرة للانتقال إلى منطقة مشاهدة",
|
||||
"Wildlife": "الحياة البرية",
|
||||
"Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية",
|
||||
"Loading photo...": "جارٍ تحميل الصورة…",
|
||||
"Photo unavailable": "الصورة غير متاحة",
|
||||
"Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°",
|
||||
"Location obscured to a 0.2° cell": "الموقع مموّه ضمن خلية بحجم 0.2°",
|
||||
"Photo of {name}": "صورة {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} مشاهدة ضمن النطاق",
|
||||
"{count} sightings in view": "المشاهدات في نطاق العرض: {count}",
|
||||
"Close sighting": "إغلاق المشاهدة",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملًا.",
|
||||
"Open {name} on iNaturalist": "فتح {name} على iNaturalist",
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
|
||||
"{days}d": "{days}ي",
|
||||
"{days}d": "{days} ي",
|
||||
"All markers": "كل العلامات",
|
||||
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} ي؛ تبقى العلامات غير المؤرخة ظاهرة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} ي · غير المؤرخة تبقى ظاهرة",
|
||||
"Close camera preview": "إغلاق معاينة الكاميرا",
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
@@ -3755,11 +3755,11 @@
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "كوكبة Starlink (آلاف النقاط الإضافية)",
|
||||
"PROBES": "المسابير",
|
||||
"Space science, technology demonstration and other payloads": "علوم الفضاء والعروض التقنية وحمولات أخرى",
|
||||
"Space science, technology demonstration and other payloads": "علوم الفضاء واختبار التقنيات وحمولات أخرى",
|
||||
"DEBRIS": "الحطام",
|
||||
"Rocket bodies, fragments and dead satellites": "مراحل الصواريخ والشظايا والأقمار المعطلة",
|
||||
"Classes may lag: SATCAT is a cached copy": "قد تتأخر الفئات: SATCAT نسخة مخزنة",
|
||||
"Classes may lag: SATCAT unavailable": "قد تتأخر الفئات: SATCAT غير متاح",
|
||||
"Classes may lag: SATCAT is a cached copy": "قد لا تكون الفئات محدّثة: SATCAT نسخة مخزّنة",
|
||||
"Classes may lag: SATCAT unavailable": "قد لا تكون الفئات محدّثة: SATCAT غير متاح",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
@@ -3780,7 +3780,7 @@
|
||||
"Zoom out (−)": "تصغير (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Back to live": "العودة إلى الوضع المباشر",
|
||||
"Live": "مباشر",
|
||||
"1 h/s": "ساعة/ث",
|
||||
"1 d/s": "يوم/ث",
|
||||
@@ -3788,7 +3788,24 @@
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Rail": "السكك الحديدية",
|
||||
"Urban rail": "القطارات الحضرية",
|
||||
"Local rail": "قطارات الضواحي",
|
||||
"Regional rail": "القطارات الإقليمية",
|
||||
"National rail": "القطارات الوطنية",
|
||||
"International rail": "القطارات الدولية",
|
||||
"Hide {category}": "إخفاء {category}",
|
||||
"Show {category}": "إظهار {category}",
|
||||
"Live GPS": "GPS مباشر",
|
||||
"Estimated from station reports": "مقدَّر من بلاغات المحطات",
|
||||
"To {headsign}": "إلى {headsign}",
|
||||
"Next {stop} {time} {relative}": "المحطة التالية: {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "المحطة السابقة: {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(تأخير {minutes} د)",
|
||||
"Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار",
|
||||
"Photo © {photographer}": "صورة © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "صورة: {photographer} · {licence}",
|
||||
"Photo · {licence}": "صورة · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
@@ -3796,16 +3813,16 @@
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعه عند {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°",
|
||||
"Street Level is unavailable": "مستوى الشارع غير متاح",
|
||||
"Street Level is unavailable": "«مستوى الشارع» غير متاح",
|
||||
"No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع",
|
||||
"Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع",
|
||||
"Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور",
|
||||
"Street Level could not be enabled": "تعذّر تفعيل «مستوى الشارع»",
|
||||
"Street Level changed while it looked for imagery": "تغيّرت حالة «مستوى الشارع» أثناء البحث عن الصور",
|
||||
"Aim the view at a street": "وجّه العرض نحو شارع",
|
||||
"{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا",
|
||||
"{source} needs its key": "يحتاج {source} إلى مفتاحه",
|
||||
@@ -3813,9 +3830,130 @@
|
||||
"{source} could not be switched on": "تعذّر تفعيل {source}",
|
||||
"{source} is not available on this map": "{source} غير متاح على هذه الخريطة",
|
||||
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
|
||||
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
|
||||
"{place} has no position to look for imagery at": "{place}: لا موضع للبحث عن الصور فيه",
|
||||
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})",
|
||||
"tiles unavailable": "البلاطات غير متاحة",
|
||||
"zoom in to load this layer": "قرّب لتحميل هذه الطبقة",
|
||||
"Zoom to layer": "تكبير إلى الطبقة",
|
||||
"Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الانتقال إلى حيث توجد بيانات هذه الطبقة",
|
||||
"Remove from Data Layers": "إزالة من طبقات البيانات",
|
||||
"Remove from Data Layers · add it again from the library": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة",
|
||||
"Coverage outline": "حدود التغطية",
|
||||
"DRAPED": "على التضاريس",
|
||||
"Below 250 m": "أقل من 250 م",
|
||||
"250–500 m": "250–500 م",
|
||||
"500–1,000 m": "500–1,000 م",
|
||||
"1,000–2,000 m": "1,000–2,000 م",
|
||||
"2,000–3,000 m": "2,000–3,000 م",
|
||||
"3,000 m and above": "3,000 م فأكثر",
|
||||
"Exit cockpit to fly to a layer": "اخرج من القمرة للانتقال إلى طبقة",
|
||||
"Library": "المكتبة",
|
||||
"Environment": "البيئة",
|
||||
"Imagery": "الصور",
|
||||
"Demographics": "التركيبة السكانية",
|
||||
"DATASET": "مجموعة بيانات",
|
||||
"TILES": "بلاطات",
|
||||
"PACK": "حزمة",
|
||||
"INCUBATING": "قيد الاحتضان",
|
||||
"COMMUNITY": "مجتمعي",
|
||||
"VERIFIED": "موثّق",
|
||||
"ARCHIVED": "مؤرشف",
|
||||
"Datasets": "مجموعات البيانات",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"zero": "{count} مجموعة بيانات",
|
||||
"one": "مجموعة بيانات واحدة ({count})",
|
||||
"two": "مجموعتا بيانات ({count})",
|
||||
"few": "{count} مجموعات بيانات",
|
||||
"many": "{count} مجموعة بيانات",
|
||||
"other": "{count} مجموعة بيانات"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"zero": "{count} طبقة بلاطات",
|
||||
"one": "طبقة بلاطات واحدة ({count})",
|
||||
"two": "طبقتا بلاطات ({count})",
|
||||
"few": "{count} طبقات بلاطات",
|
||||
"many": "{count} طبقة بلاطات",
|
||||
"other": "{count} طبقة بلاطات"
|
||||
},
|
||||
"{count} packs": {
|
||||
"zero": "{count} حزمة",
|
||||
"one": "حزمة واحدة ({count})",
|
||||
"two": "حزمتان ({count})",
|
||||
"few": "{count} حزم",
|
||||
"many": "{count} حزمة",
|
||||
"other": "{count} حزمة"
|
||||
},
|
||||
"Themes": "المواضيع",
|
||||
"Added": "المُضافة",
|
||||
"{count} on the map": {
|
||||
"zero": "{count} على الخريطة",
|
||||
"one": "{count} على الخريطة",
|
||||
"two": "{count} على الخريطة",
|
||||
"few": "{count} على الخريطة",
|
||||
"many": "{count} على الخريطة",
|
||||
"other": "{count} على الخريطة"
|
||||
},
|
||||
"none yet": "لا شيء بعد",
|
||||
"Map features": "معالم الخريطة",
|
||||
"from {count} datasets": {
|
||||
"zero": "من {count} مجموعة بيانات",
|
||||
"one": "من مجموعة بيانات واحدة ({count})",
|
||||
"two": "من مجموعتَي بيانات ({count})",
|
||||
"few": "من {count} مجموعات بيانات",
|
||||
"many": "من {count} مجموعة بيانات",
|
||||
"other": "من {count} مجموعة بيانات"
|
||||
},
|
||||
"switch on a dataset to load features": "شغّل مجموعة بيانات لتحميل المعالم",
|
||||
"Switched on by this instance · part of the CCTV layer": "مُفعّل من هذا المثيل · جزء من طبقة CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "تفعيل من المشغّل: اضبط {env}=1 على الخادم",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "تفعيل من المشغّل: أضِف {id} إلى GEV_LIBRARY_ENABLE على الخادم",
|
||||
"Turned off by this instance's operator": "أوقفه مشغّل هذا المثيل",
|
||||
"Needs a provider key on the server": "يتطلب مفتاح مزوّد على الخادم",
|
||||
"Needs this app's server · not available in a static build": "يتطلب خادم هذا التطبيق · غير متاح في إصدار ثابت",
|
||||
"Checking what this instance serves…": "جارٍ التحقق مما يقدّمه هذا المثيل…",
|
||||
"Regional": "إقليمي",
|
||||
"Orthophoto": "صورة متعامدة",
|
||||
"Satellite mosaic": "فسيفساء أقمار صناعية",
|
||||
"Oblique": "مائلة",
|
||||
"Historic imagery": "صور تاريخية",
|
||||
"True orthophoto": "صورة متعامدة حقيقية",
|
||||
"Type": "النوع",
|
||||
"Resolution": "الدقة",
|
||||
"Flown": "تاريخ التصوير",
|
||||
"Updates": "التحديث",
|
||||
"Ortho quality": "جودة التصحيح التعامدي",
|
||||
"DATA LIBRARY": "مكتبة البيانات",
|
||||
"Close data library": "إغلاق مكتبة البيانات",
|
||||
"Data Library at a glance": "مكتبة البيانات في لمحة",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "مصادر مباشرة تتجاوز الطبقات الأساسية. يُجلب كل منها من مزوّده ما دام مفعّلًا؛ ولا يُخزَّن شيء في هذا التطبيق. أضِف واحدًا وسيظهر في طبقات البيانات ضمن المكتبة.",
|
||||
"Name, provider or country": "الاسم أو المزوّد أو البلد",
|
||||
"Theme": "الموضوع",
|
||||
"All themes": "كل المواضيع",
|
||||
"Only what this instance serves": "فقط ما يقدّمه هذا المثيل",
|
||||
"OPT-IN": "بتفعيل المشغّل",
|
||||
"KEY": "مفتاح",
|
||||
"KEYLESS": "بلا مفتاح",
|
||||
"ZOOM": "تكبير",
|
||||
"Zoom to {name}": "تكبير إلى {name}",
|
||||
"WORKING…": "جارٍ العمل…",
|
||||
"ADD": "إضافة",
|
||||
"Remove {name} from Data Layers": "إزالة {name} من طبقات البيانات",
|
||||
"Add {name} to Data Layers": "إضافة {name} إلى طبقات البيانات",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"zero": "تأكيد · إزالة الكل ({count})",
|
||||
"one": "تأكيد · إزالة {count}",
|
||||
"two": "تأكيد · إزالة الكل ({count})",
|
||||
"few": "تأكيد · إزالة الكل ({count})",
|
||||
"many": "تأكيد · إزالة الكل ({count})",
|
||||
"other": "تأكيد · إزالة الكل ({count})"
|
||||
},
|
||||
"REMOVE ALL": "إزالة الكل",
|
||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
|
||||
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
|
||||
|
||||
+166
-52
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"News feed unavailable": "Nachrichtenfeed nicht verfügbar",
|
||||
"Developing: {name}": "In Entwicklung: {name}",
|
||||
"Developing: {name}": "Aktuelle Entwicklung: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} Ereignis heute",
|
||||
"other": "{count} Ereignisse heute"
|
||||
@@ -13,11 +13,11 @@
|
||||
"Video": "Video",
|
||||
"Photos": "Fotos",
|
||||
"Claimed by {who}": "Behauptet von {who}",
|
||||
"Claimed": "Behauptung",
|
||||
"Claimed": "Behauptet",
|
||||
"Not yet confirmed by others": "Noch nicht von anderen bestätigt",
|
||||
"Reported by {side} sources": "Gemeldet von Quellen aus {side}",
|
||||
"Reported by {sides}": "Gemeldet von {sides}",
|
||||
"{side} sources": "Quellen aus {side}",
|
||||
"Reported by {side} sources": "Gemeldet von Quellen: {side}",
|
||||
"Reported by {sides}": "Gemeldet von Quellen: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Offiziell",
|
||||
"Reported by both sides": "Von beiden Seiten gemeldet",
|
||||
"Independently reported": "Unabhängig gemeldet",
|
||||
@@ -47,8 +47,8 @@
|
||||
"Significant": "Erheblich",
|
||||
"Major": "Schwer",
|
||||
"age|now": "jetzt",
|
||||
"{minutes} min": "{minutes} Min.",
|
||||
"{hours} h": "{hours} Std.",
|
||||
"{minutes} min": "{minutes} min",
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} Quelle",
|
||||
"other": "{count} Quellen"
|
||||
@@ -65,8 +65,8 @@
|
||||
"{severity} only": "Nur {severity}",
|
||||
"All categories": "Alle Kategorien",
|
||||
"Reading the news…": "Nachrichten werden gelesen…",
|
||||
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
|
||||
"Last {hours} h": "Letzte {hours} Std.",
|
||||
"News, last {hours} hours": "Nachrichten, letzte {hours} h",
|
||||
"Last {hours} h": "Letzte {hours} h",
|
||||
"Time window": "Zeitraum",
|
||||
"Conflicts": "Konflikte",
|
||||
"Local": "Lokal",
|
||||
@@ -82,8 +82,8 @@
|
||||
"How to read the map": "So liest man die Karte",
|
||||
"Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Durchgehender Rand: von beiden Seiten oder unabhängig gemeldet. Gestrichelter Rand: eine Behauptung. Dünner Rand: eine Quelle oder die eigene Presse einer Seite.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Bildkarte: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.",
|
||||
"Side labels describe the source, not whether a report is true.": "Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Infokarte mit Bild: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.",
|
||||
"Side labels describe the source, not whether a report is true.": "Angaben zur Seite beschreiben die Quelle, nicht ob eine Meldung stimmt.",
|
||||
"Exit cockpit to fly to a news report": "Cockpit verlassen, um zu einer Meldung zu fliegen",
|
||||
"News items in the feed": "Meldungen im Feed",
|
||||
"Flying to Austin, TX...": "Flug nach Austin, TX...",
|
||||
@@ -252,7 +252,7 @@
|
||||
"loading...": "lädt...",
|
||||
"KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH",
|
||||
"LIVE · updated {age}": "LIVE · aktualisiert {age}",
|
||||
"24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC",
|
||||
"24 h to {day} {time} UTC": "24 h bis {day} {time} UTC",
|
||||
"loading mapped installation context": "Kontext der kartierten Anlage wird geladen",
|
||||
"MAPPED INSTALLATION": "KARTIERTE ANLAGE",
|
||||
"LAUNCH SITE · {site}": "STARTPLATZ · {site}",
|
||||
@@ -282,15 +282,15 @@
|
||||
"{value} KM/S": "{value} KM/S",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}",
|
||||
"SITES STANDBY": "ORTE STANDBY",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN",
|
||||
"SITES STANDBY": "STANDORTE STANDBY",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR STANDORT-PINGS AKTIVIEREN",
|
||||
"Release the camera from {name}": "Kamera von {name} lösen",
|
||||
"RETURN · {name}": "ZURÜCK · {name}",
|
||||
"PREVIOUS SATELLITE": "VORHERIGER SATELLIT",
|
||||
"Return to {name}": "Zurück zu {name}",
|
||||
"SATELLITE": "SATELLIT",
|
||||
"Ride {name}": "Mit {name} fliegen",
|
||||
"Unnamed site": "Unbenannter Ort",
|
||||
"Unnamed site": "Unbenannter Standort",
|
||||
"OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°",
|
||||
"FOCUS · {name}": "FOKUS · {name}",
|
||||
"Keep the camera on {name}": "Kamera auf {name} halten",
|
||||
@@ -493,8 +493,8 @@
|
||||
"other": "{count} Kameras geladen · CCTV aktivieren"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} weitere Kamera außerhalb dieser Ansicht · verschieben oder herauszoomen",
|
||||
"other": "{count} weitere Kameras außerhalb dieser Ansicht · verschieben oder herauszoomen"
|
||||
"one": "{count} weitere Kamera außerhalb dieser Ansicht · Karte verschieben oder herauszoomen",
|
||||
"other": "{count} weitere Kameras außerhalb dieser Ansicht · Karte verschieben oder herauszoomen"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "CCTV aktivieren, um Kreuzungskameras zu laden",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.",
|
||||
@@ -1619,7 +1619,7 @@
|
||||
"Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %",
|
||||
"Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %",
|
||||
"GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten der letzten {minutes} min",
|
||||
"Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC",
|
||||
"Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.",
|
||||
@@ -3107,7 +3107,7 @@
|
||||
"Restricted": "Flugbeschränkungsgebiet",
|
||||
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete",
|
||||
"scene|PLAY": "START",
|
||||
"AUTHORING": "ERSTELLUNG",
|
||||
"AUTHORING": "SZENEN",
|
||||
"Create and edit scenes": "Szenen erstellen und bearbeiten",
|
||||
"SCENE": "SZENE",
|
||||
"Scene": "Szene",
|
||||
@@ -3129,8 +3129,8 @@
|
||||
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
|
||||
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
|
||||
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
|
||||
"Left panels": "Linke Panels",
|
||||
"Right panels": "Rechte Panels",
|
||||
"Left panels": "Linke Bereiche",
|
||||
"Right panels": "Rechte Bereiche",
|
||||
"CONFIRMED": "BESTÄTIGT",
|
||||
"PAD / RTLS": "STARTPLATZ / RTLS",
|
||||
"fire confidence filter|All": "Alle",
|
||||
@@ -3292,20 +3292,20 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Letzte 24 h Nachrichten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Seitenangaben, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Nachrichten der letzten 24 h. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Angaben zur Seite, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.",
|
||||
"Economy": "Wirtschaft",
|
||||
"News from {area}": "Nachrichten: {area}",
|
||||
"Within {km} km of the view centre": "Im Umkreis von {km} km um die Bildmitte",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "über {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Lokal in der Nachrichtenebene: die Nachrichten des Tages aus den Medien des Landes im Blick",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "„Lokal“ in der Ebene „Nachrichten“: die Nachrichten des Tages aus den Medien des Landes im Bild",
|
||||
"{provider} refused its key": "{provider} hat den Schlüssel abgelehnt",
|
||||
"Check {envVars} in Provider Settings": "{envVars} in den Anbieter-Einstellungen prüfen",
|
||||
"{provider} key required": "{provider}-Schlüssel erforderlich",
|
||||
"{provider} key required": "Schlüssel für {provider} erforderlich",
|
||||
"{provider} limit reached for today": "{provider}: Tageslimit erreicht",
|
||||
"News resumes tomorrow, or with a paid plan": "Nachrichten gibt es morgen wieder, oder mit einem kostenpflichtigen Tarif",
|
||||
"News key required": "Nachrichtenschlüssel erforderlich",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Ebene „Nachrichten“, neben den Artikeln von GDELT",
|
||||
"Worldwide": "Weltweit",
|
||||
"Shipwrecks": "Schiffswracks",
|
||||
"Before 1900": "Vor 1900",
|
||||
@@ -3322,8 +3322,8 @@
|
||||
"Cargo or finds: {value}": "Ladung oder Funde: {value}",
|
||||
"Protected wreck": "Geschütztes Wrack",
|
||||
"Source: {value}": "Quelle: {value}",
|
||||
"Click the card to open EMODnet ↗": "Karte anklicken, um EMODnet zu öffnen ↗",
|
||||
"Click the card to open the source ↗": "Karte anklicken, um die Quelle zu öffnen ↗",
|
||||
"Click the card to open EMODnet ↗": "Eintrag anklicken, um EMODnet zu öffnen ↗",
|
||||
"Click the card to open the source ↗": "Eintrag anklicken, um die Quelle zu öffnen ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfügbar; nur EMODnet wird angezeigt",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfügbar; nur Wikidata wird angezeigt",
|
||||
"Shipwrecks unavailable": "Schiffswracks nicht verfügbar",
|
||||
@@ -3333,11 +3333,11 @@
|
||||
"other": "{count} Wracks"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Schiffbruch",
|
||||
"Shipwreck": "Schiffswrack",
|
||||
"Fatalities: {count}": "Todesopfer: {count}",
|
||||
"Survivors: {count}": "Überlebende: {count}",
|
||||
"Click the card to open the article ↗": "Karte anklicken, um den Artikel zu öffnen ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Klicke auf eine Markierung für ihre Karte.",
|
||||
"Click the card to open the article ↗": "Eintrag anklicken, um den Artikel zu öffnen ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Für die Infokarte eine Markierung anklicken.",
|
||||
"All years": "Alle Jahre",
|
||||
"Period": "Zeitraum",
|
||||
"From": "Von",
|
||||
@@ -3363,11 +3363,11 @@
|
||||
"Fungi": "Pilze",
|
||||
"Other animals": "Andere Tiere",
|
||||
"Other or unknown": "Andere oder unbekannt",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken",
|
||||
"{label} {detail} — click to retry": "{label}: {detail} – zum erneuten Versuch klicken",
|
||||
"Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar",
|
||||
"Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen",
|
||||
"No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt",
|
||||
"Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar",
|
||||
"No recent sightings in view": "Keine aktuellen Beobachtungen im Bild",
|
||||
"Acartia feed unavailable": "Acartia-Feed nicht verfügbar",
|
||||
"Show all taxa": "Alle Artengruppen anzeigen",
|
||||
"BIRDS": "VÖGEL",
|
||||
"Show only birds": "Nur Vögel anzeigen",
|
||||
@@ -3385,48 +3385,48 @@
|
||||
"Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen",
|
||||
"Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen",
|
||||
"Wildlife": "Tierwelt",
|
||||
"Zoom in for local sightings": "Für lokale Beobachtungen vergrößern",
|
||||
"Zoom in for local sightings": "Hineinzoomen für lokale Beobachtungen",
|
||||
"Loading photo...": "Foto wird geladen…",
|
||||
"Photo unavailable": "Foto nicht verfügbar",
|
||||
"Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert",
|
||||
"Photo of {name}": "Foto von {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} Sichtungen im Ausschnitt",
|
||||
"Close sighting": "Sichtung schließen",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
|
||||
"{count} sightings in view": "Beobachtungen im Bild: {count}",
|
||||
"Close sighting": "Beobachtung schließen",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Symbol der Markierung zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
|
||||
"Open {name} on iNaturalist": "{name} auf iNaturalist öffnen",
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
|
||||
"{days}d": "{days}T",
|
||||
"{days}d": "{days} T",
|
||||
"All markers": "Alle Markierungen",
|
||||
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} T; undatierte Markierungen bleiben erhalten",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} T · undatierte bleiben",
|
||||
"Close camera preview": "Kameravorschau schließen",
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt; die IP-Adresse ist für diesen Anbieter sichtbar.",
|
||||
"Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden",
|
||||
"Captured {month}": "Aufgenommen im {month}",
|
||||
"this spot": "diese Stelle",
|
||||
"the view centre": "die Bildmitte",
|
||||
"{source} at {place}": "{source} bei {place}",
|
||||
"Street level at {place}": "Straßenebene bei {place}",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Straßenebene: {place}",
|
||||
"Switching map": "Karte wird gewechselt",
|
||||
"Opening imagery": "Bilder werden geöffnet",
|
||||
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Foto auf Straßenebene aus diesem Link ohne gültigen Schlüssel nicht öffnen",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
|
||||
"STATIONS": "STATIONEN",
|
||||
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
|
||||
"IMAGING": "BILDGEBUNG",
|
||||
"Earth observation: optical, radar, weather and science": "Erdbeobachtung: optisch, Radar, Wetter und Wissenschaft",
|
||||
"TELECOM": "TELEKOM",
|
||||
"TELECOM": "KOMM.",
|
||||
"Communications and navigation satellites, Starlink excepted": "Kommunikations- und Navigationssatelliten, ohne Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Die Starlink-Konstellation (Tausende zusätzliche Punkte)",
|
||||
@@ -3443,11 +3443,11 @@
|
||||
"SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen",
|
||||
"<1 H OLD": "<1 STD ALT",
|
||||
"{value} H OLD": "{value} STD ALT",
|
||||
"<1 H OLD": "<1 H ALT",
|
||||
"{value} H OLD": "{value} H ALT",
|
||||
"{value} D OLD": "{value} T ALT",
|
||||
"FWD": "VORAUS",
|
||||
"AFT": "ACHTERN",
|
||||
"AFT": "ACHTERAUS",
|
||||
"RIGHT": "RECHTS",
|
||||
"LEFT": "LINKS",
|
||||
"TRACK UP": "KURS OBEN",
|
||||
@@ -3456,15 +3456,32 @@
|
||||
"Zoom out (−)": "Herauszoomen (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG",
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
"Back to live": "Zurück zu Live",
|
||||
"Live": "Live",
|
||||
"1 h/s": "1 Std./s",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 Tag/s",
|
||||
"1 wk/s": "1 Wo./s",
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Bahn",
|
||||
"Urban rail": "Stadtverkehr",
|
||||
"Local rail": "Vorortverkehr",
|
||||
"Regional rail": "Regionalverkehr",
|
||||
"National rail": "Fernverkehr",
|
||||
"International rail": "International",
|
||||
"Hide {category}": "{category} ausblenden",
|
||||
"Show {category}": "{category} einblenden",
|
||||
"Live GPS": "Live-GPS",
|
||||
"Estimated from station reports": "Geschätzt aus Bahnhofsmeldungen",
|
||||
"To {headsign}": "Nach {headsign}",
|
||||
"Next {stop} {time} {relative}": "Nächster Halt {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Vorheriger Halt {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min Verspätung)",
|
||||
"Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Foto · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
@@ -3485,13 +3502,110 @@
|
||||
"Aim the view at a street": "Die Ansicht auf eine Straße richten",
|
||||
"{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene",
|
||||
"{source} needs its key": "{source} braucht seinen Schlüssel",
|
||||
"{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist",
|
||||
"{source} needs the {map} map, which is not available": "{source} braucht die Karte „{map}“, die nicht verfügbar ist",
|
||||
"{source} could not be switched on": "{source} konnte nicht eingeschaltet werden",
|
||||
"{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar",
|
||||
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
|
||||
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
|
||||
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})",
|
||||
"tiles unavailable": "Kacheln nicht verfügbar",
|
||||
"zoom in to load this layer": "hineinzoomen, um diese Ebene zu laden",
|
||||
"Zoom to layer": "Zur Ebene zoomen",
|
||||
"Zoom to layer · fly to where this layer has data": "Zur Ebene zoomen · dorthin fliegen, wo diese Ebene Daten hat",
|
||||
"Remove from Data Layers": "Aus Datenebenen entfernen",
|
||||
"Remove from Data Layers · add it again from the library": "Aus Datenebenen entfernen · über die Bibliothek erneut hinzufügen",
|
||||
"Coverage outline": "Abdeckungsumriss",
|
||||
"DRAPED": "DRAPIERT",
|
||||
"Below 250 m": "Unter 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m und mehr",
|
||||
"Exit cockpit to fly to a layer": "Cockpit verlassen, um zu einer Ebene zu fliegen",
|
||||
"Library": "Bibliothek",
|
||||
"Environment": "Umwelt",
|
||||
"Imagery": "Bildmaterial",
|
||||
"Demographics": "Demografie",
|
||||
"DATASET": "DATENSATZ",
|
||||
"TILES": "KACHELN",
|
||||
"PACK": "PAKET",
|
||||
"INCUBATING": "IN ERPROBUNG",
|
||||
"COMMUNITY": "COMMUNITY",
|
||||
"VERIFIED": "GEPRÜFT",
|
||||
"ARCHIVED": "ARCHIVIERT",
|
||||
"Datasets": "Datensätze",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} Datensatz",
|
||||
"other": "{count} Datensätze"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} Kachelebene",
|
||||
"other": "{count} Kachelebenen"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} Paket",
|
||||
"other": "{count} Pakete"
|
||||
},
|
||||
"Themes": "Themen",
|
||||
"Added": "Hinzugefügt",
|
||||
"{count} on the map": {
|
||||
"one": "{count} auf der Karte",
|
||||
"other": "{count} auf der Karte"
|
||||
},
|
||||
"none yet": "noch keine",
|
||||
"Map features": "Kartenobjekte",
|
||||
"from {count} datasets": {
|
||||
"one": "aus {count} Datensatz",
|
||||
"other": "aus {count} Datensätzen"
|
||||
},
|
||||
"switch on a dataset to load features": "einen Datensatz einschalten, um Objekte zu laden",
|
||||
"Switched on by this instance · part of the CCTV layer": "Von dieser Instanz eingeschaltet · Teil der CCTV-Ebene",
|
||||
"Operator opt-in: set {env}=1 on the server": "Opt-in durch Betreiber: {env}=1 auf dem Server setzen",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Opt-in durch Betreiber: {id} zu GEV_LIBRARY_ENABLE auf dem Server hinzufügen",
|
||||
"Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet",
|
||||
"Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server",
|
||||
"Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar",
|
||||
"Checking what this instance serves…": "Es wird geprüft, was diese Instanz bereitstellt…",
|
||||
"Regional": "Regional",
|
||||
"Orthophoto": "Orthofoto",
|
||||
"Satellite mosaic": "Satellitenmosaik",
|
||||
"Oblique": "Schrägluftbild",
|
||||
"Historic imagery": "Historische Luftbilder",
|
||||
"True orthophoto": "True-Orthofoto",
|
||||
"Type": "Typ",
|
||||
"Resolution": "Auflösung",
|
||||
"Flown": "Befliegung",
|
||||
"Updates": "Aktualisierung",
|
||||
"Ortho quality": "Orthoqualität",
|
||||
"DATA LIBRARY": "DATENBIBLIOTHEK",
|
||||
"Close data library": "Datenbibliothek schließen",
|
||||
"Data Library at a glance": "Datenbibliothek auf einen Blick",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Hinzugefügte Quellen erscheinen in den Datenebenen unter „Bibliothek“.",
|
||||
"Name, provider or country": "Name, Anbieter oder Land",
|
||||
"Theme": "Thema",
|
||||
"All themes": "Alle Themen",
|
||||
"Only what this instance serves": "Nur was diese Instanz bereitstellt",
|
||||
"OPT-IN": "OPT-IN",
|
||||
"KEY": "SCHLÜSSEL",
|
||||
"KEYLESS": "OHNE SCHLÜSSEL",
|
||||
"ZOOM": "ZOOMEN",
|
||||
"Zoom to {name}": "Zu {name} zoomen",
|
||||
"WORKING…": "ARBEITET…",
|
||||
"ADD": "HINZUFÜGEN",
|
||||
"Remove {name} from Data Layers": "{name} aus Datenebenen entfernen",
|
||||
"Add {name} to Data Layers": "{name} zu Datenebenen hinzufügen",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "BESTÄTIGEN · {count} ENTFERNEN",
|
||||
"other": "BESTÄTIGEN · ALLE {count} ENTFERNEN"
|
||||
},
|
||||
"REMOVE ALL": "ALLE ENTFERNEN",
|
||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
|
||||
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
|
||||
|
||||
+175
-55
@@ -3,12 +3,12 @@
|
||||
"Developing: {name}": "En desarrollo: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} suceso hoy",
|
||||
"many": "{count} sucesos hoy",
|
||||
"many": "{count} de sucesos hoy",
|
||||
"other": "{count} sucesos hoy"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} noticia hoy",
|
||||
"many": "{count} noticias hoy",
|
||||
"many": "{count} de noticias hoy",
|
||||
"other": "{count} noticias hoy"
|
||||
},
|
||||
"Disputed: {name}": "En disputa: {name}",
|
||||
@@ -17,9 +17,9 @@
|
||||
"Claimed by {who}": "Según {who}",
|
||||
"Claimed": "Afirmación",
|
||||
"Not yet confirmed by others": "Aún sin confirmar por otros",
|
||||
"Reported by {side} sources": "Informado por fuentes de {side}",
|
||||
"Reported by {sides}": "Informado por {sides}",
|
||||
"{side} sources": "fuentes de {side}",
|
||||
"Reported by {side} sources": "Informado por fuentes: {side}",
|
||||
"Reported by {sides}": "Informado por fuentes: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Oficial",
|
||||
"Reported by both sides": "Informado por ambos bandos",
|
||||
"Independently reported": "Informado de forma independiente",
|
||||
@@ -43,7 +43,7 @@
|
||||
"Humanitarian": "Humanitario",
|
||||
"Disaster": "Catástrofe",
|
||||
"Protest": "Protesta",
|
||||
"Political": "Político",
|
||||
"Political": "Política",
|
||||
"No physical event": "Sin hecho físico",
|
||||
"Minor incident": "Incidente menor",
|
||||
"Significant": "Significativo",
|
||||
@@ -53,34 +53,34 @@
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} fuente",
|
||||
"many": "{count} fuentes",
|
||||
"many": "{count} de fuentes",
|
||||
"other": "{count} fuentes"
|
||||
},
|
||||
"News feed not configured on this server": "Noticias no configuradas en este servidor",
|
||||
"Malformed news hotspots response": "Respuesta de focos de noticias mal formada",
|
||||
"Malformed news feed response": "Respuesta de noticias mal formada",
|
||||
"{place} (placed by model)": "{place} (ubicado por el modelo)",
|
||||
"English headline by the model": "Titular en inglés del modelo",
|
||||
"English headline by the model": "Titular traducido al inglés por el modelo",
|
||||
"Not placed on the map": "Sin ubicar en el mapa",
|
||||
"Close": "Cerrar",
|
||||
"Any severity": "Cualquier gravedad",
|
||||
"{severity} and above": "{severity} o más",
|
||||
"{severity} and above": "{severity} o superior",
|
||||
"{severity} only": "Solo {severity}",
|
||||
"All categories": "Todas las categorías",
|
||||
"Reading the news…": "Leyendo las noticias…",
|
||||
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
||||
"Last {hours} h": "Últimas {hours} h",
|
||||
"News, last {hours} hours": "Noticias desde hace {hours} h",
|
||||
"Last {hours} h": "Desde hace {hours} h",
|
||||
"Time window": "Periodo",
|
||||
"Conflicts": "Conflictos",
|
||||
"Local": "Local",
|
||||
"News sections": "Secciones de noticias",
|
||||
"Zoom in to see local news": "Acerca el mapa para ver noticias locales",
|
||||
"Zoom in to see local news": "Acércate para ver noticias locales",
|
||||
"Hotspots": "Focos",
|
||||
"Severity": "Gravedad",
|
||||
"Category": "Categoría",
|
||||
"Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h",
|
||||
"Nothing reported near here in the last {hours} h": "No hay noticias cerca de aquí desde hace {hours} h",
|
||||
"Latest news": "Últimas noticias",
|
||||
"No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros",
|
||||
"No news matches these filters in the last {hours} h": "Desde hace {hours} h no hay noticias que coincidan con estos filtros",
|
||||
"Load older news": "Cargar noticias anteriores",
|
||||
"How to read the map": "Cómo leer el mapa",
|
||||
"Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso",
|
||||
@@ -219,8 +219,8 @@
|
||||
"Landmark: --": "Punto de interés: --",
|
||||
"Search any location": "Buscar un lugar",
|
||||
"Search any location...": "Buscar un lugar…",
|
||||
"LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA BABOR {bearing}°",
|
||||
"LOOK STBD {bearing}°": "VISTA A ESTRIBOR {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA A BABOR {bearing}°",
|
||||
"LOOK ASTERN": "VISTA A POPA",
|
||||
"UP {angle}°": "ARRIBA {angle}°",
|
||||
"DOWN {angle}°": "ABAJO {angle}°",
|
||||
@@ -232,7 +232,7 @@
|
||||
"BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE",
|
||||
"OPTICAL PLANE · 01": "PLANO ÓPTICO · 01",
|
||||
"HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS",
|
||||
"NO HEADING · AIS": "SIN PROA · AIS",
|
||||
"NO HEADING · AIS": "PROA N/D · AIS",
|
||||
"VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO",
|
||||
"FIRST PERSON": "PRIMERA PERSONA",
|
||||
"BRIDGE VIEW": "VISTA DEL PUENTE",
|
||||
@@ -240,7 +240,7 @@
|
||||
"Cockpit vision style": "Estilo de visión de la cabina",
|
||||
"Current aircraft heading": "Rumbo actual de la aeronave",
|
||||
"Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa",
|
||||
"Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)",
|
||||
"Look ahead (Home or 0)": "Mirar a proa (Home o 0)",
|
||||
"LOOK AHEAD": "MIRAR A PROA",
|
||||
"Current vessel heading": "Proa actual del buque",
|
||||
"GROUND SPEED · KTS": "VEL. SUELO · KN",
|
||||
@@ -353,7 +353,7 @@
|
||||
"TRACK ACQUIRED": "RASTREO ADQUIRIDO",
|
||||
"{label} · COURSE {heading}°": "{label} · RUMBO {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · PROA {heading}°",
|
||||
"{label} · NO HEADING": "{label} · SIN PROA",
|
||||
"{label} · NO HEADING": "{label} · PROA N/D",
|
||||
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
|
||||
"Select flight {title}": "Seleccionar el vuelo {title}",
|
||||
"CONTEXT STANDBY": "CONTEXTO EN ESPERA",
|
||||
@@ -502,9 +502,9 @@
|
||||
"other": "{count} cámaras cargadas · activa CCTV para activarlas"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} cámara más fuera de esta vista · desplaza o aleja",
|
||||
"many": "{count} de cámaras más fuera de esta vista · desplaza o aleja",
|
||||
"other": "{count} cámaras más fuera de esta vista · desplaza o aleja"
|
||||
"one": "{count} cámara más fuera de esta vista · desplaza el mapa o aléjate",
|
||||
"many": "{count} de cámaras más fuera de esta vista · desplaza el mapa o aléjate",
|
||||
"other": "{count} cámaras más fuera de esta vista · desplaza el mapa o aléjate"
|
||||
},
|
||||
"No summary available.": "No hay resumen disponible.",
|
||||
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
|
||||
@@ -694,7 +694,7 @@
|
||||
"Directory metadata only": "Solo metadatos del directorio",
|
||||
"Choose a globe marker or use next.": "Elige un marcador del globo o pulsa SIG.",
|
||||
"TAGS · {tags}": "ETIQUETAS · {tags}",
|
||||
"Ready — playback starts only from your action": "Preparada: la reproducción empieza cuando pulses ESCUCHAR",
|
||||
"Ready — playback starts only from your action": "Preparada: la reproducción solo empieza cuando tú la inicias",
|
||||
"Connecting directly to broadcaster…": "Conectando directamente con la emisora…",
|
||||
"Buffering broadcaster stream…": "Almacenando en búfer el flujo de la emisora…",
|
||||
"Playing {station}": "Reproduciendo {station}",
|
||||
@@ -756,7 +756,7 @@
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}",
|
||||
"SITES STANDBY": "SITIOS EN ESPERA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVA INSTALACIONES MAPEADAS PARA PINGS DE SITIOS",
|
||||
"Release the camera from {name}": "Soltar la cámara de {name}",
|
||||
"RETURN · {name}": "VOLVER · {name}",
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
@@ -785,7 +785,7 @@
|
||||
"Hide the two-line element set": "Ocultar el conjunto de elementos de dos líneas",
|
||||
"Two-line element set": "Conjunto de elementos de dos líneas",
|
||||
"Return the view to nadir": "Volver la vista al nadir",
|
||||
"Return to nadir (Home or 0)": "Volver al nadir (Inicio o 0)",
|
||||
"Return to nadir (Home or 0)": "Volver al nadir (Home o 0)",
|
||||
"NADIR": "AL NADIR",
|
||||
"REPLAY ASCENT": "REPETIR ASCENSO",
|
||||
"Replay the estimated ascent with a following camera": "Reproduce el ascenso estimado con una cámara de seguimiento",
|
||||
@@ -1773,10 +1773,10 @@
|
||||
"Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com",
|
||||
"ESC EXIT": "ESC SALIR",
|
||||
"ARROWS LOOK": "FLECHAS MIRAR",
|
||||
"HOME RESET": "INICIO RESTABLECER",
|
||||
"HOME RESET": "HOME RESTABLECER",
|
||||
"+/− ZOOM": "+/− ACERCAR/ALEJAR",
|
||||
"ARROWS TILT": "FLECHAS INCLINAR",
|
||||
"HOME NADIR": "INICIO NADIR",
|
||||
"HOME NADIR": "HOME AL NADIR",
|
||||
"SIG": "SIG",
|
||||
"NEWS": "NOTICIAS",
|
||||
"LOCAL": "LOCAL",
|
||||
@@ -2096,7 +2096,7 @@
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS: DATOS INCOMPLETOS · NO IMPLICA TODO DESPEJADO",
|
||||
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
|
||||
"ft": "ft",
|
||||
"kts": "kn",
|
||||
@@ -2339,7 +2339,7 @@
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min",
|
||||
"Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC",
|
||||
"Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar el mapa o hacer zoom y una vez por minuto. La fuente pública cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no la convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.",
|
||||
"Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Índice de baja precisión = 100 × max(0, malas − 1) / total: se descuenta una aeronave, así que no es la proporción bruta. Cada celda necesita {aircraft} aeronaves en {minutes} minutos. Las anomalías de integridad de navegación no son una detección de interferencias ni de suplantación.",
|
||||
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||
"ADS-B PRIORITY · {status}": "ADS-B PRIORIDAD · {status}",
|
||||
@@ -2450,7 +2450,7 @@
|
||||
"TomTom key unavailable": "Clave de TomTom no disponible",
|
||||
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
|
||||
"Monthly traffic allowance used": "Cupo mensual de tráfico agotado",
|
||||
"Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado",
|
||||
"Traffic rate limit reached": "Límite de peticiones de tráfico alcanzado",
|
||||
"Traffic service unavailable": "Servicio de tráfico no disponible",
|
||||
"TomTom upstream unreachable": "Servidor de TomTom inaccesible",
|
||||
"TomTom flow unavailable": "Flujo de TomTom no disponible",
|
||||
@@ -3114,7 +3114,7 @@
|
||||
"Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.",
|
||||
"Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.",
|
||||
"Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación",
|
||||
"Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores",
|
||||
"Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en Ajustes de proveedores",
|
||||
"The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida",
|
||||
"Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.",
|
||||
"Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.",
|
||||
@@ -3215,18 +3215,18 @@
|
||||
"Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano",
|
||||
"Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual",
|
||||
"Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.",
|
||||
"scene|PLAY": "REPROD.",
|
||||
"scene|PLAY": "REPRODUCIR",
|
||||
"Stop playback (Esc)": "Detén la reproducción (Esc)",
|
||||
"SAVE & SHARE": "GUARDAR Y COMPARTIR",
|
||||
"Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde",
|
||||
"Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado",
|
||||
"Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción",
|
||||
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
|
||||
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el final del proyecto",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
|
||||
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
|
||||
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
|
||||
"Left panels": "Paneles izquierdos",
|
||||
"Right panels": "Paneles derechos",
|
||||
"Left panels": "Paneles de la izquierda",
|
||||
"Right panels": "Paneles de la derecha",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
|
||||
@@ -3393,7 +3393,7 @@
|
||||
"Date unknown": "Fecha desconocida",
|
||||
"Position: centre of the protected area": "Posición: centro de la zona protegida",
|
||||
"Position approximate (within about {km} km)": "Posición aproximada (margen de unos {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posición levantada (margen de unos {m} m)",
|
||||
"Position surveyed (within about {m} m)": "Posición medida (margen de unos {m} m)",
|
||||
"Vessel: {value}": "Embarcación: {value}",
|
||||
"What happened: {value}": "Qué ocurrió: {value}",
|
||||
"Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})",
|
||||
@@ -3402,8 +3402,8 @@
|
||||
"Cargo or finds: {value}": "Carga o hallazgos: {value}",
|
||||
"Protected wreck": "Pecio protegido",
|
||||
"Source: {value}": "Fuente: {value}",
|
||||
"Click the card to open EMODnet ↗": "Haz clic en la ficha para abrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Haz clic en la ficha para abrir la fuente ↗",
|
||||
"Click the card to open EMODnet ↗": "Haz clic en la tarjeta para abrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Haz clic en la tarjeta para abrir la fuente ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata",
|
||||
"Shipwrecks unavailable": "Naufragios no disponibles",
|
||||
@@ -3417,8 +3417,8 @@
|
||||
"Shipwreck": "Naufragio",
|
||||
"Fatalities: {count}": "Fallecidos: {count}",
|
||||
"Survivors: {count}": "Supervivientes: {count}",
|
||||
"Click the card to open the article ↗": "Haz clic en la ficha para abrir el artículo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su ficha.",
|
||||
"Click the card to open the article ↗": "Haz clic en la tarjeta para abrir el artículo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su tarjeta.",
|
||||
"All years": "Todos los años",
|
||||
"Period": "Periodo",
|
||||
"From": "Desde",
|
||||
@@ -3447,7 +3447,7 @@
|
||||
"{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar",
|
||||
"Wildlife sightings source unavailable": "Fuente de avistamientos no disponible",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)",
|
||||
"No recent sightings in view": "No hay avistamientos recientes en la vista",
|
||||
"No recent sightings in view": "No hay avistamientos recientes a la vista",
|
||||
"Acartia feed unavailable": "Fuente Acartia no disponible",
|
||||
"Show all taxa": "Mostrar todos los grupos taxonómicos",
|
||||
"BIRDS": "AVES",
|
||||
@@ -3466,33 +3466,33 @@
|
||||
"Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento",
|
||||
"Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento",
|
||||
"Wildlife": "Vida silvestre",
|
||||
"Zoom in for local sightings": "Acerca para ver avistamientos locales",
|
||||
"Zoom in for local sightings": "Acércate para ver avistamientos locales",
|
||||
"Loading photo...": "Cargando foto…",
|
||||
"Photo unavailable": "Foto no disponible",
|
||||
"Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamientos en la vista",
|
||||
"{count} sightings in view": "Avistamientos a la vista: {count}",
|
||||
"Close sighting": "Cerrar avistamiento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna y flora observadas en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} en iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
|
||||
"{days}d": "{days}d",
|
||||
"{days}d": "{days} d",
|
||||
"All markers": "Todos los marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} d; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} d · se conservan los marcadores sin fecha",
|
||||
"Close camera preview": "Cerrar vista previa de la cámara",
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
|
||||
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
|
||||
"Press OPEN to load the photo": "Pulsa ABRIR para cargar la foto",
|
||||
"Captured {month}": "Capturada en {month}",
|
||||
"this spot": "este punto",
|
||||
"the view centre": "el centro de la vista",
|
||||
@@ -3537,23 +3537,40 @@
|
||||
"Zoom out (−)": "Alejar (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR",
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
"Live": "En directo",
|
||||
"Back to live": "Volver al modo en vivo",
|
||||
"Live": "En vivo",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 d/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Usa la rueda o pellizca para hacer zoom, arrastra para desplazarte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Ferrocarril",
|
||||
"Urban rail": "Urbano",
|
||||
"Local rail": "Suburbano",
|
||||
"Regional rail": "Regional",
|
||||
"National rail": "Larga distancia",
|
||||
"International rail": "Internacional",
|
||||
"Hide {category}": "Ocultar {category}",
|
||||
"Show {category}": "Mostrar {category}",
|
||||
"Live GPS": "GPS en vivo",
|
||||
"Estimated from station reports": "Posición estimada a partir de avisos de estaciones",
|
||||
"To {headsign}": "Hacia {headsign}",
|
||||
"Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min de retraso)",
|
||||
"Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Foto · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba ({time})",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba ({time})",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba ({time})",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba ({time})",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
@@ -3573,6 +3590,109 @@
|
||||
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
|
||||
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})",
|
||||
"tiles unavailable": "teselas no disponibles",
|
||||
"Environment": "Medio ambiente",
|
||||
"Imagery": "Imágenes",
|
||||
"DATASET": "DATOS",
|
||||
"TILES": "TESELAS",
|
||||
"PACK": "PAQUETE",
|
||||
"INCUBATING": "EN INCUBACIÓN",
|
||||
"COMMUNITY": "COMUNIDAD",
|
||||
"VERIFIED": "VERIFICADO",
|
||||
"ARCHIVED": "ARCHIVADO",
|
||||
"Switched on by this instance · part of the CCTV layer": "Activado en esta instancia · parte de la capa CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Activación del operador: define {env}=1 en el servidor",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activación del operador: añade {id} a GEV_LIBRARY_ENABLE en el servidor",
|
||||
"Turned off by this instance's operator": "Desactivado por el operador de esta instancia",
|
||||
"Needs a provider key on the server": "Necesita una clave del proveedor en el servidor",
|
||||
"Needs this app's server · not available in a static build": "Necesita el servidor de esta app · no disponible en una compilación estática",
|
||||
"Checking what this instance serves…": "Comprobando qué ofrece esta instancia…",
|
||||
"Regional": "Regional",
|
||||
"DATA LIBRARY": "BIBLIOTECA DE DATOS",
|
||||
"Close data library": "Cerrar la biblioteca de datos",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fuentes en vivo más allá de las capas principales. Cada una se obtiene de su proveedor mientras está activa; esta app no almacena nada. Añade una y aparecerá en Capas de datos, en Biblioteca.",
|
||||
"Name, provider or country": "Nombre, proveedor o país",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Todos los temas",
|
||||
"Only what this instance serves": "Solo lo que ofrece esta instancia",
|
||||
"OPT-IN": "OPCIONAL",
|
||||
"KEY": "CLAVE",
|
||||
"KEYLESS": "SIN CLAVE",
|
||||
"WORKING…": "PROCESANDO…",
|
||||
"ADD": "AÑADIR",
|
||||
"Remove {name} from Data Layers": "Quitar {name} de Capas de datos",
|
||||
"Add {name} to Data Layers": "Añadir {name} a Capas de datos",
|
||||
"Library": "Biblioteca",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DATOS",
|
||||
"DRAPED": "SUPERPUESTO",
|
||||
"Zoom to layer": "Acercar a la capa",
|
||||
"ZOOM": "ACERCAR",
|
||||
"Zoom to {name}": "Acercar a {name}",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFIRMAR · QUITAR {count}",
|
||||
"many": "CONFIRMAR · QUITAR LAS {count}",
|
||||
"other": "CONFIRMAR · QUITAR LAS {count}"
|
||||
},
|
||||
"REMOVE ALL": "QUITAR TODO",
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: quitar de Capas de datos todas las entradas añadidas",
|
||||
"Remove every added entry from Data Layers": "Quitar de Capas de datos todas las entradas añadidas",
|
||||
"Exit cockpit to fly to a layer": "Sal de la cabina para volar a una capa",
|
||||
"Zoom to layer · fly to where this layer has data": "Acercar a la capa · vuela a donde esta capa tiene datos",
|
||||
"Remove from Data Layers": "Quitar de Capas de datos",
|
||||
"Remove from Data Layers · add it again from the library": "Quitar de Capas de datos · vuelve a añadirla desde la biblioteca",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} conjunto de datos",
|
||||
"many": "{count} de conjuntos de datos",
|
||||
"other": "{count} conjuntos de datos"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} capa de teselas",
|
||||
"many": "{count} de capas de teselas",
|
||||
"other": "{count} capas de teselas"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} paquete",
|
||||
"many": "{count} de paquetes",
|
||||
"other": "{count} paquetes"
|
||||
},
|
||||
"Added": "Añadidas",
|
||||
"{count} on the map": {
|
||||
"one": "{count} en el mapa",
|
||||
"many": "{count} en el mapa",
|
||||
"other": "{count} en el mapa"
|
||||
},
|
||||
"none yet": "ninguna todavía",
|
||||
"from {count} datasets": {
|
||||
"one": "de {count} conjunto de datos",
|
||||
"many": "de {count} de conjuntos de datos",
|
||||
"other": "de {count} conjuntos de datos"
|
||||
},
|
||||
"switch on a dataset to load features": "activa un conjunto de datos para cargar elementos",
|
||||
"Data Library at a glance": "La biblioteca de datos de un vistazo",
|
||||
"Map features": "Elementos del mapa",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"Datasets": "Conjuntos de datos",
|
||||
"Themes": "Temas",
|
||||
"zoom in to load this layer": "acércate para cargar esta capa",
|
||||
"Demographics": "Demografía",
|
||||
"Coverage outline": "Contorno de cobertura",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Mosaico satelital",
|
||||
"Oblique": "Oblicua",
|
||||
"Historic imagery": "Imágenes históricas",
|
||||
"True orthophoto": "Ortofoto verdadera",
|
||||
"Type": "Tipo",
|
||||
"Resolution": "Resolución",
|
||||
"Flown": "Vuelo",
|
||||
"Updates": "Actualización",
|
||||
"Ortho quality": "Calidad de la ortofoto",
|
||||
"Below 250 m": "Menos de 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1000 m",
|
||||
"1,000–2,000 m": "1000–2000 m",
|
||||
"2,000–3,000 m": "2000–3000 m",
|
||||
"3,000 m and above": "3000 m o más",
|
||||
"The globe stopped drawing": "El globo dejó de dibujarse",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
|
||||
|
||||
+204
-84
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"News feed unavailable": "Fil d’actualités indisponible",
|
||||
"Developing: {name}": "En cours : {name}",
|
||||
"News feed unavailable": "Fil d'actualités indisponible",
|
||||
"Developing: {name}": "En cours : {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} événement aujourd’hui",
|
||||
"many": "{count} événements aujourd’hui",
|
||||
"other": "{count} événements aujourd’hui"
|
||||
"one": "{count} événement aujourd'hui",
|
||||
"many": "{count} d'événements aujourd'hui",
|
||||
"other": "{count} événements aujourd'hui"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} article aujourd’hui",
|
||||
"many": "{count} articles aujourd’hui",
|
||||
"other": "{count} articles aujourd’hui"
|
||||
"one": "{count} article aujourd'hui",
|
||||
"many": "{count} d'articles aujourd'hui",
|
||||
"other": "{count} articles aujourd'hui"
|
||||
},
|
||||
"Disputed: {name}": "Contesté : {name}",
|
||||
"Disputed: {name}": "Zone contestée : {name}",
|
||||
"Video": "Vidéo",
|
||||
"Photos": "Photos",
|
||||
"Claimed by {who}": "Selon {who}",
|
||||
"Claimed": "Affirmation",
|
||||
"Not yet confirmed by others": "Pas encore confirmé par d’autres",
|
||||
"Reported by {side} sources": "Rapporté par des sources {side}",
|
||||
"Reported by {sides}": "Rapporté par {sides}",
|
||||
"{side} sources": "sources {side}",
|
||||
"Not yet confirmed by others": "Pas encore confirmé par d'autres",
|
||||
"Reported by {side} sources": "Rapporté par des sources : {side}",
|
||||
"Reported by {sides}": "Rapporté par des sources : {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Officiel",
|
||||
"Reported by both sides": "Rapporté par les deux camps",
|
||||
"Independently reported": "Rapporté de façon indépendante",
|
||||
"Single source": "Source unique",
|
||||
"International": "Internationale",
|
||||
"Independent": "Indépendante",
|
||||
"Unattributed": "Non attribué",
|
||||
"Unattributed": "Non attribuée",
|
||||
"Outside party": "Partie extérieure",
|
||||
"Ukraine–Russia": "Ukraine–Russie",
|
||||
"Israel–Gaza–Lebanon": "Israël–Gaza–Liban",
|
||||
@@ -48,19 +48,19 @@
|
||||
"Minor incident": "Incident mineur",
|
||||
"Significant": "Important",
|
||||
"Major": "Majeur",
|
||||
"age|now": "maintenant",
|
||||
"age|now": "à l'instant",
|
||||
"{minutes} min": "{minutes} min",
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} source",
|
||||
"many": "{count} sources",
|
||||
"many": "{count} de sources",
|
||||
"other": "{count} sources"
|
||||
},
|
||||
"News feed not configured on this server": "Fil d’actualités non configuré sur ce serveur",
|
||||
"Malformed news hotspots response": "Réponse des foyers d’actualité mal formée",
|
||||
"Malformed news feed response": "Réponse du fil d’actualités mal formée",
|
||||
"News feed not configured on this server": "Fil d'actualités non configuré sur ce serveur",
|
||||
"Malformed news hotspots response": "Réponse des foyers d'actualité mal formée",
|
||||
"Malformed news feed response": "Réponse du fil d'actualités mal formée",
|
||||
"{place} (placed by model)": "{place} (placé par le modèle)",
|
||||
"English headline by the model": "Titre anglais du modèle",
|
||||
"English headline by the model": "Titre traduit en anglais par le modèle",
|
||||
"Not placed on the map": "Non placé sur la carte",
|
||||
"Close": "Fermer",
|
||||
"Any severity": "Toute gravité",
|
||||
@@ -68,26 +68,26 @@
|
||||
"{severity} only": "{severity} uniquement",
|
||||
"All categories": "Toutes les catégories",
|
||||
"Reading the news…": "Lecture des actualités…",
|
||||
"News, last {hours} hours": "Actualités des dernières {hours} heures",
|
||||
"Last {hours} h": "Dernières {hours} h",
|
||||
"News, last {hours} hours": "Actualités sur {hours} h",
|
||||
"Last {hours} h": "Sur {hours} h",
|
||||
"Time window": "Période",
|
||||
"Conflicts": "Conflits",
|
||||
"Local": "Local",
|
||||
"News sections": "Rubriques d’actualité",
|
||||
"Zoom in to see local news": "Zoomez pour voir l’actualité locale",
|
||||
"News sections": "Rubriques d'actualité",
|
||||
"Zoom in to see local news": "Zoomez pour voir l'actualité locale",
|
||||
"Hotspots": "Foyers",
|
||||
"Severity": "Gravité",
|
||||
"Category": "Catégorie",
|
||||
"Nothing reported near here in the last {hours} h": "Rien de signalé près d’ici ces dernières {hours} h",
|
||||
"Nothing reported near here in the last {hours} h": "Rien de signalé près d'ici en {hours} h",
|
||||
"Latest news": "Dernières actualités",
|
||||
"No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres",
|
||||
"No news matches these filters in the last {hours} h": "Aucune actualité ne correspond à ces filtres sur {hours} h",
|
||||
"Load older news": "Charger des actualités plus anciennes",
|
||||
"How to read the map": "Comment lire la carte",
|
||||
"Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d’un camp.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Carte illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.",
|
||||
"Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l’article.",
|
||||
"Exit cockpit to fly to a news report": "Quittez le cockpit pour aller à un article",
|
||||
"Colour: severity · icon: type of event": "Couleur : gravité · icône : type d'événement",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d'un camp.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Fiche illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.",
|
||||
"Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l'article.",
|
||||
"Exit cockpit to fly to a news report": "Quittez le cockpit pour rejoindre un article",
|
||||
"News items in the feed": "Articles dans le fil",
|
||||
"Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.",
|
||||
"Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.",
|
||||
@@ -951,7 +951,7 @@
|
||||
"Hide the two-line element set": "Masquer le jeu d'éléments à deux lignes",
|
||||
"Two-line element set": "Jeu d'éléments à deux lignes",
|
||||
"Return the view to nadir": "Ramener la vue au nadir",
|
||||
"Return to nadir (Home or 0)": "Retour au nadir (Home ou 0)",
|
||||
"Return to nadir (Home or 0)": "Revenir au nadir (Home ou 0)",
|
||||
"NADIR": "AU NADIR",
|
||||
"Selected Space Mission": "Mission spatiale sélectionnée",
|
||||
"SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE",
|
||||
@@ -1754,9 +1754,9 @@
|
||||
"other": "{count} caméras chargées · activer CCTV pour les utiliser"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} autre caméra hors de cette vue · déplacer ou dézoomer",
|
||||
"many": "{count} d'autres caméras hors de cette vue · déplacer ou dézoomer",
|
||||
"other": "{count} autres caméras hors de cette vue · déplacer ou dézoomer"
|
||||
"one": "{count} autre caméra hors de cette vue · déplacez la carte ou dézoomez",
|
||||
"many": "{count} d'autres caméras hors de cette vue · déplacez la carte ou dézoomez",
|
||||
"other": "{count} autres caméras hors de cette vue · déplacez la carte ou dézoomez"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Activez CCTV pour charger les carrefours équipés de caméras",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.",
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.",
|
||||
"ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES",
|
||||
"REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES",
|
||||
"CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L’ACTUALISATION",
|
||||
"CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L'ACTUALISATION",
|
||||
"RESOLVING REGION": "IDENTIFICATION DE LA RÉGION",
|
||||
"REGION UNAVAILABLE": "RÉGION INDISPONIBLE",
|
||||
"NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H",
|
||||
@@ -1794,9 +1794,9 @@
|
||||
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE DÉSACTIVÉ · SITUATION PARTIELLE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS DÉSACTIVÉ · SITUATION PARTIELLE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES : DONNÉES INCOMPLÈTES · PAS UN FEU VERT",
|
||||
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
|
||||
@@ -1832,7 +1832,7 @@
|
||||
"ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES",
|
||||
"CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ",
|
||||
"{range} NM": "{range} MN",
|
||||
"{range} NM": "{range} NM",
|
||||
"AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"Disable cockpit weather effects": "Désactiver les effets météo du cockpit",
|
||||
@@ -2867,7 +2867,7 @@
|
||||
"Next cockpit vision style": "Style de vision suivant du cockpit",
|
||||
"Next vision style": "Style de vision suivant",
|
||||
"Current aircraft heading": "Cap actuel de l'aéronef",
|
||||
"Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave",
|
||||
"Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave",
|
||||
"Look ahead (Home or 0)": "Vue avant (Home ou 0)",
|
||||
"LOOK AHEAD": "VUE AVANT",
|
||||
"Current vessel heading": "Cap actuel du navire",
|
||||
@@ -2880,7 +2880,7 @@
|
||||
"Contact cockpit summary": "Résumé cockpit du contact",
|
||||
"CONTACT": "CONTACT",
|
||||
"CONTACTS · 250 KM": "CONTACTS · 250 KM",
|
||||
"RINGS {spacing} NM": "CERCLES {spacing} MN",
|
||||
"RINGS {spacing} NM": "CERCLES {spacing} NM",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} CONTACT",
|
||||
"many": "{count} DE CONTACTS",
|
||||
@@ -3195,7 +3195,7 @@
|
||||
"Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant",
|
||||
"Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle",
|
||||
"Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.",
|
||||
"Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet",
|
||||
"Play from this scene through the rest of the project": "Lire à partir de cette scène jusqu'à la fin du projet",
|
||||
"Stop playback (Esc)": "Arrêter la lecture (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin",
|
||||
"SAVE & SHARE": "ENREGISTRER ET PARTAGER",
|
||||
@@ -3372,40 +3372,40 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d’actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l’article.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d'actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l'article.",
|
||||
"Economy": "Économie",
|
||||
"News from {area}": "Actualités : {area}",
|
||||
"News from {area}": "Actualités : {area}",
|
||||
"Within {km} km of the view centre": "À moins de {km} km du centre de la vue",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "via {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l’actualité du jour des médias du pays affiché",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l'actualité du jour des médias du pays affiché",
|
||||
"{provider} refused its key": "{provider} a refusé sa clé",
|
||||
"Check {envVars} in Provider Settings": "Vérifiez {envVars} dans les paramètres des fournisseurs",
|
||||
"{provider} key required": "Clé {provider} requise",
|
||||
"{provider} limit reached for today": "{provider} a atteint sa limite du jour",
|
||||
"News resumes tomorrow, or with a paid plan": "Les actualités reprennent demain, ou avec une offre payante",
|
||||
"News key required": "Clé d’actualités requise",
|
||||
"News key required": "Clé d'actualités requise",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT",
|
||||
"Worldwide": "Monde entier",
|
||||
"Shipwrecks": "Épaves",
|
||||
"Before 1900": "Avant 1900",
|
||||
"After 1945": "Après 1945",
|
||||
"Date unknown": "Date inconnue",
|
||||
"Position: centre of the protected area": "Position : centre de la zone protégée",
|
||||
"Position: centre of the protected area": "Position : centre de la zone protégée",
|
||||
"Position approximate (within about {km} km)": "Position approximative (à environ {km} km près)",
|
||||
"Position surveyed (within about {m} m)": "Position relevée (à environ {m} m près)",
|
||||
"Vessel: {value}": "Navire : {value}",
|
||||
"What happened: {value}": "Ce qui s'est passé : {value}",
|
||||
"Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondeur : {m} m",
|
||||
"Notes: {value}": "Notes : {value}",
|
||||
"Cargo or finds: {value}": "Cargaison ou découvertes : {value}",
|
||||
"Vessel: {value}": "Navire : {value}",
|
||||
"What happened: {value}": "Ce qui s'est passé : {value}",
|
||||
"Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondeur : {m} m",
|
||||
"Notes: {value}": "Notes : {value}",
|
||||
"Cargo or finds: {value}": "Cargaison ou découvertes : {value}",
|
||||
"Protected wreck": "Épave protégée",
|
||||
"Source: {value}": "Source : {value}",
|
||||
"Source: {value}": "Source : {value}",
|
||||
"Click the card to open EMODnet ↗": "Cliquez sur la fiche pour ouvrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Cliquez sur la fiche pour ouvrir la source ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché",
|
||||
"Shipwrecks unavailable": "Épaves indisponibles",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)",
|
||||
"{count} wrecks": {
|
||||
@@ -3415,14 +3415,14 @@
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufrage",
|
||||
"Fatalities: {count}": "Morts : {count}",
|
||||
"Survivors: {count}": "Survivants : {count}",
|
||||
"Fatalities: {count}": "Morts : {count}",
|
||||
"Survivors: {count}": "Survivants : {count}",
|
||||
"Click the card to open the article ↗": "Cliquez sur la fiche pour ouvrir l'article ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le repère grossit avec le nombre de morts. Cliquez sur un repère pour voir sa fiche.",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le marqueur grossit avec le nombre de morts. Cliquez sur un marqueur pour voir sa fiche.",
|
||||
"All years": "Toutes les années",
|
||||
"Period": "Période",
|
||||
"From": "Du",
|
||||
"To": "Au",
|
||||
"From": "De",
|
||||
"To": "À",
|
||||
"First year of the shipwreck period": "Première année de la période des naufrages",
|
||||
"Last year of the shipwreck period": "Dernière année de la période des naufrages",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
@@ -3470,10 +3470,10 @@
|
||||
"Loading photo...": "Chargement de la photo…",
|
||||
"Photo unavailable": "Photo indisponible",
|
||||
"Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°",
|
||||
"Photo of {name}": "Photo de {name}",
|
||||
"Photo of {name}": "Photo : {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} observations dans la vue",
|
||||
"{count} sightings in view": "Observations dans la vue : {count}",
|
||||
"Close sighting": "Fermer l'observation",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.",
|
||||
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
|
||||
@@ -3496,17 +3496,17 @@
|
||||
"Captured {month}": "Prise en {month}",
|
||||
"this spot": "cet endroit",
|
||||
"the view centre": "le centre de la vue",
|
||||
"{source} at {place}": "{source} à {place}",
|
||||
"Street level at {place}": "Niveau rue à {place}",
|
||||
"{source} at {place}": "{source} : {place}",
|
||||
"Street level at {place}": "Niveau rue : {place}",
|
||||
"Switching map": "Changement de carte",
|
||||
"Opening imagery": "Ouverture des images",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d'abord.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
|
||||
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
|
||||
"TELECOM": "TÉLÉCOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satellites de communication et de navigation, hors Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
@@ -3515,9 +3515,9 @@
|
||||
"Space science, technology demonstration and other payloads": "Sciences spatiales, démonstrations technologiques et autres charges utiles",
|
||||
"DEBRIS": "DÉBRIS",
|
||||
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
|
||||
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
|
||||
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"Classes may lag: SATCAT is a cached copy": "Classes peut-être pas à jour : SATCAT est une copie en cache",
|
||||
"Classes may lag: SATCAT unavailable": "Classes peut-être pas à jour : SATCAT indisponible",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
|
||||
@@ -3531,7 +3531,7 @@
|
||||
"AFT": "ARRIÈRE",
|
||||
"RIGHT": "DROITE",
|
||||
"LEFT": "GAUCHE",
|
||||
"TRACK UP": "TRACE EN HAUT",
|
||||
"TRACK UP": "ROUTE EN HAUT",
|
||||
"COURSE HOLD · AIS": "VERROU ROUTE · AIS",
|
||||
"Zoom in (+)": "Zoomer (+)",
|
||||
"Zoom out (−)": "Dézoomer (−)",
|
||||
@@ -3543,22 +3543,39 @@
|
||||
"1 d/s": "1 j/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-cliquez pour tout voir",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Trains",
|
||||
"Urban rail": "Urbain",
|
||||
"Local rail": "Périurbain",
|
||||
"Regional rail": "Régional",
|
||||
"National rail": "Grandes lignes",
|
||||
"International rail": "International",
|
||||
"Hide {category}": "Masquer la catégorie {category}",
|
||||
"Show {category}": "Afficher la catégorie {category}",
|
||||
"Live GPS": "GPS en direct",
|
||||
"Estimated from station reports": "Position estimée d'après les passages en gare",
|
||||
"To {headsign}": "Vers {headsign}",
|
||||
"Next {stop} {time} {relative}": "Prochain arrêt {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Arrêt précédent {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min de retard)",
|
||||
"Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train",
|
||||
"Photo © {photographer}": "Photo © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}",
|
||||
"Photo · {licence}": "Photo · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position ({time})",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position ({time})",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position ({time})",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"ICAO: {id}": "ICAO : {id}",
|
||||
"NORAD: {id}": "NORAD : {id}",
|
||||
"MMSI: {id}": "MMSI : {id}",
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position ({time})",
|
||||
"ICAO: {id}": "OACI : {id}",
|
||||
"NORAD: {id}": "NORAD : {id}",
|
||||
"MMSI: {id}": "MMSI : {id}",
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise entre −90° et 90°, et la longitude entre −180° et 180°",
|
||||
"Street Level is unavailable": "Niveau rue n'est pas disponible",
|
||||
"No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée",
|
||||
"Street Level could not be enabled": "Impossible d'activer Niveau rue",
|
||||
@@ -3572,7 +3589,110 @@
|
||||
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
|
||||
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})",
|
||||
"tiles unavailable": "tuiles indisponibles",
|
||||
"zoom in to load this layer": "zoomez pour charger cette couche",
|
||||
"Zoom to layer": "Zoomer sur la couche",
|
||||
"Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · rejoindre la zone où cette couche a des données",
|
||||
"Remove from Data Layers": "Retirer du panneau Couches",
|
||||
"Remove from Data Layers · add it again from the library": "Retirer du panneau Couches · ajoutez-la à nouveau depuis la bibliothèque",
|
||||
"Coverage outline": "Contour de couverture",
|
||||
"DRAPED": "DRAPÉ",
|
||||
"Below 250 m": "Moins de 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1 000 m",
|
||||
"1,000–2,000 m": "1 000–2 000 m",
|
||||
"2,000–3,000 m": "2 000–3 000 m",
|
||||
"3,000 m and above": "3 000 m et plus",
|
||||
"Exit cockpit to fly to a layer": "Quittez le cockpit pour rejoindre une couche",
|
||||
"Library": "Bibliothèque",
|
||||
"Environment": "Environnement",
|
||||
"Imagery": "Imagerie",
|
||||
"Demographics": "Démographie",
|
||||
"DATASET": "JEU DE DONNÉES",
|
||||
"TILES": "TUILES",
|
||||
"PACK": "PACK",
|
||||
"INCUBATING": "EN INCUBATION",
|
||||
"COMMUNITY": "COMMUNAUTÉ",
|
||||
"VERIFIED": "VÉRIFIÉ",
|
||||
"ARCHIVED": "ARCHIVÉ",
|
||||
"Datasets": "Jeux de données",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} jeu de données",
|
||||
"many": "{count} de jeux de données",
|
||||
"other": "{count} jeux de données"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} couche de tuiles",
|
||||
"many": "{count} de couches de tuiles",
|
||||
"other": "{count} couches de tuiles"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pack",
|
||||
"many": "{count} de packs",
|
||||
"other": "{count} packs"
|
||||
},
|
||||
"Themes": "Thèmes",
|
||||
"Added": "Ajoutées",
|
||||
"{count} on the map": {
|
||||
"one": "{count} sur la carte",
|
||||
"many": "{count} sur la carte",
|
||||
"other": "{count} sur la carte"
|
||||
},
|
||||
"none yet": "aucune pour l'instant",
|
||||
"Map features": "Entités cartographiques",
|
||||
"from {count} datasets": {
|
||||
"one": "issues de {count} jeu de données",
|
||||
"many": "issues de {count} de jeux de données",
|
||||
"other": "issues de {count} jeux de données"
|
||||
},
|
||||
"switch on a dataset to load features": "activez un jeu de données pour charger des entités",
|
||||
"Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur",
|
||||
"Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance",
|
||||
"Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur",
|
||||
"Needs this app's server · not available in a static build": "Nécessite le serveur de cette application · indisponible dans une version statique",
|
||||
"Checking what this instance serves…": "Vérification de ce que propose cette instance…",
|
||||
"Regional": "Régional",
|
||||
"Orthophoto": "Orthophoto",
|
||||
"Satellite mosaic": "Mosaïque satellite",
|
||||
"Oblique": "Oblique",
|
||||
"Historic imagery": "Imagerie historique",
|
||||
"True orthophoto": "Orthophoto vraie",
|
||||
"Type": "Type",
|
||||
"Resolution": "Résolution",
|
||||
"Flown": "Prise de vue",
|
||||
"Updates": "Mise à jour",
|
||||
"Ortho quality": "Qualité ortho",
|
||||
"DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES",
|
||||
"Close data library": "Fermer la bibliothèque de données",
|
||||
"Data Library at a glance": "La bibliothèque de données en un coup d'œil",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette application. Ajoutez-en une et elle apparaît dans le panneau Couches, sous Bibliothèque.",
|
||||
"Name, provider or country": "Nom, fournisseur ou pays",
|
||||
"Theme": "Thème",
|
||||
"All themes": "Tous les thèmes",
|
||||
"Only what this instance serves": "Seulement ce que propose cette instance",
|
||||
"OPT-IN": "SUR ACTIVATION",
|
||||
"KEY": "CLÉ",
|
||||
"KEYLESS": "SANS CLÉ",
|
||||
"ZOOM": "ZOOMER",
|
||||
"Zoom to {name}": "Zoomer sur {name}",
|
||||
"WORKING…": "EN COURS…",
|
||||
"ADD": "AJOUTER",
|
||||
"Remove {name} from Data Layers": "Retirer {name} du panneau Couches",
|
||||
"Add {name} to Data Layers": "Ajouter {name} au panneau Couches",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFIRMER · RETIRER {count}",
|
||||
"many": "CONFIRMER · RETIRER LES {count}",
|
||||
"other": "CONFIRMER · RETIRER LES {count}"
|
||||
},
|
||||
"REMOVE ALL": "TOUT RETIRER",
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
|
||||
"The globe stopped drawing": "Le globe ne s’affiche plus",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
|
||||
|
||||
+160
-40
@@ -3293,17 +3293,17 @@
|
||||
"Refresh map still": "רענון תמונת המפה",
|
||||
"The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.",
|
||||
"Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה",
|
||||
"Press OPEN to load the photo": "לחץ על \"פתח\" כדי לטעון את התמונה",
|
||||
"Captured {month}": "צולם ב-{month}",
|
||||
"this spot": "המקום הזה",
|
||||
"the view centre": "מרכז התצוגה",
|
||||
"{source} at {place}": "{source} ב-{place}",
|
||||
"Street level at {place}": "רמת רחוב ב-{place}",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "צילומי רחוב: {place}",
|
||||
"Switching map": "מחליף מפה",
|
||||
"Opening imagery": "פותח צילומים",
|
||||
"Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.",
|
||||
"Street View is ready: press {open} to load it": "Street View מוכן: לחץ על \"{open}\" כדי לטעון אותו",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת הרחוב שבקישור הזה בלי מפתח תקף",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "תמונות הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.",
|
||||
"Ship bridge view": "תצוגת גשר האונייה",
|
||||
"Satellite orbital view": "התצוגה המסלולית של הלוויין",
|
||||
"Exit bridge view": "יציאה מתצוגת הגשר",
|
||||
@@ -3369,7 +3369,24 @@
|
||||
"Reported voyage": "הפלגה שדווחה",
|
||||
"REPORTED VOYAGE": "הפלגה שדווחה",
|
||||
"1 min/s": "דקה/שנ׳",
|
||||
"Rail": "רכבות",
|
||||
"Urban rail": "רכבת עירונית",
|
||||
"Local rail": "רכבת מקומית",
|
||||
"Regional rail": "רכבת אזורית",
|
||||
"National rail": "רכבת ארצית",
|
||||
"International rail": "רכבת בינלאומית",
|
||||
"Hide {category}": "הסתרת {category}",
|
||||
"Show {category}": "הצגת {category}",
|
||||
"Live GPS": "GPS חי",
|
||||
"Estimated from station reports": "משוער לפי דיווחי תחנות",
|
||||
"To {headsign}": "אל {headsign}",
|
||||
"Next {stop} {time} {relative}": "התחנה הבאה {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "התחנה הקודמת {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} דק׳ איחור)",
|
||||
"Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס אל רכבת",
|
||||
"Photo © {photographer}": "צילום © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "צילום: {photographer} · {licence}",
|
||||
"Photo · {licence}": "צילום · {licence}",
|
||||
"1 h/s": "שעה/שנ׳",
|
||||
"1 d/s": "יום/שנ׳",
|
||||
"1 wk/s": "שבוע/שנ׳",
|
||||
@@ -3405,16 +3422,16 @@
|
||||
"{provider} limit reached for today": "המכסה היומית של {provider} נוצלה",
|
||||
"News resumes tomorrow, or with a paid plan": "החדשות יחזרו מחר, או עם תוכנית בתשלום",
|
||||
"{provider} refused its key": "{provider} דחה את המפתח",
|
||||
"Check {envVars} in Provider Settings": "בדקו את {envVars} בהגדרות הספקים",
|
||||
"Check {envVars} in Provider Settings": "בדוק את {envVars} בהגדרות הספקים",
|
||||
"{provider} key required": "נדרש מפתח של {provider}",
|
||||
"Developing: {name}": "מתפתח: {name}",
|
||||
"{count} events today": {
|
||||
"one": "אירוע {count} היום",
|
||||
"one": "{count} אירוע היום",
|
||||
"two": "{count} אירועים היום",
|
||||
"other": "{count} אירועים היום"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "דיווח {count} היום",
|
||||
"one": "{count} דיווח היום",
|
||||
"two": "{count} דיווחים היום",
|
||||
"other": "{count} דיווחים היום"
|
||||
},
|
||||
@@ -3423,12 +3440,12 @@
|
||||
"via {source}": "דרך {source}",
|
||||
"Video": "וידאו",
|
||||
"Photos": "תמונות",
|
||||
"Claimed by {who}": "נטען על ידי {who}",
|
||||
"Claimed": "נטען",
|
||||
"Claimed by {who}": "לטענת {who}",
|
||||
"Claimed": "טענה",
|
||||
"Not yet confirmed by others": "טרם אושר על ידי אחרים",
|
||||
"Reported by {side} sources": "דווח על ידי מקורות {side}",
|
||||
"Reported by {sides}": "דווח על ידי {sides}",
|
||||
"{side} sources": "מקורות {side}",
|
||||
"Reported by {side} sources": "דווח על ידי מקורות: {side}",
|
||||
"Reported by {sides}": "דווח על ידי מקורות: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "רשמי",
|
||||
"Reported by both sides": "דווח על ידי שני הצדדים",
|
||||
"Independently reported": "דווח באופן עצמאי",
|
||||
@@ -3464,7 +3481,7 @@
|
||||
"{minutes} min": "{minutes} דק׳",
|
||||
"{hours} h": "{hours} שע׳",
|
||||
"{count} sources": {
|
||||
"one": "מקור {count}",
|
||||
"one": "{count} מקור",
|
||||
"two": "{count} מקורות",
|
||||
"other": "{count} מקורות"
|
||||
},
|
||||
@@ -3472,35 +3489,35 @@
|
||||
"News feed not configured on this server": "פיד החדשות אינו מוגדר בשרת זה",
|
||||
"Malformed news hotspots response": "תגובת מוקדי החדשות פגומה",
|
||||
"Malformed news feed response": "תגובת פיד החדשות פגומה",
|
||||
"Exit cockpit to fly to a news report": "צאו מתא הטייס כדי לטוס אל דיווח חדשותי",
|
||||
"Exit cockpit to fly to a news report": "צא מתא הטייס כדי לטוס אל דיווח חדשותי",
|
||||
"{source} · LAST {hours} H": "{source} · {hours} השעות האחרונות",
|
||||
"CACHED · REFRESH FAILED": "מהמטמון · הרענון נכשל",
|
||||
"{source} · CACHED {age}": "{source} · מהמטמון {age}",
|
||||
"NO NEWS IN THE LAST {hours} H": "אין חדשות ב־{hours} השעות האחרונות",
|
||||
"{place} (placed by model)": "{place} (מוקם על ידי המודל)",
|
||||
"NO NEWS IN THE LAST {hours} H": "אין חדשות ב-{hours} השעות האחרונות",
|
||||
"{place} (placed by model)": "{place} (מיקום לפי המודל)",
|
||||
"English headline by the model": "כותרת באנגלית מאת המודל",
|
||||
"Not placed on the map": "לא מוקם על המפה",
|
||||
"Not placed on the map": "ללא מיקום על המפה",
|
||||
"Close": "סגירה",
|
||||
"Conflicts": "עימותים",
|
||||
"Local": "מקומי",
|
||||
"Any severity": "כל חומרה",
|
||||
"Any severity": "כל רמות החומרה",
|
||||
"{severity} and above": "{severity} ומעלה",
|
||||
"{severity} only": "{severity} בלבד",
|
||||
"All categories": "כל הקטגוריות",
|
||||
"Last {hours} h": "{hours} שע׳ אחרונות",
|
||||
"News from {area}": "חדשות מ{area}",
|
||||
"Within {km} km of the view centre": "בטווח {km} ק״מ ממרכז התצוגה",
|
||||
"Last {hours} h": "{hours} שע׳ אחורה",
|
||||
"News from {area}": "חדשות מ-{area}",
|
||||
"Within {km} km of the view centre": "בטווח {km} ק\"מ ממרכז התצוגה",
|
||||
"Reading the news…": "קורא את החדשות…",
|
||||
"News, last {hours} hours": "חדשות, {hours} השעות האחרונות",
|
||||
"News, last {hours} hours": "חדשות, {hours} שע׳ אחורה",
|
||||
"Time window": "חלון זמן",
|
||||
"News sections": "מדורי חדשות",
|
||||
"Hotspots": "מוקדים",
|
||||
"Severity": "חומרה",
|
||||
"Category": "קטגוריה",
|
||||
"Zoom in to see local news": "התקרבו כדי לראות חדשות מקומיות",
|
||||
"Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה ב־{hours} השעות האחרונות",
|
||||
"Zoom in to see local news": "התקרב כדי לראות חדשות מקומיות",
|
||||
"Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה בחלון של {hours} שע׳",
|
||||
"Latest news": "חדשות אחרונות",
|
||||
"No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה ב־{hours} השעות האחרונות",
|
||||
"No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה בחלון של {hours} שע׳",
|
||||
"Load older news": "טעינת חדשות ישנות יותר",
|
||||
"How to read the map": "איך לקרוא את המפה",
|
||||
"Colour: severity · icon: type of event": "צבע: חומרה · סמל: סוג האירוע",
|
||||
@@ -3514,8 +3531,8 @@
|
||||
"After 1945": "אחרי 1945",
|
||||
"Date unknown": "תאריך לא ידוע",
|
||||
"Position: centre of the protected area": "מיקום: מרכז השטח המוגן",
|
||||
"Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ־{km} ק״מ)",
|
||||
"Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ־{m} מ׳)",
|
||||
"Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ-{km} ק\"מ)",
|
||||
"Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ-{m} מ׳)",
|
||||
"Shipwreck": "ספינה טרופה",
|
||||
"Fatalities: {count}": "הרוגים: {count}",
|
||||
"Survivors: {count}": "ניצולים: {count}",
|
||||
@@ -3532,32 +3549,32 @@
|
||||
"Click the card to open the source ↗": "לחץ על הכרטיס כדי לפתוח את המקור ↗",
|
||||
"All years": "כל השנים",
|
||||
"Period": "תקופה",
|
||||
"From": "מ־",
|
||||
"From": "מ-",
|
||||
"First year of the shipwreck period": "השנה הראשונה בתקופת הטביעות",
|
||||
"To": "עד",
|
||||
"Last year of the shipwreck period": "השנה האחרונה בתקופת הטביעות",
|
||||
"Wikidata unavailable; showing EMODnet only": "ויקינתונים אינו זמין; מוצג EMODnet בלבד",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג ויקינתונים בלבד",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata אינו זמין; מוצג EMODnet בלבד",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג Wikidata בלבד",
|
||||
"Shipwrecks unavailable": "ספינות טרופות לא זמינות",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} ספינה טרופה",
|
||||
"two": "{count} ספינות טרופות",
|
||||
"other": "{count} ספינות טרופות"
|
||||
},
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · Wikidata (כל העולם)",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ-EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מ-Wikidata. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום נכון ל-{time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID של כלי השיט: {id}",
|
||||
"Vessel UUID: {id}": "UUID של האונייה: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°",
|
||||
"Street Level is unavailable": "Street Level אינו זמין",
|
||||
"No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב",
|
||||
@@ -3573,6 +3590,109 @@
|
||||
"{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים",
|
||||
"No {source} imagery near {place}": "אין צילומים של {source} ליד {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})",
|
||||
"tiles unavailable": "האריחים אינם זמינים",
|
||||
"zoom in to load this layer": "התקרב כדי לטעון את השכבה הזאת",
|
||||
"Zoom to layer": "התקרבות לשכבה",
|
||||
"Zoom to layer · fly to where this layer has data": "התקרבות לשכבה · טיסה למקום שבו יש לשכבה נתונים",
|
||||
"Remove from Data Layers": "הסרה משכבות נתונים",
|
||||
"Remove from Data Layers · add it again from the library": "הסרה משכבות נתונים · אפשר להוסיף אותה שוב מהספרייה",
|
||||
"Coverage outline": "קו מתאר של הכיסוי",
|
||||
"DRAPED": "פרוש על הקרקע",
|
||||
"Below 250 m": "מתחת ל-250 מ׳",
|
||||
"250–500 m": "250–500 מ׳",
|
||||
"500–1,000 m": "500–1,000 מ׳",
|
||||
"1,000–2,000 m": "1,000–2,000 מ׳",
|
||||
"2,000–3,000 m": "2,000–3,000 מ׳",
|
||||
"3,000 m and above": "3,000 מ׳ ומעלה",
|
||||
"Exit cockpit to fly to a layer": "צא מתא הטייס כדי לטוס אל שכבה",
|
||||
"Library": "ספרייה",
|
||||
"Environment": "סביבה",
|
||||
"Imagery": "צילומים",
|
||||
"Demographics": "דמוגרפיה",
|
||||
"DATASET": "מערך נתונים",
|
||||
"TILES": "אריחים",
|
||||
"PACK": "חבילה",
|
||||
"INCUBATING": "בהבשלה",
|
||||
"COMMUNITY": "קהילה",
|
||||
"VERIFIED": "מאומת",
|
||||
"ARCHIVED": "בארכיון",
|
||||
"Datasets": "מערכי נתונים",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} מערך נתונים",
|
||||
"two": "{count} מערכי נתונים",
|
||||
"other": "{count} מערכי נתונים"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} שכבת אריחים",
|
||||
"two": "{count} שכבות אריחים",
|
||||
"other": "{count} שכבות אריחים"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} חבילה",
|
||||
"two": "{count} חבילות",
|
||||
"other": "{count} חבילות"
|
||||
},
|
||||
"Themes": "נושאים",
|
||||
"Added": "נוספו",
|
||||
"{count} on the map": {
|
||||
"one": "{count} על המפה",
|
||||
"two": "{count} על המפה",
|
||||
"other": "{count} על המפה"
|
||||
},
|
||||
"none yet": "אין עדיין",
|
||||
"Map features": "אובייקטים במפה",
|
||||
"from {count} datasets": {
|
||||
"one": "מ-{count} מערך נתונים",
|
||||
"two": "מ-{count} מערכי נתונים",
|
||||
"other": "מ-{count} מערכי נתונים"
|
||||
},
|
||||
"switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון אובייקטים",
|
||||
"Switched on by this instance · part of the CCTV layer": "מופעל במופע הזה · חלק משכבת CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "הפעלה ביוזמת המפעיל: הגדר {env}=1 בשרת",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "הפעלה ביוזמת המפעיל: הוסף את {id} ל-GEV_LIBRARY_ENABLE בשרת",
|
||||
"Turned off by this instance's operator": "כובה על ידי המפעיל של המופע הזה",
|
||||
"Needs a provider key on the server": "נדרש מפתח ספק בשרת",
|
||||
"Needs this app's server · not available in a static build": "נדרש השרת של האפליקציה · לא זמין בבנייה סטטית",
|
||||
"Checking what this instance serves…": "בודק מה המופע הזה מספק…",
|
||||
"Regional": "אזורי",
|
||||
"Orthophoto": "אורתופוטו",
|
||||
"Satellite mosaic": "פסיפס לווייני",
|
||||
"Oblique": "צילום אלכסוני",
|
||||
"Historic imagery": "צילומים היסטוריים",
|
||||
"True orthophoto": "אורתופוטו אמיתי",
|
||||
"Type": "סוג",
|
||||
"Resolution": "רזולוציה",
|
||||
"Flown": "צולם מהאוויר",
|
||||
"Updates": "עדכונים",
|
||||
"Ortho quality": "איכות האורתו",
|
||||
"DATA LIBRARY": "ספריית נתונים",
|
||||
"Close data library": "סגירת ספריית הנתונים",
|
||||
"Data Library at a glance": "ספריית הנתונים במבט חטוף",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "מקורות חיים מעבר לשכבות הליבה. כל מקור נטען מהספק שלו כל עוד הוא מופעל; דבר אינו נשמר באפליקציה. הוסף מקור והוא יופיע בשכבות נתונים תחת ספרייה.",
|
||||
"Name, provider or country": "שם, ספק או מדינה",
|
||||
"Theme": "נושא",
|
||||
"All themes": "כל הנושאים",
|
||||
"Only what this instance serves": "רק מה שהמופע הזה מספק",
|
||||
"OPT-IN": "בהפעלה ידנית",
|
||||
"KEY": "מפתח",
|
||||
"KEYLESS": "ללא מפתח",
|
||||
"ZOOM": "התקרבות",
|
||||
"Zoom to {name}": "התקרבות אל {name}",
|
||||
"WORKING…": "מבצע…",
|
||||
"ADD": "הוספה",
|
||||
"Remove {name} from Data Layers": "הסרת {name} משכבות נתונים",
|
||||
"Add {name} to Data Layers": "הוספת {name} לשכבות נתונים",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "אישור · הסרה ({count})",
|
||||
"two": "אישור · הסרת הכול ({count})",
|
||||
"other": "אישור · הסרת הכול ({count})"
|
||||
},
|
||||
"REMOVE ALL": "הסרת הכול",
|
||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
|
||||
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
|
||||
|
||||
+176
-56
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"News feed unavailable": "Feed delle notizie non disponibile",
|
||||
"Developing: {name}": "In sviluppo: {name}",
|
||||
"Developing: {name}": "In evoluzione: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} evento oggi",
|
||||
"many": "{count} eventi oggi",
|
||||
"many": "{count} di eventi oggi",
|
||||
"other": "{count} eventi oggi"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} notizia oggi",
|
||||
"many": "{count} notizie oggi",
|
||||
"many": "{count} di notizie oggi",
|
||||
"other": "{count} notizie oggi"
|
||||
},
|
||||
"Disputed: {name}": "Conteso: {name}",
|
||||
"Disputed: {name}": "Area contesa: {name}",
|
||||
"Video": "Video",
|
||||
"Photos": "Foto",
|
||||
"Claimed by {who}": "Secondo {who}",
|
||||
"Claimed": "Affermazione",
|
||||
"Not yet confirmed by others": "Non ancora confermato da altri",
|
||||
"Reported by {side} sources": "Riportato da fonti {side}",
|
||||
"Reported by {sides}": "Riportato da {sides}",
|
||||
"{side} sources": "fonti {side}",
|
||||
"Reported by {side} sources": "Riportato da fonti: {side}",
|
||||
"Reported by {sides}": "Riportato da fonti: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Ufficiale",
|
||||
"Reported by both sides": "Riportato da entrambe le parti",
|
||||
"Independently reported": "Riportato in modo indipendente",
|
||||
@@ -53,14 +53,14 @@
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} fonte",
|
||||
"many": "{count} fonti",
|
||||
"many": "{count} di fonti",
|
||||
"other": "{count} fonti"
|
||||
},
|
||||
"News feed not configured on this server": "Feed delle notizie non configurato su questo server",
|
||||
"Malformed news hotspots response": "Risposta dei focolai di notizie non valida",
|
||||
"Malformed news feed response": "Risposta del feed delle notizie non valida",
|
||||
"{place} (placed by model)": "{place} (collocato dal modello)",
|
||||
"English headline by the model": "Titolo inglese del modello",
|
||||
"{place} (placed by model)": "{place} (posizione stimata dal modello)",
|
||||
"English headline by the model": "Titolo tradotto in inglese dal modello",
|
||||
"Not placed on the map": "Non collocato sulla mappa",
|
||||
"Close": "Chiudi",
|
||||
"Any severity": "Qualsiasi gravità",
|
||||
@@ -68,8 +68,8 @@
|
||||
"{severity} only": "Solo {severity}",
|
||||
"All categories": "Tutte le categorie",
|
||||
"Reading the news…": "Lettura delle notizie…",
|
||||
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
|
||||
"Last {hours} h": "Ultime {hours} h",
|
||||
"News, last {hours} hours": "Notizie, periodo in ore: {hours}",
|
||||
"Last {hours} h": "Periodo: {hours} h",
|
||||
"Time window": "Periodo",
|
||||
"Conflicts": "Conflitti",
|
||||
"Local": "Locale",
|
||||
@@ -78,16 +78,16 @@
|
||||
"Hotspots": "Focolai",
|
||||
"Severity": "Gravità",
|
||||
"Category": "Categoria",
|
||||
"Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h",
|
||||
"Nothing reported near here in the last {hours} h": "Nessuna segnalazione qui vicino · periodo: {hours} h",
|
||||
"Latest news": "Ultime notizie",
|
||||
"No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri",
|
||||
"No news matches these filters in the last {hours} h": "Nessuna notizia corrisponde a questi filtri · periodo: {hours} h",
|
||||
"Load older news": "Carica notizie precedenti",
|
||||
"How to read the map": "Come leggere la mappa",
|
||||
"Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un’affermazione. Bordo sottile: una fonte, o la stampa di una parte.",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un'affermazione. Bordo sottile: una fonte, o la stampa di una parte.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Più grande: più fonti · più tenue: più vecchio. Scheda con immagine: un evento grave o in rapida crescita con due o più fonti.",
|
||||
"Side labels describe the source, not whether a report is true.": "Le etichette di parte descrivono la fonte, non se la notizia è vera.",
|
||||
"Exit cockpit to fly to a news report": "Esci dalla cabina per volare a una notizia",
|
||||
"Side labels describe the source, not whether a report is true.": "Le etichette delle parti descrivono la fonte, non se la notizia è vera.",
|
||||
"Exit cockpit to fly to a news report": "Esci dal cockpit per volare a una notizia",
|
||||
"News items in the feed": "Notizie nel feed",
|
||||
"Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.",
|
||||
"Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.",
|
||||
@@ -1230,8 +1230,8 @@
|
||||
"Hybrid": "Ibrido",
|
||||
"Lines": "Linee",
|
||||
"Dots": "Punti",
|
||||
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
|
||||
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
|
||||
"Road lines colored by live flow: green to red": "Strade colorate in base al flusso live: dal verde al rosso",
|
||||
"Moving dots colored by live flow": "Punti in movimento colorati in base al flusso live",
|
||||
"Moving dots": "Punti in movimento",
|
||||
"TomTom key unavailable": "Chiave TomTom non disponibile",
|
||||
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
|
||||
@@ -1754,9 +1754,9 @@
|
||||
"other": "{count} telecamere caricate · attiva CCTV per attivarle"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom",
|
||||
"many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom",
|
||||
"other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom"
|
||||
"one": "{count} altra telecamera fuori da questa vista · sposta la mappa o riduci lo zoom",
|
||||
"many": "Altre {count} di telecamere fuori da questa vista · sposta la mappa o riduci lo zoom",
|
||||
"other": "Altre {count} telecamere fuori da questa vista · sposta la mappa o riduci lo zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.",
|
||||
@@ -1796,7 +1796,7 @@
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "DATI AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
|
||||
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
|
||||
@@ -1881,7 +1881,7 @@
|
||||
"TRACK ACQUIRED": "TRACCIA ACQUISITA",
|
||||
"{label} · COURSE {heading}°": "{label} · ROTTA {heading}°",
|
||||
"{label} · HEADING {heading}°": "{label} · PRUA {heading}°",
|
||||
"{label} · NO HEADING": "{label} · NESSUNA PRUA",
|
||||
"{label} · NO HEADING": "{label} · PRUA N/D",
|
||||
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
|
||||
"Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova",
|
||||
"No selected data layers": "Nessun livello dati selezionato",
|
||||
@@ -2839,8 +2839,8 @@
|
||||
"Aircraft cockpit view": "Vista cockpit dell'aereo",
|
||||
"Ship bridge view": "Vista dalla plancia della nave",
|
||||
"Satellite orbital view": "Vista orbitale dal satellite",
|
||||
"LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°",
|
||||
"LOOK STBD {bearing}°": "VISTA A DRITTA {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA A SINISTRA {bearing}°",
|
||||
"LOOK ASTERN": "VISTA A POPPA",
|
||||
"UP {angle}°": "SU {angle}°",
|
||||
"DOWN {angle}°": "GIÙ {angle}°",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI",
|
||||
"OPTICAL PLANE · 01": "PIANO OTTICO · 01",
|
||||
"HEADING HOLD · AIS": "BLOCCO PRUA · AIS",
|
||||
"NO HEADING · AIS": "NESSUNA PRUA · AIS",
|
||||
"NO HEADING · AIS": "PRUA N/D · AIS",
|
||||
"VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO",
|
||||
"GROUND SPEED · KTS": "VEL. AL SUOLO · KTS",
|
||||
"SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS",
|
||||
@@ -2989,7 +2989,7 @@
|
||||
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
|
||||
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l'indice delle missioni. Riprova più tardi.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
|
||||
"Internet radio and local SDR": "Radio internet e SDR locale",
|
||||
@@ -3010,7 +3010,7 @@
|
||||
"Radio volume": "Volume radio",
|
||||
"STATION SITE": "SITO STAZIONE",
|
||||
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente quando premi play, scegli una stazione sul globo o chiedi la riproduzione con un comando vocale. Il tuo IP è visibile a quell'emittente.",
|
||||
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
|
||||
"LOCAL RTL-SDR": "RTL-SDR LOCALE",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
|
||||
@@ -3207,7 +3207,7 @@
|
||||
"Left panels": "Pannelli a sinistra",
|
||||
"Right panels": "Pannelli a destra",
|
||||
"CONFIRMED": "CONFERMATA",
|
||||
"PAD / RTLS": "SITO DI LANCIO / RTLS",
|
||||
"PAD / RTLS": "RAMPA / RTLS",
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
@@ -3372,7 +3372,7 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette di parte, dove presenti, descrivono la fonte, non se la notizia è vera.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette delle parti, dove presenti, descrivono la fonte, non se la notizia è vera.",
|
||||
"Economy": "Economia",
|
||||
"News from {area}": "Notizie: {area}",
|
||||
"Within {km} km of the view centre": "Entro {km} km dal centro della vista",
|
||||
@@ -3394,7 +3394,7 @@
|
||||
"Position: centre of the protected area": "Posizione: centro dell'area protetta",
|
||||
"Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)",
|
||||
"Vessel: {value}": "Imbarcazione: {value}",
|
||||
"Vessel: {value}": "Nave: {value}",
|
||||
"What happened: {value}": "Cosa è successo: {value}",
|
||||
"Depth: {m} m ({quality})": "Profondità: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondità: {m} m",
|
||||
@@ -3414,11 +3414,11 @@
|
||||
"other": "{count} relitti"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufragio",
|
||||
"Shipwreck": "Relitto",
|
||||
"Fatalities: {count}": "Vittime: {count}",
|
||||
"Survivors: {count}": "Sopravvissuti: {count}",
|
||||
"Click the card to open the article ↗": "Fai clic sulla scheda per aprire l'articolo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda.",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; il marcatore cresce con il numero di vittime. Fai clic su un marcatore per la sua scheda.",
|
||||
"All years": "Tutti gli anni",
|
||||
"Period": "Periodo",
|
||||
"From": "Dal",
|
||||
@@ -3444,7 +3444,7 @@
|
||||
"Fungi": "Funghi",
|
||||
"Other animals": "Altri animali",
|
||||
"Other or unknown": "Altri o sconosciuti",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare",
|
||||
"{label} {detail} — click to retry": "{label} {detail}: clic per riprovare",
|
||||
"Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)",
|
||||
"No recent sightings in view": "Nessun avvistamento recente nella vista",
|
||||
@@ -3463,8 +3463,8 @@
|
||||
"All rights reserved": "Tutti i diritti riservati",
|
||||
"Cetacean": "Cetaceo",
|
||||
"Wildlife Sightings": "Avvistamenti naturalistici",
|
||||
"Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento",
|
||||
"Exit cockpit to fly to a sighting": "Esci dal cockpit per volare a un avvistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Esci dal cockpit per volare a un'area di avvistamento",
|
||||
"Wildlife": "Natura",
|
||||
"Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali",
|
||||
"Loading photo...": "Caricamento della foto…",
|
||||
@@ -3473,7 +3473,7 @@
|
||||
"Photo of {name}": "Foto di {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avvistamenti nella vista",
|
||||
"{count} sightings in view": "Avvistamenti nella vista: {count}",
|
||||
"Close sighting": "Chiudi avvistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.",
|
||||
"Open {name} on iNaturalist": "Apri {name} su iNaturalist",
|
||||
@@ -3481,23 +3481,23 @@
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
|
||||
"{days}d": "{days}g",
|
||||
"All markers": "Tutti gli indicatori",
|
||||
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"{days}d": "{days} g",
|
||||
"All markers": "Tutti i marcatori",
|
||||
"Show markers of every age": "Mostra i marcatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} g; marcatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} g · senza data mantenuti",
|
||||
"Close camera preview": "Chiudi anteprima della telecamera",
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"The map still uses a separate request and may show a different moment.": "L'immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
|
||||
"Press OPEN to load the photo": "Premi APRI per caricare la foto",
|
||||
"Captured {month}": "Scattata a {month}",
|
||||
"Captured {month}": "Scattata: {month}",
|
||||
"this spot": "questo punto",
|
||||
"the view centre": "il centro della vista",
|
||||
"{source} at {place}": "{source} a {place}",
|
||||
"Street level at {place}": "Vista stradale a {place}",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Vista stradale: {place}",
|
||||
"Switching map": "Cambio mappa",
|
||||
"Opening imagery": "Apertura delle immagini",
|
||||
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
|
||||
@@ -3512,11 +3512,11 @@
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "La costellazione Starlink (migliaia di punti in più)",
|
||||
"PROBES": "SONDE",
|
||||
"Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri carichi utili",
|
||||
"Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri payload",
|
||||
"DEBRIS": "DETRITI",
|
||||
"Rocket bodies, fragments and dead satellites": "Stadi di razzi, frammenti e satelliti non operativi",
|
||||
"Classes may lag: SATCAT is a cached copy": "Le classi potrebbero essere in ritardo: SATCAT è una copia in cache",
|
||||
"Classes may lag: SATCAT unavailable": "Le classi potrebbero essere in ritardo: SATCAT non disponibile",
|
||||
"Classes may lag: SATCAT is a cached copy": "Le classi potrebbero non essere aggiornate: SATCAT è una copia in cache",
|
||||
"Classes may lag: SATCAT unavailable": "Le classi potrebbero non essere aggiornate: SATCAT non disponibile",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
@@ -3545,24 +3545,41 @@
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Ferrovie",
|
||||
"Urban rail": "Urbano",
|
||||
"Local rail": "Suburbano",
|
||||
"Regional rail": "Regionale",
|
||||
"National rail": "Lunga percorrenza",
|
||||
"International rail": "Internazionale",
|
||||
"Hide {category}": "Nascondi {category}",
|
||||
"Show {category}": "Mostra {category}",
|
||||
"Live GPS": "GPS in tempo reale",
|
||||
"Estimated from station reports": "Posizione stimata dalle segnalazioni delle stazioni",
|
||||
"To {headsign}": "Per {headsign}",
|
||||
"Next {stop} {time} {relative}": "Prossima fermata {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Fermata precedente {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min di ritardo)",
|
||||
"Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Foto · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°",
|
||||
"Street Level is unavailable": "Vista stradale non è disponibile",
|
||||
"Street Level is unavailable": "Il livello Vista stradale non è disponibile",
|
||||
"No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata",
|
||||
"Street Level could not be enabled": "Impossibile attivare Vista stradale",
|
||||
"Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini",
|
||||
"Street Level changed while it looked for imagery": "Il livello Vista stradale è cambiato durante la ricerca delle immagini",
|
||||
"Aim the view at a street": "Punta la vista su una strada",
|
||||
"{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui",
|
||||
"{source} needs its key": "{source} richiede la sua chiave",
|
||||
@@ -3573,6 +3590,109 @@
|
||||
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
|
||||
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})",
|
||||
"tiles unavailable": "tile non disponibili",
|
||||
"zoom in to load this layer": "ingrandisci per caricare questo livello",
|
||||
"Zoom to layer": "Zoom sul livello",
|
||||
"Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati",
|
||||
"Remove from Data Layers": "Rimuovi da Livelli dati",
|
||||
"Remove from Data Layers · add it again from the library": "Rimuovi da Livelli dati · aggiungilo di nuovo dalla libreria",
|
||||
"Coverage outline": "Contorno di copertura",
|
||||
"DRAPED": "DRAPPEGGIATO",
|
||||
"Below 250 m": "Sotto i 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m e oltre",
|
||||
"Exit cockpit to fly to a layer": "Esci dal cockpit per volare a un livello",
|
||||
"Library": "Libreria",
|
||||
"Environment": "Ambiente",
|
||||
"Imagery": "Immagini",
|
||||
"Demographics": "Demografia",
|
||||
"DATASET": "DATASET",
|
||||
"TILES": "TILE",
|
||||
"PACK": "PACCHETTO",
|
||||
"INCUBATING": "IN INCUBAZIONE",
|
||||
"COMMUNITY": "COMUNITÀ",
|
||||
"VERIFIED": "VERIFICATO",
|
||||
"ARCHIVED": "ARCHIVIATO",
|
||||
"Datasets": "Dataset",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} dataset",
|
||||
"many": "{count} di dataset",
|
||||
"other": "{count} dataset"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} livello di tile",
|
||||
"many": "{count} di livelli di tile",
|
||||
"other": "{count} livelli di tile"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pacchetto",
|
||||
"many": "{count} di pacchetti",
|
||||
"other": "{count} pacchetti"
|
||||
},
|
||||
"Themes": "Temi",
|
||||
"Added": "Aggiunti",
|
||||
"{count} on the map": {
|
||||
"one": "{count} sulla mappa",
|
||||
"many": "{count} sulla mappa",
|
||||
"other": "{count} sulla mappa"
|
||||
},
|
||||
"none yet": "ancora nessuno",
|
||||
"Map features": "Elementi della mappa",
|
||||
"from {count} datasets": {
|
||||
"one": "da {count} dataset",
|
||||
"many": "da {count} di dataset",
|
||||
"other": "da {count} dataset"
|
||||
},
|
||||
"switch on a dataset to load features": "attiva un dataset per caricare gli elementi",
|
||||
"Switched on by this instance · part of the CCTV layer": "Attivato da questa istanza · parte del livello CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server",
|
||||
"Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza",
|
||||
"Needs a provider key on the server": "Richiede una chiave del provider sul server",
|
||||
"Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica",
|
||||
"Checking what this instance serves…": "Verifica di ciò che offre questa istanza…",
|
||||
"Regional": "Regionale",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Mosaico satellitare",
|
||||
"Oblique": "Obliqua",
|
||||
"Historic imagery": "Immagini storiche",
|
||||
"True orthophoto": "Ortofoto vera",
|
||||
"Type": "Tipo",
|
||||
"Resolution": "Risoluzione",
|
||||
"Flown": "Volo",
|
||||
"Updates": "Aggiornamenti",
|
||||
"Ortho quality": "Qualità ortofoto",
|
||||
"DATA LIBRARY": "LIBRERIA DATI",
|
||||
"Close data library": "Chiudi la libreria dati",
|
||||
"Data Library at a glance": "La libreria dati in sintesi",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti live oltre ai livelli principali. Ognuna viene caricata dal suo provider finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.",
|
||||
"Name, provider or country": "Nome, provider o paese",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Tutti i temi",
|
||||
"Only what this instance serves": "Solo ciò che offre questa istanza",
|
||||
"OPT-IN": "SU ATTIVAZIONE",
|
||||
"KEY": "CHIAVE",
|
||||
"KEYLESS": "SENZA CHIAVE",
|
||||
"ZOOM": "ZOOM",
|
||||
"Zoom to {name}": "Zoom su {name}",
|
||||
"WORKING…": "IN CORSO…",
|
||||
"ADD": "AGGIUNGI",
|
||||
"Remove {name} from Data Layers": "Rimuovi {name} da Livelli dati",
|
||||
"Add {name} to Data Layers": "Aggiungi {name} a Livelli dati",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFERMA · RIMUOVI {count}",
|
||||
"many": "CONFERMA · RIMUOVI TUTTE LE {count}",
|
||||
"other": "CONFERMA · RIMUOVI TUTTE LE {count}"
|
||||
},
|
||||
"REMOVE ALL": "RIMUOVI TUTTO",
|
||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
|
||||
"The globe stopped drawing": "Il globo ha smesso di disegnare",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
|
||||
|
||||
+185
-77
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"News feed unavailable": "ニュースフィードを利用できません",
|
||||
"Developing: {name}": "進行中: {name}",
|
||||
"Developing: {name}": "進行中:{name}",
|
||||
"{count} events today": {
|
||||
"other": "本日の出来事 {count}件"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"other": "本日の報道 {count}件"
|
||||
},
|
||||
"Disputed: {name}": "係争地: {name}",
|
||||
"Disputed: {name}": "係争地:{name}",
|
||||
"Video": "動画",
|
||||
"Photos": "写真",
|
||||
"Claimed by {who}": "{who}の主張",
|
||||
"Claimed": "主張",
|
||||
"Not yet confirmed by others": "他からの確認はまだない",
|
||||
"Reported by {side} sources": "{side}側の情報源が報道",
|
||||
"Reported by {sides}": "報道: {sides}",
|
||||
"{side} sources": "{side}側の情報源",
|
||||
"Not yet confirmed by others": "他の情報源による確認なし",
|
||||
"Reported by {side} sources": "{side}の情報源が報道",
|
||||
"Reported by {sides}": "報道:{sides}",
|
||||
"{side} sources": "{side}の情報源",
|
||||
"Official": "公式",
|
||||
"Reported by both sides": "双方が報道",
|
||||
"Independently reported": "独立した報道あり",
|
||||
@@ -36,13 +36,13 @@
|
||||
"Air defence": "防空",
|
||||
"Ground fighting": "地上戦",
|
||||
"Casualties": "死傷者",
|
||||
"Humanitarian": "人道",
|
||||
"Humanitarian": "人道問題",
|
||||
"Disaster": "災害",
|
||||
"Protest": "抗議",
|
||||
"Political": "政治",
|
||||
"No physical event": "物理的な事象なし",
|
||||
"Minor incident": "小規模な事案",
|
||||
"Significant": "重大",
|
||||
"Significant": "中程度",
|
||||
"Major": "深刻",
|
||||
"age|now": "今",
|
||||
"{minutes} min": "{minutes}分",
|
||||
@@ -68,7 +68,7 @@
|
||||
"Conflicts": "紛争",
|
||||
"Local": "ローカル",
|
||||
"News sections": "ニュースの区分",
|
||||
"Zoom in to see local news": "拡大するとローカルニュースが表示されます",
|
||||
"Zoom in to see local news": "ズームインするとローカルニュースが表示されます",
|
||||
"Hotspots": "ホットスポット",
|
||||
"Severity": "深刻度",
|
||||
"Category": "カテゴリ",
|
||||
@@ -77,10 +77,10 @@
|
||||
"No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません",
|
||||
"Load older news": "以前のニュースを読み込む",
|
||||
"How to read the map": "地図の見方",
|
||||
"Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁: 双方または独立した情報源が報道。破線の縁: 主張。細い縁: 単一の情報源、または一方の陣営のメディア。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい: 情報源が多い · 薄い: 古い。画像カード: 情報源が2件以上ある深刻な、または急拡大中の出来事。",
|
||||
"Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽ではありません。",
|
||||
"Colour: severity · icon: type of event": "色:深刻度 · アイコン:出来事の種類",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁:双方または独立した情報源が報道。破線の縁:主張。細い縁:単一の情報源、または一方の陣営のメディア。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい:情報源が多い · 薄い:古い。画像カード:情報源が2件以上ある深刻な、または急拡大中の出来事。",
|
||||
"Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽を示すものではありません。",
|
||||
"Exit cockpit to fly to a news report": "ニュースへ移動するにはコックピットを終了してください",
|
||||
"News items in the feed": "フィード内のニュース数",
|
||||
"Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。",
|
||||
@@ -479,7 +479,7 @@
|
||||
"Remove {title} from this app's saved keys": "{title}をこのアプリの保存済みキーから削除",
|
||||
"Project keys: {status}": "プロジェクトのキー:{status}",
|
||||
"your app configuration": "アプリの設定",
|
||||
"your local .env": "ローカルの .env",
|
||||
"your local .env": "ローカルの.env",
|
||||
"Saving…": "保存中…",
|
||||
"Save failed: {detail}": "保存に失敗しました:{detail}",
|
||||
"Paste at least one key first.": "まず、キーを1つ以上貼り付けてください。",
|
||||
@@ -801,7 +801,7 @@
|
||||
"loading...": "読込中…",
|
||||
"KEY REQUIRED": "キーが必要",
|
||||
"LIVE · updated {age}": "ライブ · 更新 {age}",
|
||||
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
|
||||
"24 h to {day} {time} UTC": "{day} {time} UTCまでの24時間",
|
||||
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
|
||||
"Live data unavailable": "ライブデータを利用できません",
|
||||
"Intel Picture": "情勢図",
|
||||
@@ -831,7 +831,7 @@
|
||||
"Low-accuracy score 2–10%": "低精度スコア 2–10%",
|
||||
"Low-accuracy score > 10%": "低精度スコア > 10%",
|
||||
"GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · 過去{minutes}分間に表示したビューのセル",
|
||||
"Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します",
|
||||
"Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。",
|
||||
@@ -889,18 +889,18 @@
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示",
|
||||
"Release the camera from {name}": "{name} からカメラを解放",
|
||||
"SITES STANDBY": "施設待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると施設ピングを表示",
|
||||
"Release the camera from {name}": "{name}からカメラを解放",
|
||||
"RETURN · {name}": "戻る · {name}",
|
||||
"PREVIOUS SATELLITE": "前の衛星",
|
||||
"Return to {name}": "{name} に戻る",
|
||||
"Return to {name}": "{name}に戻る",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "{name} に乗る",
|
||||
"Unnamed site": "名称のない地点",
|
||||
"Ride {name}": "{name}に乗る",
|
||||
"Unnamed site": "名称不明の施設",
|
||||
"OFF-NADIR {angle}°": "オフナディア {angle}°",
|
||||
"FOCUS · {name}": "フォーカス · {name}",
|
||||
"Keep the camera on {name}": "カメラを {name} に向け続ける",
|
||||
"Keep the camera on {name}": "カメラを{name}に向け続ける",
|
||||
"ZOOM {zoom}×": "ズーム {zoom}×",
|
||||
"SWATH {value} KM": "観測幅 {value} km",
|
||||
"SWATH ≈ {value} KM": "観測幅 ≈ {value} km",
|
||||
@@ -909,8 +909,8 @@
|
||||
"ORBITAL VELOCITY": "軌道速度",
|
||||
"SUB-SATELLITE POINT": "衛星直下点",
|
||||
"NORTH UP": "ノースアップ",
|
||||
"CURRENTLY OVER": "現在の上空",
|
||||
"{division}, {country}": "{division}、{country}",
|
||||
"CURRENTLY OVER": "現在の直下",
|
||||
"{division}, {country}": "{country}・{division}",
|
||||
"{region} · near {place}": "{region} · {place}付近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "元期 {time}",
|
||||
@@ -1702,7 +1702,7 @@
|
||||
"other": "カメラ {count}台を読込済み · CCTVを有効にして使用"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "この表示範囲外にさらに {count}台のカメラ · 移動またはズームアウト"
|
||||
"other": "表示範囲外にあと{count}台のカメラ · パンまたはズームアウト"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。",
|
||||
@@ -1740,8 +1740,8 @@
|
||||
"REGIONAL NEWS": "地域ニュース",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィード オフ · 部分的な状況",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィード オフ · 部分的な状況",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
|
||||
"CONTEXT STANDBY": "コンテキスト待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
|
||||
@@ -1876,7 +1876,7 @@
|
||||
"Earthquakes (24h)": "地震(24時間)",
|
||||
"Satellites": "衛星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "宇宙ミッション 30日",
|
||||
"Space Missions (30d)": "宇宙ミッション(30日)",
|
||||
"Street Traffic": "道路交通",
|
||||
"CCTV": "CCTV",
|
||||
"Radio": "ラジオ",
|
||||
@@ -2858,7 +2858,7 @@
|
||||
"Radio volume": "ラジオの音量",
|
||||
"STATION SITE": "放送局サイト",
|
||||
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声アシスタントに再生を指示すると、ブラウザーが放送局に直接接続します。お使いのIPアドレスは放送局側から見えます。",
|
||||
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
|
||||
"LOCAL RTL-SDR": "ローカルRTL-SDR",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
|
||||
@@ -3032,7 +3032,7 @@
|
||||
"Restricted": "飛行制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"AUTHORING": "シーン作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
"SCENE": "シーン",
|
||||
"Scene": "シーン",
|
||||
@@ -3047,7 +3047,7 @@
|
||||
"Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。",
|
||||
"Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生",
|
||||
"Stop playback (Esc)": "再生を停止(Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "選択中のショットの次を再生(必要に応じて次のシーンへ進む)",
|
||||
"SAVE & SHARE": "保存と共有",
|
||||
"Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード",
|
||||
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
|
||||
@@ -3078,7 +3078,7 @@
|
||||
"Drawn shapes": "描いた図形",
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Exit cockpit to fly to a drawing": "描いた図形へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
|
||||
"OSM Places": "OSMの場所",
|
||||
@@ -3212,7 +3212,7 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽を示すものではありません。",
|
||||
"Economy": "経済",
|
||||
"News from {area}": "{area}のニュース",
|
||||
"Within {km} km of the view centre": "表示中心から{km} km以内",
|
||||
@@ -3228,33 +3228,33 @@
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)",
|
||||
"Worldwide": "世界",
|
||||
"Shipwrecks": "難破船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以降",
|
||||
"Before 1900": "1900年より前",
|
||||
"After 1945": "1946年以降",
|
||||
"Date unknown": "日付不明",
|
||||
"Position: centre of the protected area": "位置: 保護区域の中心",
|
||||
"Position: centre of the protected area": "位置:保護区域の中心",
|
||||
"Position approximate (within about {km} km)": "おおよその位置(誤差約{km} km)",
|
||||
"Position surveyed (within about {m} m)": "測量された位置(誤差約{m} m)",
|
||||
"Vessel: {value}": "船種: {value}",
|
||||
"What happened: {value}": "経緯: {value}",
|
||||
"Depth: {m} m ({quality})": "水深: {m} m({quality})",
|
||||
"Depth: {m} m": "水深: {m} m",
|
||||
"Notes: {value}": "備考: {value}",
|
||||
"Cargo or finds: {value}": "積荷・遺物: {value}",
|
||||
"Vessel: {value}": "船種:{value}",
|
||||
"What happened: {value}": "経緯:{value}",
|
||||
"Depth: {m} m ({quality})": "水深:{m} m({quality})",
|
||||
"Depth: {m} m": "水深:{m} m",
|
||||
"Notes: {value}": "備考:{value}",
|
||||
"Cargo or finds: {value}": "積荷・遺物:{value}",
|
||||
"Protected wreck": "保護対象の難破船",
|
||||
"Source: {value}": "出典: {value}",
|
||||
"Source: {value}": "出典:{value}",
|
||||
"Click the card to open EMODnet ↗": "カードをクリックしてEMODnetを開く ↗",
|
||||
"Click the card to open the source ↗": "カードをクリックして出典を開く ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidataを利用できません。EMODnetのみ表示しています",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnetを利用できません。Wikidataのみ表示しています",
|
||||
"Shipwrecks unavailable": "難破船データを利用できません",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域)· Wikidata(世界)",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域) · Wikidata(世界)",
|
||||
"{count} wrecks": {
|
||||
"other": "難破船 {count}件"
|
||||
"other": "難破船 {count}隻"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "難破",
|
||||
"Fatalities: {count}": "死者: {count}",
|
||||
"Survivors: {count}": "生存者: {count}",
|
||||
"Shipwreck": "難破船",
|
||||
"Fatalities: {count}": "死者:{count}人",
|
||||
"Survivors: {count}": "生存者:{count}人",
|
||||
"Click the card to open the article ↗": "カードをクリックして記事を開く ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧州の歴史的な難破船はEMODnet Human Activities、世界の著名な難破船はWikidataによります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。",
|
||||
"All years": "全期間",
|
||||
@@ -3277,13 +3277,13 @@
|
||||
"Amphibians": "両生類",
|
||||
"Ray-finned fishes": "条鰭類",
|
||||
"Insects": "昆虫",
|
||||
"Arachnids": "クモ類",
|
||||
"Arachnids": "クモガタ類",
|
||||
"Plants": "植物",
|
||||
"Fungi": "菌類",
|
||||
"Other animals": "その他の動物",
|
||||
"Other or unknown": "その他・不明",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — クリックして再試行",
|
||||
"Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません",
|
||||
"{label} {detail} — click to retry": "{label} {detail}:クリックで再試行",
|
||||
"Wildlife sightings source unavailable": "野生生物の目撃情報のソースを利用できません",
|
||||
"Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示",
|
||||
"No recent sightings in view": "表示範囲に最近の目撃情報はありません",
|
||||
"Acartia feed unavailable": "Acartiaのフィードを利用できません",
|
||||
@@ -3301,10 +3301,10 @@
|
||||
"All rights reserved": "無断転載禁止",
|
||||
"Cetacean": "クジラ類",
|
||||
"Wildlife Sightings": "野生生物の目撃情報",
|
||||
"Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了",
|
||||
"Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了",
|
||||
"Exit cockpit to fly to a sighting": "目撃地点へ移動するにはコックピットを終了してください",
|
||||
"Exit cockpit to fly to a sighting area": "目撃範囲へ移動するにはコックピットを終了してください",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "拡大して地域の目撃情報を表示",
|
||||
"Zoom in for local sightings": "ズームインして地域の目撃情報を表示",
|
||||
"Loading photo...": "写真を読み込み中…",
|
||||
"Photo unavailable": "写真を表示できません",
|
||||
"Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています",
|
||||
@@ -3313,12 +3313,12 @@
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "表示範囲に{count}件の目撃情報",
|
||||
"Close sighting": "目撃情報を閉じる",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°のセルとしてのみ表示し、セイリッシュ海のAcartiaによる海生哺乳類の目撃情報は0.01°に丸めます。個体数調査ではありません。",
|
||||
"Open {name} on iNaturalist": "iNaturalistで{name}を開く",
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · licences per record": "iNaturalistの投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalistの投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"{days}d": "{days}日",
|
||||
"All markers": "すべてのマーカー",
|
||||
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||
@@ -3329,8 +3329,8 @@
|
||||
"Close camera preview": "カメラのプレビューを閉じる",
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
|
||||
"Press OPEN to load the photo": "「開く」を押して写真を読み込む",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画はQuébec 511から再生され、お使いのIPアドレスはQuébec 511側から見えます。",
|
||||
"Press OPEN to load the photo": "[開く]を押して写真を読み込む",
|
||||
"Captured {month}": "{month}撮影",
|
||||
"this spot": "この地点",
|
||||
"the view centre": "表示中心",
|
||||
@@ -3338,8 +3338,8 @@
|
||||
"Street level at {place}": "{place}のストリートレベル",
|
||||
"Switching map": "地図を切り替え中",
|
||||
"Opening imagery": "画像を開いています",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:[{open}]を押して読み込んでください",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source}は有効なキーがないため、このリンクのストリートレベル写真を開けません",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
|
||||
"STATIONS": "ステーション",
|
||||
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
|
||||
@@ -3353,16 +3353,16 @@
|
||||
"Space science, technology demonstration and other payloads": "宇宙科学、技術実証、その他のペイロード",
|
||||
"DEBRIS": "デブリ",
|
||||
"Rocket bodies, fragments and dead satellites": "ロケット機体、破片、運用を終えた衛星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類が古い可能性: SATCAT はキャッシュのコピーです",
|
||||
"Classes may lag: SATCAT unavailable": "分類が古い可能性: SATCAT を取得できません",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類が古い可能性:SATCATはキャッシュのコピーです",
|
||||
"Classes may lag: SATCAT unavailable": "分類が古い可能性:SATCATを取得できません",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCATは各物体の種別と運用状態を示します。最新のコピーがない間、分類はUCSデータベース、名称、CelesTrakのグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "センサー観測幅 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km",
|
||||
"SENSOR": "センサー",
|
||||
"Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム",
|
||||
"<1 H OLD": "1時間未満前",
|
||||
"<1 H OLD": "1時間以内",
|
||||
"{value} H OLD": "{value}時間前",
|
||||
"{value} D OLD": "{value}日前",
|
||||
"FWD": "前方",
|
||||
@@ -3371,8 +3371,8 @@
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "トラックアップ",
|
||||
"COURSE HOLD · AIS": "対地針路保持 · AIS",
|
||||
"Zoom in (+)": "ズームイン (+)",
|
||||
"Zoom out (−)": "ズームアウト (−)",
|
||||
"Zoom in (+)": "ズームイン(+)",
|
||||
"Zoom out (−)": "ズームアウト(−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
@@ -3383,15 +3383,32 @@
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒",
|
||||
"Rail": "鉄道",
|
||||
"Urban rail": "都市鉄道",
|
||||
"Local rail": "近郊列車",
|
||||
"Regional rail": "地域列車",
|
||||
"National rail": "長距離列車",
|
||||
"International rail": "国際列車",
|
||||
"Hide {category}": "{category}を非表示",
|
||||
"Show {category}": "{category}を表示",
|
||||
"Live GPS": "ライブGPS",
|
||||
"Estimated from station reports": "駅の記録から推定",
|
||||
"To {headsign}": "{headsign}行き",
|
||||
"Next {stop} {time} {relative}": "次の駅 {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "前の駅 {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "({minutes}分遅れ)",
|
||||
"Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください",
|
||||
"Photo © {photographer}": "写真 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています",
|
||||
"Photo: {photographer} · {licence}": "写真:{photographer} · {licence}",
|
||||
"Photo · {licence}": "写真 · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトの位置情報は現在届いていません。最後に確認された位置を表示しています",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトの位置情報は現在届いていません。{time}時点の位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機の位置情報は現在届いていません。最後に確認された位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機の位置情報は現在届いていません。{time}時点の位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶の位置情報は現在届いていません。最後に確認された位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶の位置情報は現在届いていません。{time}時点の位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星の位置情報は現在届いていません。最後に確認された位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星の位置情報は現在届いていません。{time}時点の位置を表示しています",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
@@ -3410,7 +3427,98 @@
|
||||
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
|
||||
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
|
||||
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})",
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター:{filters})",
|
||||
"tiles unavailable": "タイルを利用できません",
|
||||
"zoom in to load this layer": "ズームインしてこのレイヤーを読み込む",
|
||||
"Zoom to layer": "レイヤーにズーム",
|
||||
"Zoom to layer · fly to where this layer has data": "レイヤーにズーム · このレイヤーのデータがある場所へ移動",
|
||||
"Remove from Data Layers": "データレイヤーから削除",
|
||||
"Remove from Data Layers · add it again from the library": "データレイヤーから削除 · ライブラリから再度追加できます",
|
||||
"Coverage outline": "カバー範囲の輪郭",
|
||||
"DRAPED": "地形に投影",
|
||||
"Below 250 m": "250 m未満",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m以上",
|
||||
"Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください",
|
||||
"Library": "ライブラリ",
|
||||
"Environment": "環境",
|
||||
"Imagery": "画像",
|
||||
"Demographics": "人口統計",
|
||||
"DATASET": "データセット",
|
||||
"TILES": "タイル",
|
||||
"PACK": "パック",
|
||||
"INCUBATING": "試験中",
|
||||
"COMMUNITY": "コミュニティ",
|
||||
"VERIFIED": "検証済み",
|
||||
"ARCHIVED": "アーカイブ済み",
|
||||
"Datasets": "データセット",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count}件のデータセット"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count}件のタイルレイヤー"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count}件のパック"
|
||||
},
|
||||
"Themes": "テーマ",
|
||||
"Added": "追加済み",
|
||||
"{count} on the map": {
|
||||
"other": "地図上に{count}件"
|
||||
},
|
||||
"none yet": "まだありません",
|
||||
"Map features": "地図上の地物",
|
||||
"from {count} datasets": {
|
||||
"other": "{count}件のデータセットから"
|
||||
},
|
||||
"switch on a dataset to load features": "データセットをオンにすると地物を読み込みます",
|
||||
"Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTVレイヤーの一部",
|
||||
"Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで{env}=1を設定",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーのGEV_LIBRARY_ENABLEに{id}を追加",
|
||||
"Turned off by this instance's operator": "このインスタンスの運用者により無効化されています",
|
||||
"Needs a provider key on the server": "サーバーにプロバイダーのキーが必要です",
|
||||
"Needs this app's server · not available in a static build": "このアプリのサーバーが必要 · 静的ビルドでは利用できません",
|
||||
"Checking what this instance serves…": "このインスタンスの提供内容を確認中…",
|
||||
"Regional": "地域",
|
||||
"Orthophoto": "オルソ画像",
|
||||
"Satellite mosaic": "衛星モザイク",
|
||||
"Oblique": "斜め写真",
|
||||
"Historic imagery": "過去の画像",
|
||||
"True orthophoto": "トゥルーオルソ画像",
|
||||
"Type": "種類",
|
||||
"Resolution": "解像度",
|
||||
"Flown": "撮影",
|
||||
"Updates": "更新",
|
||||
"Ortho quality": "オルソ品質",
|
||||
"DATA LIBRARY": "データライブラリ",
|
||||
"Close data library": "データライブラリを閉じる",
|
||||
"Data Library at a glance": "データライブラリの概要",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "コアレイヤー以外のライブソースです。オンの間は各プロバイダーから取得され、このアプリには何も保存されません。追加すると、データレイヤーの「ライブラリ」に表示されます。",
|
||||
"Name, provider or country": "名前、プロバイダー、国",
|
||||
"Theme": "テーマ",
|
||||
"All themes": "すべてのテーマ",
|
||||
"Only what this instance serves": "このインスタンスが提供するもののみ",
|
||||
"OPT-IN": "要有効化",
|
||||
"KEY": "キー必須",
|
||||
"KEYLESS": "キー不要",
|
||||
"ZOOM": "ズーム",
|
||||
"Zoom to {name}": "{name}にズーム",
|
||||
"WORKING…": "処理中…",
|
||||
"ADD": "追加",
|
||||
"Remove {name} from Data Layers": "{name}をデータレイヤーから削除",
|
||||
"Add {name} to Data Layers": "{name}をデータレイヤーに追加",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "確認 · {count}件をすべて削除"
|
||||
},
|
||||
"REMOVE ALL": "すべて削除",
|
||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る",
|
||||
"The globe stopped drawing": "地球儀の描画が止まりました",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
|
||||
|
||||
+172
-52
@@ -3,12 +3,12 @@
|
||||
"Developing: {name}": "Em desenvolvimento: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} evento hoje",
|
||||
"many": "{count} eventos hoje",
|
||||
"many": "{count} de eventos hoje",
|
||||
"other": "{count} eventos hoje"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} notícia hoje",
|
||||
"many": "{count} notícias hoje",
|
||||
"many": "{count} de notícias hoje",
|
||||
"other": "{count} notícias hoje"
|
||||
},
|
||||
"Disputed: {name}": "Em disputa: {name}",
|
||||
@@ -17,9 +17,9 @@
|
||||
"Claimed by {who}": "Segundo {who}",
|
||||
"Claimed": "Alegação",
|
||||
"Not yet confirmed by others": "Ainda não confirmado por outros",
|
||||
"Reported by {side} sources": "Noticiado por fontes de {side}",
|
||||
"Reported by {side} sources": "Noticiado por fontes: {side}",
|
||||
"Reported by {sides}": "Noticiado por {sides}",
|
||||
"{side} sources": "fontes de {side}",
|
||||
"{side} sources": "fontes: {side}",
|
||||
"Official": "Oficial",
|
||||
"Reported by both sides": "Noticiado por ambos os lados",
|
||||
"Independently reported": "Noticiado de forma independente",
|
||||
@@ -27,7 +27,7 @@
|
||||
"International": "Internacional",
|
||||
"Independent": "Independente",
|
||||
"Unattributed": "Sem atribuição",
|
||||
"Outside party": "Parte externa",
|
||||
"Outside party": "Terceiros",
|
||||
"Ukraine–Russia": "Ucrânia–Rússia",
|
||||
"Israel–Gaza–Lebanon": "Israel–Gaza–Líbano",
|
||||
"Iran–Hormuz": "Irã–Ormuz",
|
||||
@@ -43,7 +43,7 @@
|
||||
"Humanitarian": "Humanitário",
|
||||
"Disaster": "Desastre",
|
||||
"Protest": "Protesto",
|
||||
"Political": "Político",
|
||||
"Political": "Política",
|
||||
"No physical event": "Sem evento físico",
|
||||
"Minor incident": "Incidente menor",
|
||||
"Significant": "Significativo",
|
||||
@@ -53,7 +53,7 @@
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} fonte",
|
||||
"many": "{count} fontes",
|
||||
"many": "{count} de fontes",
|
||||
"other": "{count} fontes"
|
||||
},
|
||||
"News feed not configured on this server": "Feed de notícias não configurado neste servidor",
|
||||
@@ -64,23 +64,23 @@
|
||||
"Not placed on the map": "Não localizado no mapa",
|
||||
"Close": "Fechar",
|
||||
"Any severity": "Qualquer gravidade",
|
||||
"{severity} and above": "{severity} ou mais",
|
||||
"{severity} and above": "{severity} ou superior",
|
||||
"{severity} only": "Apenas {severity}",
|
||||
"All categories": "Todas as categorias",
|
||||
"Reading the news…": "Lendo as notícias…",
|
||||
"News, last {hours} hours": "Notícias das últimas {hours} horas",
|
||||
"Last {hours} h": "Últimas {hours} h",
|
||||
"News, last {hours} hours": "Notícias de até {hours} h atrás",
|
||||
"Last {hours} h": "Até {hours} h atrás",
|
||||
"Time window": "Período",
|
||||
"Conflicts": "Conflitos",
|
||||
"Local": "Local",
|
||||
"News sections": "Secções de notícias",
|
||||
"Zoom in to see local news": "Aproxime para ver notícias locais",
|
||||
"News sections": "Seções de notícias",
|
||||
"Zoom in to see local news": "Aproxime o zoom para ver notícias locais",
|
||||
"Hotspots": "Focos",
|
||||
"Severity": "Gravidade",
|
||||
"Category": "Categoria",
|
||||
"Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h",
|
||||
"Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto desde {hours} h atrás",
|
||||
"Latest news": "Últimas notícias",
|
||||
"No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros",
|
||||
"No news matches these filters in the last {hours} h": "Nenhuma notícia de até {hours} h atrás corresponde a estes filtros",
|
||||
"Load older news": "Carregar notícias anteriores",
|
||||
"How to read the map": "Como ler o mapa",
|
||||
"Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento",
|
||||
@@ -857,7 +857,7 @@
|
||||
"Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%",
|
||||
"Low-accuracy score > 10%": "Índice de baixa precisão > 10%",
|
||||
"GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das áreas visualizadas nos últimos {minutes} min",
|
||||
"Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC",
|
||||
"Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.",
|
||||
@@ -926,14 +926,14 @@
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
"Return to {name}": "Voltar para {name}",
|
||||
"SATELLITE": "SATÉLITE",
|
||||
"Ride {name}": "Acompanhar {name}",
|
||||
"Ride {name}": "Ir a bordo de {name}",
|
||||
"Unnamed site": "Local sem nome",
|
||||
"OFF-NADIR {angle}°": "FORA DO NADIR {angle}°",
|
||||
"FOCUS · {name}": "FOCO · {name}",
|
||||
"Keep the camera on {name}": "Manter a câmera em {name}",
|
||||
"ZOOM {zoom}×": "ZOOM {zoom}×",
|
||||
"SWATH {value} KM": "FAIXA IMAGEADA {value} KM",
|
||||
"SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM",
|
||||
"SWATH {value} KM": "FAIXA {value} KM",
|
||||
"SWATH ≈ {value} KM": "FAIXA ≈ {value} KM",
|
||||
"Orbital view": "Vista orbital",
|
||||
"ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR",
|
||||
"ORBITAL VELOCITY": "VELOCIDADE ORBITAL",
|
||||
@@ -1234,7 +1234,7 @@
|
||||
"TomTom key unavailable": "Chave do TomTom indisponível",
|
||||
"TomTom daily budget reached": "Limite diário do TomTom atingido",
|
||||
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
|
||||
"Traffic rate limit reached": "Limite de solicitações de trânsito atingido",
|
||||
"Traffic rate limit reached": "Trânsito: limite de requisições atingido",
|
||||
"Traffic service unavailable": "Serviço de trânsito indisponível",
|
||||
"TomTom upstream unreachable": "Servidor do TomTom inacessível",
|
||||
"TomTom flow unavailable": "Fluxo do TomTom indisponível",
|
||||
@@ -1754,9 +1754,9 @@
|
||||
"other": "{count} câmeras carregadas · ative o CCTV para usar"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom",
|
||||
"many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom",
|
||||
"other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom"
|
||||
"one": "Mais {count} câmera fora desta vista · mova o mapa ou afaste o zoom",
|
||||
"many": "Mais {count} de câmeras fora desta vista · mova o mapa ou afaste o zoom",
|
||||
"other": "Mais {count} câmeras fora desta vista · mova o mapa ou afaste o zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.",
|
||||
@@ -1796,7 +1796,7 @@
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "LISTA DE AERONAVES PRÓXIMAS INCOMPLETA · NÃO GARANTE SEGURANÇA",
|
||||
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
|
||||
@@ -2848,7 +2848,7 @@
|
||||
"DOWN {angle}°": "ABAIXO {angle}°",
|
||||
"LEVEL": "NIVELADO",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "VISTA {elevation}",
|
||||
"LOOK {elevation}": "VISTA · {elevation}",
|
||||
"Estimated destination direction": "Direção estimada do destino",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE",
|
||||
@@ -3012,7 +3012,7 @@
|
||||
"Radio volume": "Volume do rádio",
|
||||
"STATION SITE": "SITE DA ESTAÇÃO",
|
||||
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.",
|
||||
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio se conecta diretamente à emissora quando você pressiona OUVIR, escolhe uma estação no globo ou pede por voz para tocar. Seu IP fica visível para essa emissora.",
|
||||
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
|
||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
|
||||
@@ -3372,13 +3372,13 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia leva ao seu editor; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia tem um link para o veículo que a publicou; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.",
|
||||
"Economy": "Economia",
|
||||
"News from {area}": "Notícias: {area}",
|
||||
"Within {km} km of the view centre": "Até {km} km do centro da vista",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "via {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local na camada de notícias: as notícias do dia dos veículos do país em vista",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "\"Local\" na camada de notícias: as notícias do dia dos veículos de imprensa do país na vista",
|
||||
"{provider} refused its key": "{provider} recusou a chave",
|
||||
"Check {envVars} in Provider Settings": "Verifique {envVars} em \"Configurações de provedores\"",
|
||||
"{provider} key required": "Chave do {provider} necessária",
|
||||
@@ -3402,8 +3402,8 @@
|
||||
"Cargo or finds: {value}": "Carga ou achados: {value}",
|
||||
"Protected wreck": "Naufrágio protegido",
|
||||
"Source: {value}": "Fonte: {value}",
|
||||
"Click the card to open EMODnet ↗": "Clique na ficha para abrir o EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Clique na ficha para abrir a fonte ↗",
|
||||
"Click the card to open EMODnet ↗": "Clique no cartão para abrir o EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Clique no cartão para abrir a fonte ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponível; mostrando só o EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponível; mostrando só o Wikidata",
|
||||
"Shipwrecks unavailable": "Naufrágios indisponíveis",
|
||||
@@ -3417,8 +3417,8 @@
|
||||
"Shipwreck": "Naufrágio",
|
||||
"Fatalities: {count}": "Mortos: {count}",
|
||||
"Survivors: {count}": "Sobreviventes: {count}",
|
||||
"Click the card to open the article ↗": "Clique na ficha para abrir o artigo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique num marcador para ver a ficha.",
|
||||
"Click the card to open the article ↗": "Clique no cartão para abrir o artigo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique em um marcador para ver o cartão.",
|
||||
"All years": "Todos os anos",
|
||||
"Period": "Período",
|
||||
"From": "Início",
|
||||
@@ -3446,7 +3446,7 @@
|
||||
"Other or unknown": "Outros ou desconhecidos",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente",
|
||||
"Wildlife sightings source unavailable": "Fonte de avistamentos indisponível",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)",
|
||||
"Show only community-confirmed (research-grade) sightings": "Mostrar apenas avistamentos confirmados pela comunidade (grau de pesquisa)",
|
||||
"No recent sightings in view": "Nenhum avistamento recente na vista",
|
||||
"Acartia feed unavailable": "Feed Acartia indisponível",
|
||||
"Show all taxa": "Mostrar todos os grupos taxonômicos",
|
||||
@@ -3466,28 +3466,28 @@
|
||||
"Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento",
|
||||
"Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento",
|
||||
"Wildlife": "Vida selvagem",
|
||||
"Zoom in for local sightings": "Amplie para ver avistamentos locais",
|
||||
"Zoom in for local sightings": "Aproxime o zoom para ver avistamentos locais",
|
||||
"Loading photo...": "Carregando foto…",
|
||||
"Photo unavailable": "Foto indisponível",
|
||||
"Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°",
|
||||
"Photo of {name}": "Foto de {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} avistamentos na vista",
|
||||
"{count} sightings in view": "Avistamentos na vista: {count}",
|
||||
"Close sighting": "Fechar avistamento",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Vida selvagem observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos do mar de Salish via Acartia são arredondados para 0,01°. Não é um censo.",
|
||||
"Open {name} on iNaturalist": "Abrir {name} no iNaturalist",
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
|
||||
"{days}d": "{days}d",
|
||||
"{days}d": "{days} d",
|
||||
"All markers": "Todos os marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} d; marcadores sem data são mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} d · sem data mantidos",
|
||||
"Close camera preview": "Fechar prévia da câmera",
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
@@ -3496,16 +3496,16 @@
|
||||
"Captured {month}": "Capturada em {month}",
|
||||
"this spot": "este ponto",
|
||||
"the view centre": "o centro da vista",
|
||||
"{source} at {place}": "{source} em {place}",
|
||||
"Street level at {place}": "Nível da rua em {place}",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Nível da rua: {place}",
|
||||
"Switching map": "Mudando de mapa",
|
||||
"Opening imagery": "Abrindo imagens",
|
||||
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto no nível da rua deste link sem uma chave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos no nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"STATIONS": "ESTAÇÕES",
|
||||
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
|
||||
"IMAGING": "IMAGENS",
|
||||
"IMAGING": "IMAGEAMENTO",
|
||||
"Earth observation: optical, radar, weather and science": "Observação da Terra: óptica, radar, meteorologia e ciência",
|
||||
"TELECOM": "TELECOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satélites de comunicações e navegação, exceto Starlink",
|
||||
@@ -3517,7 +3517,7 @@
|
||||
"Rocket bodies, fragments and dead satellites": "Corpos de foguetes, fragmentos e satélites inativos",
|
||||
"Classes may lag: SATCAT is a cached copy": "As classes podem estar desatualizadas: SATCAT é uma cópia em cache",
|
||||
"Classes may lag: SATCAT unavailable": "As classes podem estar desatualizadas: SATCAT indisponível",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT do CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos do CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM",
|
||||
@@ -3543,26 +3543,43 @@
|
||||
"1 d/s": "1 d/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou use o gesto de pinça para dar zoom, arraste para percorrer · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Trens",
|
||||
"Urban rail": "Urbano",
|
||||
"Local rail": "Suburbano",
|
||||
"Regional rail": "Regional",
|
||||
"National rail": "Longo curso",
|
||||
"International rail": "Internacional",
|
||||
"Hide {category}": "Ocultar {category}",
|
||||
"Show {category}": "Mostrar {category}",
|
||||
"Live GPS": "GPS ao vivo",
|
||||
"Estimated from station reports": "Posição estimada pelos registros das estações",
|
||||
"To {headsign}": "Para {headsign}",
|
||||
"Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min de atraso)",
|
||||
"Exit cockpit to fly to a train": "Saia da cabine para voar até um trem",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Foto · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°",
|
||||
"Street Level is unavailable": "Nível da rua não está disponível",
|
||||
"Street Level is unavailable": "A camada \"Nível da rua\" não está disponível",
|
||||
"No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada",
|
||||
"Street Level could not be enabled": "Não foi possível ativar Nível da rua",
|
||||
"Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens",
|
||||
"Street Level could not be enabled": "Não foi possível ativar a camada \"Nível da rua\"",
|
||||
"Street Level changed while it looked for imagery": "A camada \"Nível da rua\" mudou enquanto procurava imagens",
|
||||
"Aim the view at a street": "Aponte a vista para uma rua",
|
||||
"{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui",
|
||||
"{source} needs its key": "{source} precisa da sua chave",
|
||||
@@ -3573,6 +3590,109 @@
|
||||
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
|
||||
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})",
|
||||
"tiles unavailable": "blocos indisponíveis",
|
||||
"zoom in to load this layer": "aproxime o zoom para carregar esta camada",
|
||||
"Zoom to layer": "Ir para a camada",
|
||||
"Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados",
|
||||
"Remove from Data Layers": "Remover de \"Camadas de dados\"",
|
||||
"Remove from Data Layers · add it again from the library": "Remover de \"Camadas de dados\" · adicione de novo pela biblioteca",
|
||||
"Coverage outline": "Contorno de cobertura",
|
||||
"DRAPED": "SOBREPOSTO",
|
||||
"Below 250 m": "Abaixo de 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1.000 m",
|
||||
"1,000–2,000 m": "1.000–2.000 m",
|
||||
"2,000–3,000 m": "2.000–3.000 m",
|
||||
"3,000 m and above": "3.000 m ou mais",
|
||||
"Exit cockpit to fly to a layer": "Saia da cabine para voar até uma camada",
|
||||
"Library": "Biblioteca",
|
||||
"Environment": "Meio ambiente",
|
||||
"Imagery": "Imagens",
|
||||
"Demographics": "Demografia",
|
||||
"DATASET": "DADOS",
|
||||
"TILES": "BLOCOS",
|
||||
"PACK": "PACOTE",
|
||||
"INCUBATING": "EM INCUBAÇÃO",
|
||||
"COMMUNITY": "COMUNIDADE",
|
||||
"VERIFIED": "VERIFICADO",
|
||||
"ARCHIVED": "ARQUIVADO",
|
||||
"Datasets": "Conjuntos de dados",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} conjunto de dados",
|
||||
"many": "{count} de conjuntos de dados",
|
||||
"other": "{count} conjuntos de dados"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} camada de blocos",
|
||||
"many": "{count} de camadas de blocos",
|
||||
"other": "{count} camadas de blocos"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} pacote",
|
||||
"many": "{count} de pacotes",
|
||||
"other": "{count} pacotes"
|
||||
},
|
||||
"Themes": "Temas",
|
||||
"Added": "Adicionadas",
|
||||
"{count} on the map": {
|
||||
"one": "{count} no mapa",
|
||||
"many": "{count} no mapa",
|
||||
"other": "{count} no mapa"
|
||||
},
|
||||
"none yet": "nenhuma ainda",
|
||||
"Map features": "Elementos do mapa",
|
||||
"from {count} datasets": {
|
||||
"one": "de {count} conjunto de dados",
|
||||
"many": "de {count} de conjuntos de dados",
|
||||
"other": "de {count} conjuntos de dados"
|
||||
},
|
||||
"switch on a dataset to load features": "ative um conjunto de dados para carregar elementos",
|
||||
"Switched on by this instance · part of the CCTV layer": "Ativado nesta instância · parte da camada CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Ativação pelo operador: defina {env}=1 no servidor",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Ativação pelo operador: adicione {id} a GEV_LIBRARY_ENABLE no servidor",
|
||||
"Turned off by this instance's operator": "Desativado pelo operador desta instância",
|
||||
"Needs a provider key on the server": "Requer uma chave do provedor no servidor",
|
||||
"Needs this app's server · not available in a static build": "Requer o servidor deste app · indisponível em uma build estática",
|
||||
"Checking what this instance serves…": "Verificando o que esta instância oferece…",
|
||||
"Regional": "Regional",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Mosaico de satélite",
|
||||
"Oblique": "Oblíqua",
|
||||
"Historic imagery": "Imagens históricas",
|
||||
"True orthophoto": "Ortofoto verdadeira",
|
||||
"Type": "Tipo",
|
||||
"Resolution": "Resolução",
|
||||
"Flown": "Voo",
|
||||
"Updates": "Atualização",
|
||||
"Ortho quality": "Qualidade da ortofoto",
|
||||
"DATA LIBRARY": "BIBLIOTECA DE DADOS",
|
||||
"Close data library": "Fechar a biblioteca de dados",
|
||||
"Data Library at a glance": "A biblioteca de dados em resumo",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em \"Camadas de dados\", em \"Biblioteca\".",
|
||||
"Name, provider or country": "Nome, provedor ou país",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Todos os temas",
|
||||
"Only what this instance serves": "Só o que esta instância oferece",
|
||||
"OPT-IN": "ATIV. PELO OPERADOR",
|
||||
"KEY": "CHAVE",
|
||||
"KEYLESS": "SEM CHAVE",
|
||||
"ZOOM": "APROXIMAR",
|
||||
"Zoom to {name}": "Ir para {name}",
|
||||
"WORKING…": "PROCESSANDO…",
|
||||
"ADD": "ADICIONAR",
|
||||
"Remove {name} from Data Layers": "Remover {name} de \"Camadas de dados\"",
|
||||
"Add {name} to Data Layers": "Adicionar {name} a \"Camadas de dados\"",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "CONFIRMAR · REMOVER {count}",
|
||||
"many": "CONFIRMAR · REMOVER TODAS AS {count}",
|
||||
"other": "CONFIRMAR · REMOVER TODAS AS {count}"
|
||||
},
|
||||
"REMOVE ALL": "REMOVER TUDO",
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
|
||||
"The globe stopped drawing": "O globo parou de desenhar",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
|
||||
|
||||
+207
-81
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"News feed unavailable": "Лента новостей недоступна",
|
||||
"Developing: {name}": "Развивается: {name}",
|
||||
"Developing: {name}": "Развитие событий: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} событие сегодня",
|
||||
"few": "{count} события сегодня",
|
||||
@@ -19,12 +19,12 @@
|
||||
"Claimed by {who}": "Заявление: {who}",
|
||||
"Claimed": "Заявление",
|
||||
"Not yet confirmed by others": "Пока не подтверждено другими",
|
||||
"Reported by {side} sources": "Сообщают источники {side}",
|
||||
"Reported by {sides}": "Сообщают: {sides}",
|
||||
"{side} sources": "источники {side}",
|
||||
"Reported by {side} sources": "Сообщают источники: {side}",
|
||||
"Reported by {sides}": "Сообщают источники: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Официально",
|
||||
"Reported by both sides": "Сообщают обе стороны",
|
||||
"Independently reported": "Подтверждено независимыми источниками",
|
||||
"Independently reported": "Сообщают независимые источники",
|
||||
"Single source": "Один источник",
|
||||
"International": "Международные",
|
||||
"Independent": "Независимые",
|
||||
@@ -42,7 +42,7 @@
|
||||
"Air defence": "ПВО",
|
||||
"Ground fighting": "Наземные бои",
|
||||
"Casualties": "Жертвы",
|
||||
"Humanitarian": "Гуманитарное",
|
||||
"Humanitarian": "Гуманитарная ситуация",
|
||||
"Disaster": "Бедствие",
|
||||
"Protest": "Протест",
|
||||
"Political": "Политика",
|
||||
@@ -50,7 +50,7 @@
|
||||
"Minor incident": "Незначительный инцидент",
|
||||
"Significant": "Значительный",
|
||||
"Major": "Крупный",
|
||||
"age|now": "сейчас",
|
||||
"age|now": "только что",
|
||||
"{minutes} min": "{minutes} мин",
|
||||
"{hours} h": "{hours} ч",
|
||||
"{count} sources": {
|
||||
@@ -60,7 +60,7 @@
|
||||
"other": "{count} источника"
|
||||
},
|
||||
"News feed not configured on this server": "Лента новостей не настроена на этом сервере",
|
||||
"Malformed news hotspots response": "Некорректный ответ горячих точек новостей",
|
||||
"Malformed news hotspots response": "Некорректный ответ с горячими точками новостей",
|
||||
"Malformed news feed response": "Некорректный ответ ленты новостей",
|
||||
"{place} (placed by model)": "{place} (место определила модель)",
|
||||
"English headline by the model": "Английский заголовок от модели",
|
||||
@@ -68,7 +68,7 @@
|
||||
"Close": "Закрыть",
|
||||
"Any severity": "Любая тяжесть",
|
||||
"{severity} and above": "{severity} и выше",
|
||||
"{severity} only": "Только {severity}",
|
||||
"{severity} only": "Только: {severity}",
|
||||
"All categories": "Все категории",
|
||||
"Reading the news…": "Читаем новости…",
|
||||
"News, last {hours} hours": "Новости за последние {hours} ч",
|
||||
@@ -87,7 +87,7 @@
|
||||
"Load older news": "Загрузить более ранние новости",
|
||||
"How to read the map": "Как читать карту",
|
||||
"Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной обод: сообщают обе стороны или независимые источники. Пунктирный обод: заявление. Тонкий обод: один источник или пресса одной стороны.",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной контур: сообщают обе стороны или независимые источники. Пунктирный контур: заявление. Тонкий контур: один источник или пресса одной стороны.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Крупнее: больше источников · бледнее: старше. Карточка с фото: крупное или быстро растущее событие от двух и более источников.",
|
||||
"Side labels describe the source, not whether a report is true.": "Метки сторон описывают источник, а не достоверность сообщения.",
|
||||
"Exit cockpit to fly to a news report": "Выйдите из кабины, чтобы перелететь к сообщению",
|
||||
@@ -268,7 +268,7 @@
|
||||
"PAUSED · {title}": "ПАУЗА · {title}",
|
||||
"UNSPECIFIED": "НЕ УКАЗАНО",
|
||||
"UNAVAILABLE": "НЕДОСТУПНО",
|
||||
"+{count} additional payload records": "+{count} доп. записи о полезной нагрузке",
|
||||
"+{count} additional payload records": "Ещё записей о полезной нагрузке: {count}",
|
||||
"PAYLOAD DATA UNAVAILABLE": "ДАННЫЕ ПОЛЕЗНОЙ НАГРУЗКИ НЕДОСТУПНЫ",
|
||||
"{value} KM DOWNRANGE": "{value} КМ ПО ТРАССЕ",
|
||||
"POSITION UNAVAILABLE": "ПОЗИЦИЯ НЕДОСТУПНА",
|
||||
@@ -285,8 +285,8 @@
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ",
|
||||
"Release the camera from {name}": "Освободить камеру от {name}",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ «ОБЪЕКТЫ НА КАРТЕ» ДЛЯ ПИНГОВ ОБЪЕКТОВ",
|
||||
"Release the camera from {name}": "Отпустить камеру с объекта: {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК",
|
||||
"Return to {name}": "Вернуться к {name}",
|
||||
@@ -295,8 +295,8 @@
|
||||
"Unnamed site": "Безымянный объект",
|
||||
"OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Удерживать камеру на {name}",
|
||||
"ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×",
|
||||
"Keep the camera on {name}": "Удерживать камеру на объекте: {name}",
|
||||
"ZOOM {zoom}×": "МАСШТАБ {zoom}×",
|
||||
"SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ",
|
||||
"SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ",
|
||||
"Orbital view": "Орбитальный вид",
|
||||
@@ -304,7 +304,7 @@
|
||||
"ORBITAL VELOCITY": "ОРБИТАЛЬНАЯ СКОРОСТЬ",
|
||||
"SUB-SATELLITE POINT": "ПОДСПУТНИКОВАЯ ТОЧКА",
|
||||
"NORTH UP": "СЕВЕР ВВЕРХУ",
|
||||
"CURRENTLY OVER": "СЕЙЧАС НАД",
|
||||
"CURRENTLY OVER": "ПОД СПУТНИКОМ",
|
||||
"{division}, {country}": "{division}, {country}",
|
||||
"{region} · near {place}": "{region} · поблизости: {place}",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
@@ -346,7 +346,7 @@
|
||||
"Cancel replay": "Отменить повтор",
|
||||
"EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ",
|
||||
"CONFIRMED": "ПОДТВЕРЖДЕНО",
|
||||
"PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS",
|
||||
"PAD / RTLS": "ПЛОЩАДКА / RTLS",
|
||||
"PLANNED · ": "ПЛАН · ",
|
||||
"SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ",
|
||||
"RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА",
|
||||
@@ -376,7 +376,7 @@
|
||||
"Starlink {detail} — click to retry": "Starlink: {detail} — нажмите, чтобы повторить",
|
||||
"load failed": "ошибка загрузки",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Показана полная оболочка Starlink — нажмите, чтобы оставить только основной каталог",
|
||||
"DENSE": "ПЛОТНЫЙ",
|
||||
"DENSE": "ПЛОТНО",
|
||||
"Syncing flow · Roads: {source}": "Синхронизация потока · Дороги: {source}",
|
||||
"LIVE · Roads: {source} · Roads without flow hidden": "ОНЛАЙН · Дороги: {source} · Дороги без потока скрыты",
|
||||
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
|
||||
@@ -469,10 +469,10 @@
|
||||
"other": "Загружено {count} камеры · включите CCTV, чтобы активировать"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите",
|
||||
"few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите",
|
||||
"many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите",
|
||||
"other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите"
|
||||
"one": "Ещё {count} камера вне поля зрения · сдвиньте или отдалите карту",
|
||||
"few": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту",
|
||||
"many": "Ещё {count} камер вне поля зрения · сдвиньте или отдалите карту",
|
||||
"other": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
|
||||
@@ -545,7 +545,7 @@
|
||||
"ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ",
|
||||
"CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ",
|
||||
"{range} NM": "{range} МОР. МИЛЬ",
|
||||
"{range} NM": "{range} МОР. МИЛ.",
|
||||
"AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ",
|
||||
"DEST {bearing}": "НАЗН. {bearing}",
|
||||
"Disable cockpit weather effects": "Выключить погодные эффекты кабины",
|
||||
@@ -742,7 +742,7 @@
|
||||
"ACQUIRING": "ЗАХВАТ",
|
||||
"SHARED {subject}": "ПО ССЫЛКЕ: {subject}",
|
||||
"entity": "объект",
|
||||
"Shared {subject} follow expired": "Срок отслеживания {subject} по ссылке истёк",
|
||||
"Shared {subject} follow expired": "Срок отслеживания по ссылке истёк: {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Не удалось восстановить {subject} по ссылке — поток данных недоступен",
|
||||
"Shared {subject} is unavailable": "{subject} по ссылке недоступен",
|
||||
"LOADING LIVE DATA": "ЗАГРУЗКА ОНЛАЙН-ДАННЫХ",
|
||||
@@ -804,8 +804,8 @@
|
||||
"Live signals": "Сигналы в реальном времени",
|
||||
"Latest regional news": "Последние региональные новости",
|
||||
"Location-based information": "Информация по текущему месту",
|
||||
"LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°",
|
||||
"LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°",
|
||||
"LOOK STBD {bearing}°": "ВЗГЛЯД НА ПР. БОРТ {bearing}°",
|
||||
"LOOK PORT {bearing}°": "ВЗГЛЯД НА ЛЕВ. БОРТ {bearing}°",
|
||||
"LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ",
|
||||
"UP {angle}°": "ВВЕРХ {angle}°",
|
||||
"DOWN {angle}°": "ВНИЗ {angle}°",
|
||||
@@ -823,7 +823,7 @@
|
||||
"LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН",
|
||||
"GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ",
|
||||
"SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ",
|
||||
"COURSE OVER GROUND": "КУРС ПО ГРУНТУ",
|
||||
"COURSE OVER GROUND": "ПУТЕВОЙ УГОЛ",
|
||||
"ALTITUDE": "ВЫСОТА",
|
||||
"CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ",
|
||||
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
@@ -842,14 +842,14 @@
|
||||
"EXIT BRIDGE": "ВЫЙТИ С МОСТИКА",
|
||||
"EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ",
|
||||
"CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ",
|
||||
"RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ",
|
||||
"RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛ.",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} КОНТАКТ",
|
||||
"few": "{count} КОНТАКТА",
|
||||
"many": "{count} КОНТАКТОВ",
|
||||
"other": "{count} КОНТАКТА"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.",
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальности радара {range} — выберите, чтобы перейти к следующей шкале.",
|
||||
"Bridge radar": "Радар ходового мостика",
|
||||
"Expand Visual Presets": "Развернуть визуальные пресеты",
|
||||
"Pin visual presets": "Закрепить визуальные пресеты",
|
||||
@@ -970,7 +970,7 @@
|
||||
"DEL": "УДАЛ.",
|
||||
"UPDATE SHOT": "ОБНОВИТЬ КАДР",
|
||||
"IMPORT": "ИМПОРТ",
|
||||
"RUN LOG": "ЖУРНАЛ ЗАПУСКОВ",
|
||||
"RUN LOG": "ЖУРНАЛ ТАЙМИНГОВ",
|
||||
"CALIBRATION": "КАЛИБРОВКА",
|
||||
"Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести",
|
||||
"SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ",
|
||||
@@ -2131,9 +2131,9 @@
|
||||
"N/A": "Н/Д",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ПОТОК ВОЕННЫХ РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖАЙШИЕ САМОЛЁТЫ: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"{source} · LAST {hours} H": "{source} · ЗА {hours} Ч",
|
||||
"ft": "фт",
|
||||
"kts": "уз",
|
||||
@@ -2416,8 +2416,8 @@
|
||||
"Import": "Импорт",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX",
|
||||
"Imported files": "Импортированные файлы",
|
||||
"DOCKED · {name}": "СТЫКОВКА · {name}",
|
||||
"DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}",
|
||||
"DOCKED · {name}": "ПРИСТЫКОВАН · {name}",
|
||||
"DOCKED · {name} · +{extra}": "ПРИСТЫКОВАНЫ · {name} · +{extra}",
|
||||
"loading coverage...": "загрузка покрытия...",
|
||||
"live feed unavailable": "поток в реальном времени недоступен",
|
||||
"GBFS fetch error": "ошибка загрузки GBFS",
|
||||
@@ -2936,7 +2936,7 @@
|
||||
"Hybrid": "Гибрид",
|
||||
"Lines": "Линии",
|
||||
"Dots": "Точки",
|
||||
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
|
||||
"Road lines colored by live flow: green to red": "Линии дорог, окрашенные по потоку онлайн: от зелёного к красному",
|
||||
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
|
||||
"Moving dots": "Движущиеся точки",
|
||||
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
|
||||
@@ -3183,11 +3183,11 @@
|
||||
"Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.",
|
||||
"Gemini Live voice control": "Голосовое управление через Gemini Live",
|
||||
"Voice provider": "Поставщик голосового сервиса",
|
||||
"Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.",
|
||||
"Voice provider": "Голосовой провайдер",
|
||||
"Changing provider reconnects voice if it is on.": "Смена провайдера переподключает голосовой режим, если он включён.",
|
||||
"Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.",
|
||||
"Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение",
|
||||
"Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков",
|
||||
"Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в «Настройках провайдеров»",
|
||||
"The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live",
|
||||
"Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.",
|
||||
"Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.",
|
||||
@@ -3215,7 +3215,7 @@
|
||||
"S2 · {name}": "S2 · {name}",
|
||||
"Report": "Сводка",
|
||||
"Open the source for {title}": "Открыть источник: {title}",
|
||||
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с",
|
||||
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "ОНЛАЙН · GitHub {commit} · {message} · проверка каждые {seconds} с",
|
||||
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени",
|
||||
"Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…",
|
||||
"{count} placemarks": {
|
||||
@@ -3292,7 +3292,7 @@
|
||||
"Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.",
|
||||
"scene|PLAY": "ПУСК",
|
||||
"Stop playback (Esc)": "Остановить воспроизведение (Esc)",
|
||||
"SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ",
|
||||
"SAVE & SHARE": "СОХРАНЕНИЕ И ОБМЕН",
|
||||
"Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта",
|
||||
"Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON",
|
||||
"Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения",
|
||||
@@ -3303,7 +3303,7 @@
|
||||
"Left panels": "Левые панели",
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к фигуре",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
|
||||
"OSM Places": "Места OSM",
|
||||
@@ -3458,14 +3458,14 @@
|
||||
"Within {km} km of the view centre": "В радиусе {km} км от центра карты",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "через {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Местное в слое новостей: новости дня от СМИ страны в кадре",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Раздел «Местные» в слое «Новости»: новости дня от СМИ страны в кадре",
|
||||
"{provider} refused its key": "{provider} отклонил ключ",
|
||||
"Check {envVars} in Provider Settings": "Проверьте {envVars} в «Настройках провайдеров»",
|
||||
"{provider} key required": "Нужен ключ {provider}",
|
||||
"{provider} limit reached for today": "{provider}: дневной лимит исчерпан",
|
||||
"News resumes tomorrow, or with a paid plan": "Новости вернутся завтра или с платным тарифом",
|
||||
"News key required": "Нужен ключ новостей",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точные координаты конфликтов в слое «Новости», в дополнение к статьям GDELT",
|
||||
"Worldwide": "Весь мир",
|
||||
"Shipwrecks": "Кораблекрушения",
|
||||
"Before 1900": "До 1900",
|
||||
@@ -3480,7 +3480,7 @@
|
||||
"Depth: {m} m": "Глубина: {m} м",
|
||||
"Notes: {value}": "Примечания: {value}",
|
||||
"Cargo or finds: {value}": "Груз или находки: {value}",
|
||||
"Protected wreck": "Охраняемый объект",
|
||||
"Protected wreck": "Затонувшее судно под охраной",
|
||||
"Source: {value}": "Источник: {value}",
|
||||
"Click the card to open EMODnet ↗": "Нажмите на карточку, чтобы открыть EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Нажмите на карточку, чтобы открыть источник ↗",
|
||||
@@ -3511,9 +3511,9 @@
|
||||
"seen {age} ago": "замечено {age} назад",
|
||||
"observed {age} ago": "наблюдалось {age} назад",
|
||||
"Be Whale Wise": "Наблюдайте китов бережно",
|
||||
"Reported by {name} ({entity})": "Сообщил: {name} ({entity})",
|
||||
"Reported by {name}": "Сообщил: {name}",
|
||||
"Salish Sea": "Селишское море",
|
||||
"Reported by {name} ({entity})": "Источник: {name} ({entity})",
|
||||
"Reported by {name}": "Источник: {name}",
|
||||
"Salish Sea": "Море Селиш",
|
||||
"Birds": "Птицы",
|
||||
"Mammals": "Млекопитающие",
|
||||
"Reptiles": "Пресмыкающиеся",
|
||||
@@ -3528,8 +3528,8 @@
|
||||
"{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить",
|
||||
"Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня",
|
||||
"No recent sightings in view": "Нет недавних наблюдений в области обзора",
|
||||
"Acartia feed unavailable": "Лента Acartia недоступна",
|
||||
"No recent sightings in view": "В кадре нет недавних наблюдений",
|
||||
"Acartia feed unavailable": "Поток Acartia недоступен",
|
||||
"Show all taxa": "Показать все группы организмов",
|
||||
"BIRDS": "ПТИЦЫ",
|
||||
"Show only birds": "Показать только птиц",
|
||||
@@ -3544,31 +3544,31 @@
|
||||
"All rights reserved": "Все права защищены",
|
||||
"Cetacean": "Китообразное",
|
||||
"Wildlife Sightings": "Наблюдения живой природы",
|
||||
"Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению",
|
||||
"Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения",
|
||||
"Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перелететь к наблюдению",
|
||||
"Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перелететь к области наблюдения",
|
||||
"Wildlife": "Живая природа",
|
||||
"Zoom in for local sightings": "Приблизьте для местных наблюдений",
|
||||
"Zoom in for local sightings": "Приблизьте карту, чтобы увидеть местные наблюдения",
|
||||
"Loading photo...": "Загрузка фото…",
|
||||
"Photo unavailable": "Фото недоступно",
|
||||
"Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Photo of {name}": "Фото: {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} наблюдений в области просмотра",
|
||||
"{count} sightings in view": "Наблюдений в кадре: {count}",
|
||||
"Close sighting": "Закрыть наблюдение",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения живой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Это не учёт численности.",
|
||||
"Open {name} on iNaturalist": "Открыть {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days}д",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Селиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days} дн.",
|
||||
"All markers": "Все метки",
|
||||
"Show markers of every age": "Показать метки любого возраста",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты остаются",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты остаются",
|
||||
"Close camera preview": "Закрыть просмотр камеры",
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
@@ -3596,18 +3596,18 @@
|
||||
"Space science, technology demonstration and other payloads": "Космическая наука, демонстрация технологий и другие полезные нагрузки",
|
||||
"DEBRIS": "МУСОР",
|
||||
"Rocket bodies, fragments and dead satellites": "Ступени ракет, обломки и неработающие спутники",
|
||||
"Classes may lag: SATCAT is a cached copy": "Классы могут отставать: SATCAT — кэшированная копия",
|
||||
"Classes may lag: SATCAT unavailable": "Классы могут отставать: SATCAT недоступен",
|
||||
"Classes may lag: SATCAT is a cached copy": "Классы могут быть неактуальны: SATCAT — кэшированная копия",
|
||||
"Classes may lag: SATCAT unavailable": "Классы могут быть неактуальны: SATCAT недоступен",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR SWATH {value} KM": "ПОЛОСА ЗАХВАТА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "ПОЛОСА ЗАХВАТА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора",
|
||||
"Zoom to the imaging sensor swath": "Приблизить до полосы захвата сенсора",
|
||||
"<1 H OLD": "<1 Ч НАЗАД",
|
||||
"{value} H OLD": "{value} Ч НАЗАД",
|
||||
"{value} D OLD": "{value} Д НАЗАД",
|
||||
"{value} D OLD": "{value} ДН. НАЗАД",
|
||||
"FWD": "ВПЕРЁД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ВПРАВО",
|
||||
@@ -3618,33 +3618,50 @@
|
||||
"Zoom out (−)": "Отдалить (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Back to live": "Вернуться к текущему времени",
|
||||
"Live": "Сейчас",
|
||||
"1 h/s": "1 ч/с",
|
||||
"1 d/s": "1 дн/с",
|
||||
"1 wk/s": "1 нед/с",
|
||||
"1 d/s": "1 дн./с",
|
||||
"1 wk/s": "1 нед./с",
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с",
|
||||
"Rail": "Железная дорога",
|
||||
"Urban rail": "Городской",
|
||||
"Local rail": "Пригородный",
|
||||
"Regional rail": "Региональный",
|
||||
"National rail": "Дальнего следования",
|
||||
"International rail": "Международный",
|
||||
"Hide {category}": "Скрыть: {category}",
|
||||
"Show {category}": "Показать: {category}",
|
||||
"Live GPS": "GPS в реальном времени",
|
||||
"Estimated from station reports": "Оценка по отметкам станций",
|
||||
"To {headsign}": "До {headsign}",
|
||||
"Next {stop} {time} {relative}": "Следующая: {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Предыдущая: {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} мин опоздания)",
|
||||
"Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду",
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}",
|
||||
"Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Фото · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Рейс по ссылке сейчас не передаёт данные — показано последнее известное положение",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Рейс по ссылке сейчас не передаёт данные — показано положение на {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Военный рейс по ссылке сейчас не передаёт данные — показано последнее известное положение",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Военный рейс по ссылке сейчас не передаёт данные — показано положение на {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Судно по ссылке сейчас не передаёт данные — показано последнее известное положение",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Судно по ссылке сейчас не передаёт данные — показано положение на {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Спутник по ссылке сейчас не передаёт данные — показано последнее известное положение",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Спутник по ссылке сейчас не передаёт данные — показано положение на {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°",
|
||||
"Street Level is unavailable": "Уличные снимки недоступны",
|
||||
"Street Level is unavailable": "Слой «Уличные снимки» недоступен",
|
||||
"No street-level imagery source is configured": "Источник уличных снимков не настроен",
|
||||
"Street Level could not be enabled": "Не удалось включить уличные снимки",
|
||||
"Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска",
|
||||
"Aim the view at a street": "Наведите вид на улицу",
|
||||
"Street Level could not be enabled": "Не удалось включить «Уличные снимки»",
|
||||
"Street Level changed while it looked for imagery": "Состояние слоя «Уличные снимки» изменилось во время поиска",
|
||||
"Aim the view at a street": "Наведите центр вида на улицу",
|
||||
"{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков",
|
||||
"{source} needs its key": "{source}: нужен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна",
|
||||
@@ -3652,8 +3669,117 @@
|
||||
"{source} is not available on this map": "{source}: недоступно на этой карте",
|
||||
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
|
||||
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
|
||||
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
|
||||
"No {source} imagery near {place}": "Нет снимков {source} рядом с местом «{place}»",
|
||||
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})",
|
||||
"tiles unavailable": "тайлы недоступны",
|
||||
"Environment": "Окружающая среда",
|
||||
"Imagery": "Снимки",
|
||||
"DATASET": "НАБОР ДАННЫХ",
|
||||
"TILES": "ТАЙЛЫ",
|
||||
"PACK": "ПАКЕТ",
|
||||
"INCUBATING": "В ИНКУБАЦИИ",
|
||||
"COMMUNITY": "СООБЩЕСТВО",
|
||||
"VERIFIED": "ПРОВЕРЕНО",
|
||||
"ARCHIVED": "В АРХИВЕ",
|
||||
"Switched on by this instance · part of the CCTV layer": "Включено на этом экземпляре · часть слоя CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Включает оператор: задайте {env}=1 на сервере",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Включает оператор: добавьте {id} в GEV_LIBRARY_ENABLE на сервере",
|
||||
"Turned off by this instance's operator": "Отключено оператором этого экземпляра",
|
||||
"Needs a provider key on the server": "Нужен ключ провайдера на сервере",
|
||||
"Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке",
|
||||
"Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…",
|
||||
"Regional": "Регионально",
|
||||
"DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ",
|
||||
"Close data library": "Закрыть библиотеку данных",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Источники в реальном времени помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».",
|
||||
"Name, provider or country": "Название, провайдер или страна",
|
||||
"Theme": "Тема",
|
||||
"All themes": "Все темы",
|
||||
"Only what this instance serves": "Только то, что доступно на этом экземпляре",
|
||||
"OPT-IN": "ВКЛЮЧАЕТ ОПЕРАТОР",
|
||||
"KEY": "КЛЮЧ",
|
||||
"KEYLESS": "БЕЗ КЛЮЧА",
|
||||
"WORKING…": "ВЫПОЛНЯЕТСЯ…",
|
||||
"ADD": "ДОБАВИТЬ",
|
||||
"Remove {name} from Data Layers": "Убрать {name} из «Слоёв данных»",
|
||||
"Add {name} to Data Layers": "Добавить {name} в «Слои данных»",
|
||||
"Library": "Библиотека",
|
||||
"BROWSE DATA LIBRARY": "ОТКРЫТЬ БИБЛИОТЕКУ ДАННЫХ",
|
||||
"DRAPED": "НАЛОЖЕНО",
|
||||
"Zoom to layer": "Приблизить к слою",
|
||||
"ZOOM": "ПРИБЛИЗИТЬ",
|
||||
"Zoom to {name}": "Приблизить к «{name}»",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "ПОДТВЕРДИТЬ · УБРАТЬ {count}",
|
||||
"few": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}",
|
||||
"many": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}",
|
||||
"other": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}"
|
||||
},
|
||||
"REMOVE ALL": "УБРАТЬ ВСЁ",
|
||||
"Confirm: remove every added entry from Data Layers": "Подтвердите: убрать все добавленные записи из «Слоёв данных»",
|
||||
"Remove every added entry from Data Layers": "Убрать все добавленные записи из «Слоёв данных»",
|
||||
"Exit cockpit to fly to a layer": "Выйдите из кабины, чтобы перелететь к слою",
|
||||
"Zoom to layer · fly to where this layer has data": "Приблизить к слою · перелететь туда, где у слоя есть данные",
|
||||
"Remove from Data Layers": "Убрать из «Слоёв данных»",
|
||||
"Remove from Data Layers · add it again from the library": "Убрать из «Слоёв данных» · снова добавить можно из библиотеки",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} набор данных",
|
||||
"few": "{count} набора данных",
|
||||
"many": "{count} наборов данных",
|
||||
"other": "{count} набора данных"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} слой тайлов",
|
||||
"few": "{count} слоя тайлов",
|
||||
"many": "{count} слоёв тайлов",
|
||||
"other": "{count} слоя тайлов"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} пакет",
|
||||
"few": "{count} пакета",
|
||||
"many": "{count} пакетов",
|
||||
"other": "{count} пакета"
|
||||
},
|
||||
"Added": "Добавлено",
|
||||
"{count} on the map": {
|
||||
"one": "{count} на карте",
|
||||
"few": "{count} на карте",
|
||||
"many": "{count} на карте",
|
||||
"other": "{count} на карте"
|
||||
},
|
||||
"none yet": "пока ничего",
|
||||
"from {count} datasets": {
|
||||
"one": "из {count} набора данных",
|
||||
"few": "из {count} наборов данных",
|
||||
"many": "из {count} наборов данных",
|
||||
"other": "из {count} набора данных"
|
||||
},
|
||||
"switch on a dataset to load features": "включите набор данных, чтобы загрузить объекты",
|
||||
"Data Library at a glance": "Библиотека данных вкратце",
|
||||
"Map features": "Объекты на карте",
|
||||
"Showing {shown} of {total}": "Показано {shown} из {total}",
|
||||
"Datasets": "Наборы данных",
|
||||
"Themes": "Темы",
|
||||
"zoom in to load this layer": "приблизьте, чтобы загрузить этот слой",
|
||||
"Demographics": "Демография",
|
||||
"Coverage outline": "Граница покрытия",
|
||||
"Orthophoto": "Ортофото",
|
||||
"Satellite mosaic": "Спутниковая мозаика",
|
||||
"Oblique": "Перспективная съёмка",
|
||||
"Historic imagery": "Исторические снимки",
|
||||
"True orthophoto": "Истинное ортофото",
|
||||
"Type": "Тип",
|
||||
"Resolution": "Разрешение",
|
||||
"Flown": "Съёмка",
|
||||
"Updates": "Обновление",
|
||||
"Ortho quality": "Качество ортофото",
|
||||
"Below 250 m": "Ниже 250 м",
|
||||
"250–500 m": "250–500 м",
|
||||
"500–1,000 m": "500–1000 м",
|
||||
"1,000–2,000 m": "1000–2000 м",
|
||||
"2,000–3,000 m": "2000–3000 м",
|
||||
"3,000 m and above": "3000 м и выше",
|
||||
"The globe stopped drawing": "Глобус перестал отрисовываться",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
|
||||
|
||||
+116
-2
@@ -3328,7 +3328,7 @@
|
||||
"Refresh map still": "Uppdatera stillbilden på kartan",
|
||||
"The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.",
|
||||
"Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden",
|
||||
"Press OPEN to load the photo": "Tryck på ÖPPNA för att ladda fotot",
|
||||
"Captured {month}": "Tagen {month}",
|
||||
"this spot": "den här platsen",
|
||||
"the view centre": "vyns mitt",
|
||||
@@ -3336,7 +3336,7 @@
|
||||
"Street level at {place}": "Gatunivå vid {place}",
|
||||
"Switching map": "Byter karta",
|
||||
"Opening imagery": "Öppnar bilder",
|
||||
"Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den",
|
||||
"Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att ladda den",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.",
|
||||
"STATIONS": "STATIONER",
|
||||
@@ -3456,7 +3456,24 @@
|
||||
"Reported voyage": "Angiven resa",
|
||||
"REPORTED VOYAGE": "ANGIVEN RESA",
|
||||
"1 min/s": "1 min/s",
|
||||
"Rail": "Järnväg",
|
||||
"Urban rail": "Tunnelbana/spårväg",
|
||||
"Local rail": "Lokaltåg",
|
||||
"Regional rail": "Regionaltåg",
|
||||
"National rail": "Fjärrtåg",
|
||||
"International rail": "Internationella tåg",
|
||||
"Hide {category}": "Dölj {category}",
|
||||
"Show {category}": "Visa {category}",
|
||||
"Live GPS": "Live-GPS",
|
||||
"Estimated from station reports": "Uppskattad utifrån stationsrapporter",
|
||||
"To {headsign}": "Mot {headsign}",
|
||||
"Next {stop} {time} {relative}": "Nästa stopp {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Föregående stopp {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} min försening)",
|
||||
"Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Foto · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Den delade militärflygningen rapporterar inte just nu – visar senast kända position",
|
||||
@@ -3492,6 +3509,103 @@
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrolla eller nyp för att zooma, dra för att flytta · dubbelklicka för att se allt",
|
||||
"Live": "Live",
|
||||
"Back to live": "Tillbaka till live",
|
||||
"tiles unavailable": "kartrutor otillgängliga",
|
||||
"zoom in to load this layer": "zooma in för att ladda lagret",
|
||||
"Zoom to layer": "Zooma till lagret",
|
||||
"Zoom to layer · fly to where this layer has data": "Zooma till lagret · flyg dit lagret har data",
|
||||
"Remove from Data Layers": "Ta bort från Datalager",
|
||||
"Remove from Data Layers · add it again from the library": "Ta bort från Datalager · lägg till det igen från biblioteket",
|
||||
"Coverage outline": "Täckningens kontur",
|
||||
"DRAPED": "DRAPERAT",
|
||||
"Below 250 m": "Under 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1 000 m",
|
||||
"1,000–2,000 m": "1 000–2 000 m",
|
||||
"2,000–3,000 m": "2 000–3 000 m",
|
||||
"3,000 m and above": "3 000 m och högre",
|
||||
"Exit cockpit to fly to a layer": "Lämna cockpiten för att flyga till ett lager",
|
||||
"Library": "Bibliotek",
|
||||
"Environment": "Miljö",
|
||||
"Imagery": "Bilder",
|
||||
"Demographics": "Demografi",
|
||||
"DATASET": "DATAMÄNGD",
|
||||
"TILES": "KARTRUTOR",
|
||||
"PACK": "PAKET",
|
||||
"INCUBATING": "INKUBERAS",
|
||||
"COMMUNITY": "ANVÄNDARBIDRAG",
|
||||
"VERIFIED": "VERIFIERAD",
|
||||
"ARCHIVED": "ARKIVERAD",
|
||||
"Datasets": "Datamängder",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} datamängd",
|
||||
"other": "{count} datamängder"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} kartrutelager",
|
||||
"other": "{count} kartrutelager"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} paket",
|
||||
"other": "{count} paket"
|
||||
},
|
||||
"Themes": "Teman",
|
||||
"Added": "Tillagda",
|
||||
"{count} on the map": {
|
||||
"one": "{count} på kartan",
|
||||
"other": "{count} på kartan"
|
||||
},
|
||||
"none yet": "inga ännu",
|
||||
"Map features": "Kartobjekt",
|
||||
"from {count} datasets": {
|
||||
"one": "från {count} datamängd",
|
||||
"other": "från {count} datamängder"
|
||||
},
|
||||
"switch on a dataset to load features": "slå på en datamängd för att ladda objekt",
|
||||
"Switched on by this instance · part of the CCTV layer": "Påslaget av den här instansen · del av CCTV-lagret",
|
||||
"Operator opt-in: set {env}=1 on the server": "Aktiveras av administratören: sätt {env}=1 på servern",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av administratören: lägg till {id} i GEV_LIBRARY_ENABLE på servern",
|
||||
"Turned off by this instance's operator": "Avstängt av instansens administratör",
|
||||
"Needs a provider key on the server": "Kräver en leverantörsnyckel på servern",
|
||||
"Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk version",
|
||||
"Checking what this instance serves…": "Kontrollerar vad instansen tillhandahåller …",
|
||||
"Regional": "Regionalt",
|
||||
"Orthophoto": "Ortofoto",
|
||||
"Satellite mosaic": "Satellitmosaik",
|
||||
"Oblique": "Snedbild",
|
||||
"Historic imagery": "Historiska bilder",
|
||||
"True orthophoto": "Sant ortofoto",
|
||||
"Type": "Typ",
|
||||
"Resolution": "Upplösning",
|
||||
"Flown": "Fotograferat",
|
||||
"Updates": "Uppdateras",
|
||||
"Ortho quality": "Ortokvalitet",
|
||||
"DATA LIBRARY": "DATABIBLIOTEK",
|
||||
"Close data library": "Stäng databiblioteket",
|
||||
"Data Library at a glance": "Databiblioteket i korthet",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Realtidskällor utöver baslagren. Var och en hämtas från sin leverantör medan den är påslagen; inget lagras i appen. Lägg till en så visas den i Datalager under Bibliotek.",
|
||||
"Name, provider or country": "Namn, leverantör eller land",
|
||||
"Theme": "Tema",
|
||||
"All themes": "Alla teman",
|
||||
"Only what this instance serves": "Bara det instansen tillhandahåller",
|
||||
"OPT-IN": "TILLVAL",
|
||||
"KEY": "NYCKEL",
|
||||
"KEYLESS": "UTAN NYCKEL",
|
||||
"ZOOM": "ZOOMA",
|
||||
"Zoom to {name}": "Zooma till {name}",
|
||||
"WORKING…": "ARBETAR …",
|
||||
"ADD": "LÄGG TILL",
|
||||
"Remove {name} from Data Layers": "Ta bort {name} från Datalager",
|
||||
"Add {name} to Data Layers": "Lägg till {name} i Datalager",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "BEKRÄFTA · TA BORT {count}",
|
||||
"other": "BEKRÄFTA · TA BORT ALLA {count}"
|
||||
},
|
||||
"REMOVE ALL": "TA BORT ALLA",
|
||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
|
||||
"The globe stopped drawing": "Globen ritas inte längre",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
|
||||
|
||||
+212
-86
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"News feed unavailable": "Стрічка новин недоступна",
|
||||
"Developing: {name}": "Розвивається: {name}",
|
||||
"Developing: {name}": "Розвиток подій: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} подія сьогодні",
|
||||
"few": "{count} події сьогодні",
|
||||
@@ -19,17 +19,17 @@
|
||||
"Claimed by {who}": "Заява: {who}",
|
||||
"Claimed": "Заява",
|
||||
"Not yet confirmed by others": "Поки не підтверджено іншими",
|
||||
"Reported by {side} sources": "Повідомляють джерела {side}",
|
||||
"Reported by {sides}": "Повідомляють: {sides}",
|
||||
"{side} sources": "джерела {side}",
|
||||
"Reported by {side} sources": "Повідомляють джерела: {side}",
|
||||
"Reported by {sides}": "Повідомляють джерела: {sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "Офіційно",
|
||||
"Reported by both sides": "Повідомляють обидві сторони",
|
||||
"Independently reported": "Підтверджено незалежними джерелами",
|
||||
"Independently reported": "Повідомляють незалежні джерела",
|
||||
"Single source": "Одне джерело",
|
||||
"International": "Міжнародні",
|
||||
"Independent": "Незалежні",
|
||||
"Unattributed": "Без атрибуції",
|
||||
"Outside party": "Зовнішня сторона",
|
||||
"Outside party": "Третя сторона",
|
||||
"Ukraine–Russia": "Україна–Росія",
|
||||
"Israel–Gaza–Lebanon": "Ізраїль–Газа–Ліван",
|
||||
"Iran–Hormuz": "Іран–Ормуз",
|
||||
@@ -42,7 +42,7 @@
|
||||
"Air defence": "ППО",
|
||||
"Ground fighting": "Наземні бої",
|
||||
"Casualties": "Жертви",
|
||||
"Humanitarian": "Гуманітарне",
|
||||
"Humanitarian": "Гуманітарна ситуація",
|
||||
"Disaster": "Лихо",
|
||||
"Protest": "Протест",
|
||||
"Political": "Політика",
|
||||
@@ -60,38 +60,38 @@
|
||||
"other": "{count} джерела"
|
||||
},
|
||||
"News feed not configured on this server": "Стрічку новин не налаштовано на цьому сервері",
|
||||
"Malformed news hotspots response": "Некоректна відповідь гарячих точок новин",
|
||||
"Malformed news hotspots response": "Некоректна відповідь із гарячими точками новин",
|
||||
"Malformed news feed response": "Некоректна відповідь стрічки новин",
|
||||
"{place} (placed by model)": "{place} (місце визначила модель)",
|
||||
"English headline by the model": "Англійський заголовок від моделі",
|
||||
"Not placed on the map": "Не позначено на мапі",
|
||||
"Not placed on the map": "Не позначено на карті",
|
||||
"Close": "Закрити",
|
||||
"Any severity": "Будь-яка тяжкість",
|
||||
"{severity} and above": "{severity} і вище",
|
||||
"{severity} only": "Лише {severity}",
|
||||
"All categories": "Усі категорії",
|
||||
"Reading the news…": "Читаємо новини…",
|
||||
"News, last {hours} hours": "Новини за останні {hours} год",
|
||||
"News, last {hours} hours": "Новини за {hours} год",
|
||||
"Last {hours} h": "За {hours} год",
|
||||
"Time window": "Період",
|
||||
"Conflicts": "Конфлікти",
|
||||
"Local": "Місцеві",
|
||||
"News sections": "Розділи новин",
|
||||
"Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини",
|
||||
"Zoom in to see local news": "Наблизьте карту, щоб побачити місцеві новини",
|
||||
"Hotspots": "Гарячі точки",
|
||||
"Severity": "Тяжкість",
|
||||
"Category": "Категорія",
|
||||
"Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося",
|
||||
"Nothing reported near here in the last {hours} h": "За {hours} год поблизу нічого не повідомлялося",
|
||||
"Latest news": "Останні новини",
|
||||
"No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами",
|
||||
"No news matches these filters in the last {hours} h": "За {hours} год немає новин за цими фільтрами",
|
||||
"Load older news": "Завантажити давніші новини",
|
||||
"How to read the map": "Як читати мапу",
|
||||
"How to read the map": "Як читати карту",
|
||||
"Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Суцільний обідок: повідомляють обидві сторони або незалежні джерела. Пунктирний обідок: заява. Тонкий обідок: одне джерело або преса однієї сторони.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більше: більше джерел · блідіше: давніше. Картка з фото: велика або швидко зростаюча подія з двох і більше джерел.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більший маркер: більше джерел · блідіший: давніша подія. Картка з фото: велика подія або подія, що швидко розвивається, з двома чи більше джерелами.",
|
||||
"Side labels describe the source, not whether a report is true.": "Позначки сторін описують джерело, а не правдивість повідомлення.",
|
||||
"Exit cockpit to fly to a news report": "Вийдіть із кабіни, щоб перелетіти до повідомлення",
|
||||
"News items in the feed": "Новин у стрічці",
|
||||
"News items in the feed": "Кількість новин у стрічці",
|
||||
"Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.",
|
||||
"Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.",
|
||||
"Click where the pin goes.": "Клацніть там, де має бути мітка.",
|
||||
@@ -936,15 +936,15 @@
|
||||
"{value} KM/S": "{value} КМ/С",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ",
|
||||
"SITES STANDBY": "ОБʼЄКТИ: ОЧІКУВАННЯ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ «НАНЕСЕНІ ОБʼЄКТИ», ЩОБ БАЧИТИ КОНТАКТИ",
|
||||
"Release the camera from {name}": "Звільнити камеру від {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК",
|
||||
"Return to {name}": "Повернутися до {name}",
|
||||
"SATELLITE": "СУПУТНИК",
|
||||
"Ride {name}": "Перейти на {name}",
|
||||
"Unnamed site": "Безіменний об'єкт",
|
||||
"Unnamed site": "Безіменний обʼєкт",
|
||||
"OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Утримувати камеру на {name}",
|
||||
@@ -1253,9 +1253,9 @@
|
||||
"Moving dots": "Рухомі точки",
|
||||
"TomTom key unavailable": "Ключ TomTom недоступний",
|
||||
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
|
||||
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
|
||||
"Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік",
|
||||
"Traffic service unavailable": "Сервіс даних про трафік недоступний",
|
||||
"Monthly traffic allowance used": "Місячний ліміт даних про дорожній рух вичерпано",
|
||||
"Traffic rate limit reached": "Перевищено ліміт запитів до сервісу руху",
|
||||
"Traffic service unavailable": "Сервіс руху недоступний",
|
||||
"TomTom upstream unreachable": "Сервер TomTom не відповідає",
|
||||
"TomTom flow unavailable": "Потік TomTom недоступний",
|
||||
"SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}",
|
||||
@@ -1779,10 +1779,10 @@
|
||||
"other": "{count} камери завантажено · увімкніть CCTV, щоб активувати"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ще {count} камера поза цим видом · посуньте або віддаліть",
|
||||
"few": "Ще {count} камери поза цим видом · посуньте або віддаліть",
|
||||
"many": "Ще {count} камер поза цим видом · посуньте або віддаліть",
|
||||
"other": "Ще {count} камери поза цим видом · посуньте або віддаліть"
|
||||
"one": "Ще {count} камера поза кадром · посуньте або віддаліть карту",
|
||||
"few": "Ще {count} камери поза кадром · посуньте або віддаліть карту",
|
||||
"many": "Ще {count} камер поза кадром · посуньте або віддаліть карту",
|
||||
"other": "Ще {count} камери поза кадром · посуньте або віддаліть карту"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.",
|
||||
@@ -1822,7 +1822,7 @@
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БОРТИ ПОБЛИЗУ: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
@@ -1859,7 +1859,7 @@
|
||||
"ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ",
|
||||
"CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ",
|
||||
"{range} NM": "{range} МОР. МИЛЬ",
|
||||
"{range} NM": "{range} МОР. МИЛ.",
|
||||
"AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ",
|
||||
"DEST {bearing}": "ПРИЗН. {bearing}",
|
||||
"Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни",
|
||||
@@ -2929,7 +2929,7 @@
|
||||
"NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS",
|
||||
"VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА",
|
||||
"GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ",
|
||||
"SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ",
|
||||
"SPEED OVER GROUND · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ",
|
||||
"ALTITUDE · FT": "ВИСОТА · ФТ",
|
||||
"FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ",
|
||||
"BRIDGE VIEW": "ВИГЛЯД З МІСТКА",
|
||||
@@ -2946,22 +2946,22 @@
|
||||
"LOOK AHEAD": "ПОГЛЯД ПО НОСІ",
|
||||
"Current vessel heading": "Поточний курс судна",
|
||||
"GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ",
|
||||
"SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ",
|
||||
"COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ",
|
||||
"SPEED OVER GROUND": "ШЛЯХОВА ШВИДКІСТЬ",
|
||||
"COURSE OVER GROUND": "ШЛЯХОВИЙ КУТ",
|
||||
"KTS": "ВУЗ",
|
||||
"ALTITUDE": "ВИСОТА",
|
||||
"FT": "ФТ",
|
||||
"Contact cockpit summary": "Зведення контакту в кабіні",
|
||||
"CONTACT": "КОНТАКТ",
|
||||
"CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ",
|
||||
"RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ",
|
||||
"RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛ.",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} КОНТАКТ",
|
||||
"few": "{count} КОНТАКТИ",
|
||||
"many": "{count} КОНТАКТІВ",
|
||||
"other": "{count} КОНТАКТУ"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.",
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальності радара {range} — виберіть, щоб перейти до наступної шкали.",
|
||||
"Bridge radar": "Радар ходового містка",
|
||||
"Contact navigation": "Навігація контактами",
|
||||
"CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ",
|
||||
@@ -3064,7 +3064,7 @@
|
||||
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
|
||||
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "Режим «Космічні місії» недоступний: не вдалося завантажити індекс місій. Спробуйте пізніше.",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
|
||||
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
|
||||
@@ -3213,7 +3213,7 @@
|
||||
"FLAT": "ПЛОСКІ",
|
||||
"SINCE": "З ДАТИ",
|
||||
"UPDATE SHOT": "ОНОВИТИ КАДР",
|
||||
"IMPORT": "ІМПОРТУВАТИ",
|
||||
"IMPORT": "ІМПОРТ",
|
||||
"RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ",
|
||||
"POWER UP": "ЗАПУСК",
|
||||
"GROUND STATION · PROVIDER SETTINGS": "НАЗЕМНА СТАНЦІЯ · НАЛАШТУВАННЯ ПОСТАЧАЛЬНИКІВ",
|
||||
@@ -3282,13 +3282,13 @@
|
||||
"+ CAPTURE SHOT": "+ ЗНЯТИ КАДР",
|
||||
"Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр",
|
||||
"Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом",
|
||||
"Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
|
||||
"Click a shot to select it, double-click to rename.": "Клацніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
|
||||
"Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту",
|
||||
"Stop playback (Esc)": "Зупинити відтворення (Esc)",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени",
|
||||
"SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ",
|
||||
"Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту",
|
||||
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
|
||||
"Load scenes from an exported JSON file": "Імпортувати сцени з експортованого файлу JSON",
|
||||
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
|
||||
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
|
||||
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
|
||||
@@ -3303,7 +3303,7 @@
|
||||
"Drawn shapes": "Намальовані фігури",
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до фігури",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
|
||||
"OSM Places": "Місця OSM",
|
||||
@@ -3455,19 +3455,19 @@
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новини за останні 24 год. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту й даних автоматично і можуть бути хибними. Позначки сторін, де вони є, описують джерело, а не правдивість повідомлення.",
|
||||
"Economy": "Економіка",
|
||||
"News from {area}": "Новини: {area}",
|
||||
"Within {km} km of the view centre": "У радіусі {km} км від центру мапи",
|
||||
"Within {km} km of the view centre": "У радіусі {km} км від центру карти",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "через {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Місцеве в шарі новин: новини дня від медіа країни в кадрі",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Розділ «Місцеві» в шарі «Новини»: новини дня від медіа країни в кадрі",
|
||||
"{provider} refused its key": "{provider} відхилив ключ",
|
||||
"Check {envVars} in Provider Settings": "Перевірте {envVars} у «Налаштуваннях постачальників»",
|
||||
"{provider} key required": "Потрібен ключ {provider}",
|
||||
"{provider} limit reached for today": "{provider}: денний ліміт вичерпано",
|
||||
"News resumes tomorrow, or with a paid plan": "Новини повернуться завтра або з платним тарифом",
|
||||
"News key required": "Потрібен ключ новин",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точні місця конфліктів у шарі «Новини», на додачу до статей GDELT",
|
||||
"Worldwide": "Увесь світ",
|
||||
"Shipwrecks": "Корабельні аварії",
|
||||
"Shipwrecks": "Затонулі судна",
|
||||
"Before 1900": "До 1900",
|
||||
"After 1945": "Після 1945",
|
||||
"Date unknown": "Дата невідома",
|
||||
@@ -3480,13 +3480,13 @@
|
||||
"Depth: {m} m": "Глибина: {m} м",
|
||||
"Notes: {value}": "Примітки: {value}",
|
||||
"Cargo or finds: {value}": "Вантаж або знахідки: {value}",
|
||||
"Protected wreck": "Охоронюваний об'єкт",
|
||||
"Protected wreck": "Охоронюваний обʼєкт",
|
||||
"Source: {value}": "Джерело: {value}",
|
||||
"Click the card to open EMODnet ↗": "Натисніть картку, щоб відкрити EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Натисніть картку, щоб відкрити джерело ↗",
|
||||
"Click the card to open EMODnet ↗": "Клацніть картку, щоб відкрити EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Клацніть картку, щоб відкрити джерело ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показано лише EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet недоступний; показано лише Wikidata",
|
||||
"Shipwrecks unavailable": "Дані про корабельні аварії недоступні",
|
||||
"Shipwrecks unavailable": "Дані про затонулі судна недоступні",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} затонуле судно",
|
||||
@@ -3495,25 +3495,25 @@
|
||||
"other": "{count} затонулого судна"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Корабельна аварія",
|
||||
"Shipwreck": "Затонуле судно",
|
||||
"Fatalities: {count}": "Загиблих: {count}",
|
||||
"Survivors: {count}": "Тих, хто вижив: {count}",
|
||||
"Click the card to open the article ↗": "Натисніть картку, щоб відкрити статтю ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі корабельні аварії світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.",
|
||||
"Survivors: {count}": "Уцілілих: {count}",
|
||||
"Click the card to open the article ↗": "Клацніть картку, щоб відкрити статтю ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі затонулі судна з усього світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Клацніть маркер, щоб відкрити картку.",
|
||||
"All years": "Усі роки",
|
||||
"Period": "Період",
|
||||
"From": "З",
|
||||
"To": "По",
|
||||
"First year of the shipwreck period": "Перший рік періоду корабельних аварій",
|
||||
"Last year of the shipwreck period": "Останній рік періоду корабельних аварій",
|
||||
"First year of the shipwreck period": "Перший рік періоду загибелі суден",
|
||||
"Last year of the shipwreck period": "Останній рік періоду загибелі суден",
|
||||
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
|
||||
"Show {name} sighting": "Показати спостереження: {name}",
|
||||
"seen {age} ago": "помічено {age} тому",
|
||||
"observed {age} ago": "спостерігалося {age} тому",
|
||||
"Be Whale Wise": "Спостерігайте за китами дбайливо",
|
||||
"Reported by {name} ({entity})": "Повідомив: {name} ({entity})",
|
||||
"Reported by {name}": "Повідомив: {name}",
|
||||
"Salish Sea": "Салішське море",
|
||||
"Reported by {name} ({entity})": "Повідомляє: {name} ({entity})",
|
||||
"Reported by {name}": "Повідомляє: {name}",
|
||||
"Salish Sea": "море Селіш",
|
||||
"Birds": "Птахи",
|
||||
"Mammals": "Ссавці",
|
||||
"Reptiles": "Плазуни",
|
||||
@@ -3525,11 +3525,11 @@
|
||||
"Fungi": "Гриби",
|
||||
"Other animals": "Інші тварини",
|
||||
"Other or unknown": "Інші або невідомі",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити",
|
||||
"{label} {detail} — click to retry": "{label}: {detail} — клацніть, щоб повторити",
|
||||
"Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне",
|
||||
"Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня",
|
||||
"No recent sightings in view": "Немає недавніх спостережень в області огляду",
|
||||
"Acartia feed unavailable": "Стрічка Acartia недоступна",
|
||||
"No recent sightings in view": "У кадрі немає недавніх спостережень",
|
||||
"Acartia feed unavailable": "Потік Acartia недоступний",
|
||||
"Show all taxa": "Показати всі групи організмів",
|
||||
"BIRDS": "ПТАХИ",
|
||||
"Show only birds": "Показати лише птахів",
|
||||
@@ -3544,31 +3544,31 @@
|
||||
"All rights reserved": "Усі права захищено",
|
||||
"Cetacean": "Китоподібне",
|
||||
"Wildlife Sightings": "Спостереження живої природи",
|
||||
"Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження",
|
||||
"Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження",
|
||||
"Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перелетіти до спостереження",
|
||||
"Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перелетіти до області спостереження",
|
||||
"Wildlife": "Жива природа",
|
||||
"Zoom in for local sightings": "Наблизьте для місцевих спостережень",
|
||||
"Zoom in for local sightings": "Наблизьте карту, щоб побачити місцеві спостереження",
|
||||
"Loading photo...": "Завантаження фото…",
|
||||
"Photo unavailable": "Фото недоступне",
|
||||
"Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°",
|
||||
"Photo of {name}": "Фото {name}",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "{count} спостережень у полі зору",
|
||||
"{count} sightings in view": "Спостережень у кадрі: {count}",
|
||||
"Close sighting": "Закрити спостереження",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження живої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише комірками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.",
|
||||
"Open {name} on iNaturalist": "Відкрити {name} в iNaturalist",
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Усі позначки",
|
||||
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"{days}d": "{days} дн",
|
||||
"All markers": "Усі мітки",
|
||||
"Show markers of every age": "Показати мітки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн; мітки без дати залишаються",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн · мітки без дати залишаються",
|
||||
"Close camera preview": "Закрити перегляд камери",
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
@@ -3581,24 +3581,24 @@
|
||||
"Street level at {place}": "Вуличні знімки: {place}",
|
||||
"Switching map": "Перемикання карти",
|
||||
"Opening imagery": "Відкриття знімків",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть «{open}», щоб завантажити",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
|
||||
"STATIONS": "СТАНЦІЇ",
|
||||
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
|
||||
"IMAGING": "ЗЙОМКА",
|
||||
"Earth observation: optical, radar, weather and science": "Спостереження Землі: оптика, радар, погода й наука",
|
||||
"TELECOM": "ЗВ’ЯЗОК",
|
||||
"Communications and navigation satellites, Starlink excepted": "Супутники зв’язку та навігації, крім Starlink",
|
||||
"TELECOM": "ЗВʼЯЗОК",
|
||||
"Communications and navigation satellites, Starlink excepted": "Супутники звʼязку та навігації, крім Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Угруповання Starlink (тисячі додаткових точок)",
|
||||
"PROBES": "ЗОНДИ",
|
||||
"Space science, technology demonstration and other payloads": "Космічна наука, демонстрація технологій та інші корисні навантаження",
|
||||
"DEBRIS": "УЛАМКИ",
|
||||
"Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й непрацюючі супутники",
|
||||
"Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й недіючі супутники",
|
||||
"Classes may lag: SATCAT is a cached copy": "Класи можуть відставати: SATCAT — кешована копія",
|
||||
"Classes may lag: SATCAT unavailable": "Класи можуть відставати: SATCAT недоступний",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного обʼєкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ",
|
||||
@@ -3618,7 +3618,7 @@
|
||||
"Zoom out (−)": "Віддалити (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Back to live": "Повернутися до поточного часу",
|
||||
"Live": "Зараз",
|
||||
"1 h/s": "1 год/с",
|
||||
"1 d/s": "1 дн/с",
|
||||
@@ -3626,15 +3626,32 @@
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с",
|
||||
"Rail": "Залізниця",
|
||||
"Urban rail": "Міський",
|
||||
"Local rail": "Приміський",
|
||||
"Regional rail": "Регіональний",
|
||||
"National rail": "Далекого сполучення",
|
||||
"International rail": "Міжнародний",
|
||||
"Hide {category}": "Сховати: {category}",
|
||||
"Show {category}": "Показати: {category}",
|
||||
"Live GPS": "GPS у реальному часі",
|
||||
"Estimated from station reports": "Оцінка за відмітками станцій",
|
||||
"To {headsign}": "Куди: {headsign}",
|
||||
"Next {stop} {time} {relative}": "Наступна: {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "Попередня: {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(+{minutes} хв запізнення)",
|
||||
"Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда",
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}",
|
||||
"Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}",
|
||||
"Photo · {licence}": "Фото · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Рейс за посиланням зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Рейс за посиланням зараз не передає даних — показано місце станом на {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Військовий рейс за посиланням зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Військовий рейс за посиланням зараз не передає даних — показано місце станом на {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Судно за посиланням зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Судно за посиланням зараз не передає даних — показано місце станом на {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Супутник за посиланням зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Супутник за посиланням зараз не передає даних — показано місце станом на {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
@@ -3642,9 +3659,9 @@
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°",
|
||||
"Street Level is unavailable": "Вуличні знімки недоступні",
|
||||
"No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано",
|
||||
"Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки",
|
||||
"Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку",
|
||||
"Aim the view at a street": "Наведіть вигляд на вулицю",
|
||||
"Street Level could not be enabled": "Не вдалося увімкнути «Вуличні знімки»",
|
||||
"Street Level changed while it looked for imagery": "Стан шару «Вуличні знімки» змінився під час пошуку",
|
||||
"Aim the view at a street": "Спрямуйте камеру на вулицю",
|
||||
"{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків",
|
||||
"{source} needs its key": "{source}: потрібен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна",
|
||||
@@ -3652,8 +3669,117 @@
|
||||
"{source} is not available on this map": "{source}: недоступно на цій карті",
|
||||
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
|
||||
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
|
||||
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
|
||||
"No {source} imagery near {place}": "Немає знімків {source} поблизу: {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})",
|
||||
"tiles unavailable": "тайли недоступні",
|
||||
"zoom in to load this layer": "наблизьте карту, щоб завантажити цей шар",
|
||||
"Zoom to layer": "Наблизити до шару",
|
||||
"Zoom to layer · fly to where this layer has data": "Наблизити до шару · перелетіти туди, де цей шар має дані",
|
||||
"Remove from Data Layers": "Видалити з «Шарів даних»",
|
||||
"Remove from Data Layers · add it again from the library": "Видалити з «Шарів даних» · можна знову додати з бібліотеки",
|
||||
"Coverage outline": "Контур покриття",
|
||||
"DRAPED": "НАКЛАДЕНО",
|
||||
"Below 250 m": "Нижче 250 м",
|
||||
"250–500 m": "250–500 м",
|
||||
"500–1,000 m": "500–1000 м",
|
||||
"1,000–2,000 m": "1000–2000 м",
|
||||
"2,000–3,000 m": "2000–3000 м",
|
||||
"3,000 m and above": "3000 м і вище",
|
||||
"Exit cockpit to fly to a layer": "Вийдіть із кабіни, щоб перелетіти до шару",
|
||||
"Library": "Бібліотека",
|
||||
"Environment": "Довкілля",
|
||||
"Imagery": "Знімки",
|
||||
"Demographics": "Демографія",
|
||||
"DATASET": "НАБІР ДАНИХ",
|
||||
"TILES": "ТАЙЛИ",
|
||||
"PACK": "ПАКЕТ",
|
||||
"INCUBATING": "НА ВИПРОБУВАННІ",
|
||||
"COMMUNITY": "СПІЛЬНОТА",
|
||||
"VERIFIED": "ПЕРЕВІРЕНО",
|
||||
"ARCHIVED": "В АРХІВІ",
|
||||
"Datasets": "Набори даних",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"one": "{count} набір даних",
|
||||
"few": "{count} набори даних",
|
||||
"many": "{count} наборів даних",
|
||||
"other": "{count} набору даних"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"one": "{count} шар тайлів",
|
||||
"few": "{count} шари тайлів",
|
||||
"many": "{count} шарів тайлів",
|
||||
"other": "{count} шару тайлів"
|
||||
},
|
||||
"{count} packs": {
|
||||
"one": "{count} пакет",
|
||||
"few": "{count} пакети",
|
||||
"many": "{count} пакетів",
|
||||
"other": "{count} пакета"
|
||||
},
|
||||
"Themes": "Теми",
|
||||
"Added": "Додані",
|
||||
"{count} on the map": {
|
||||
"one": "{count} на карті",
|
||||
"few": "{count} на карті",
|
||||
"many": "{count} на карті",
|
||||
"other": "{count} на карті"
|
||||
},
|
||||
"none yet": "поки жодного",
|
||||
"Map features": "Обʼєкти карти",
|
||||
"from {count} datasets": {
|
||||
"one": "з {count} набору даних",
|
||||
"few": "з {count} наборів даних",
|
||||
"many": "з {count} наборів даних",
|
||||
"other": "з {count} набору даних"
|
||||
},
|
||||
"switch on a dataset to load features": "увімкніть набір даних, щоб завантажити обʼєкти",
|
||||
"Switched on by this instance · part of the CCTV layer": "Увімкнено цим екземпляром · частина шару CCTV",
|
||||
"Operator opt-in: set {env}=1 on the server": "Увімкнення оператором: задайте {env}=1 на сервері",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Увімкнення оператором: додайте {id} до GEV_LIBRARY_ENABLE на сервері",
|
||||
"Turned off by this instance's operator": "Вимкнено оператором цього екземпляра",
|
||||
"Needs a provider key on the server": "Потрібен ключ постачальника на сервері",
|
||||
"Needs this app's server · not available in a static build": "Потрібен сервер цього застосунку · недоступно в статичній збірці",
|
||||
"Checking what this instance serves…": "Перевірка того, що надає цей екземпляр…",
|
||||
"Regional": "Регіонально",
|
||||
"Orthophoto": "Ортофото",
|
||||
"Satellite mosaic": "Супутникова мозаїка",
|
||||
"Oblique": "Перспективна зйомка",
|
||||
"Historic imagery": "Історичні знімки",
|
||||
"True orthophoto": "Істинне ортофото",
|
||||
"Type": "Тип",
|
||||
"Resolution": "Роздільна здатність",
|
||||
"Flown": "Зйомка",
|
||||
"Updates": "Оновлення",
|
||||
"Ortho quality": "Якість орто",
|
||||
"DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ",
|
||||
"Close data library": "Закрити бібліотеку даних",
|
||||
"Data Library at a glance": "Огляд бібліотеки даних",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Джерела даних у реальному часі поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно зʼявиться в «Шарах даних» у розділі «Бібліотека».",
|
||||
"Name, provider or country": "Назва, постачальник або країна",
|
||||
"Theme": "Тема",
|
||||
"All themes": "Усі теми",
|
||||
"Only what this instance serves": "Лише те, що надає цей екземпляр",
|
||||
"OPT-IN": "ВМИКАЄ ОПЕРАТОР",
|
||||
"KEY": "КЛЮЧ",
|
||||
"KEYLESS": "БЕЗ КЛЮЧА",
|
||||
"ZOOM": "НАБЛИЗИТИ",
|
||||
"Zoom to {name}": "Наблизити до {name}",
|
||||
"WORKING…": "ОБРОБКА…",
|
||||
"ADD": "ДОДАТИ",
|
||||
"Remove {name} from Data Layers": "Видалити {name} з «Шарів даних»",
|
||||
"Add {name} to Data Layers": "Додати {name} до «Шарів даних»",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"one": "ПІДТВЕРДИТИ · ВИДАЛИТИ {count}",
|
||||
"few": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}",
|
||||
"many": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}",
|
||||
"other": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}"
|
||||
},
|
||||
"REMOVE ALL": "ВИДАЛИТИ ВСЕ",
|
||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
|
||||
"The globe stopped drawing": "Глобус перестав відмальовуватися",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
|
||||
|
||||
+192
-84
@@ -10,12 +10,12 @@
|
||||
"Disputed: {name}": "争议地区:{name}",
|
||||
"Video": "视频",
|
||||
"Photos": "照片",
|
||||
"Claimed by {who}": "{who}声称",
|
||||
"Claimed by {who}": "{who} 声称",
|
||||
"Claimed": "声称",
|
||||
"Not yet confirmed by others": "尚未获他方证实",
|
||||
"Reported by {side} sources": "{side}方来源报道",
|
||||
"Reported by {side} sources": "报道来源:{side}",
|
||||
"Reported by {sides}": "报道来源:{sides}",
|
||||
"{side} sources": "{side}方来源",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "官方",
|
||||
"Reported by both sides": "双方均有报道",
|
||||
"Independently reported": "有独立来源报道",
|
||||
@@ -23,7 +23,7 @@
|
||||
"International": "国际",
|
||||
"Independent": "独立",
|
||||
"Unattributed": "未注明来源",
|
||||
"Outside party": "外部方",
|
||||
"Outside party": "第三方",
|
||||
"Ukraine–Russia": "乌克兰–俄罗斯",
|
||||
"Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩",
|
||||
"Iran–Hormuz": "伊朗–霍尔木兹",
|
||||
@@ -36,17 +36,17 @@
|
||||
"Air defence": "防空",
|
||||
"Ground fighting": "地面战斗",
|
||||
"Casualties": "伤亡",
|
||||
"Humanitarian": "人道",
|
||||
"Humanitarian": "人道主义",
|
||||
"Disaster": "灾害",
|
||||
"Protest": "抗议",
|
||||
"Political": "政治",
|
||||
"No physical event": "无实际事件",
|
||||
"Minor incident": "轻微事件",
|
||||
"Significant": "重大",
|
||||
"Major": "严重",
|
||||
"Significant": "较大",
|
||||
"Major": "重大",
|
||||
"age|now": "刚刚",
|
||||
"{minutes} min": "{minutes} 分钟",
|
||||
"{hours} h": "{hours} 小时",
|
||||
"{minutes} min": "{minutes} 分钟前",
|
||||
"{hours} h": "{hours} 小时前",
|
||||
"{count} sources": {
|
||||
"other": "{count} 个来源"
|
||||
},
|
||||
@@ -78,10 +78,10 @@
|
||||
"Load older news": "加载更早的新闻",
|
||||
"How to read the map": "如何解读地图",
|
||||
"Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方或独立来源均有报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的严重或快速发展的事件。",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方均有报道,或有独立来源报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的重大或快速发展的事件。",
|
||||
"Side labels describe the source, not whether a report is true.": "阵营标签描述的是来源,而非报道是否属实。",
|
||||
"Exit cockpit to fly to a news report": "退出驾驶舱以飞往新闻报道",
|
||||
"Exit cockpit to fly to a news report": "请先退出座舱,再飞往新闻报道地点",
|
||||
"News items in the feed": "列表中的新闻数",
|
||||
"Pick a shape, then click the map.": "先选择形状,再在地图上点击。",
|
||||
"Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。",
|
||||
@@ -598,7 +598,7 @@
|
||||
"FOV {value}° (DATASHEET)": "FOV {value}°(规格书)",
|
||||
"FOV {value}°": "FOV {value}°",
|
||||
"SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)",
|
||||
"COVERAGE {value}km²": "覆盖 {value}km²",
|
||||
"COVERAGE {value}km²": "覆盖 {value} km²",
|
||||
"OVERLAP {count} cams": "重叠 {count} 个摄像头",
|
||||
"ISOLATED VIEW": "独立视角",
|
||||
"PROJ MONITOR": "投影:监视器",
|
||||
@@ -616,8 +616,8 @@
|
||||
"Hurricane": "飓风",
|
||||
"Tropical storm": "热带风暴",
|
||||
"Tropical depression": "热带低压",
|
||||
"Subtropical storm": "亚热带风暴",
|
||||
"Subtropical depression": "亚热带低压",
|
||||
"Subtropical storm": "副热带风暴",
|
||||
"Subtropical depression": "副热带低压",
|
||||
"Track and cone match this advisory": "路径和预报不确定范围与本期公告一致",
|
||||
"Track/cone awaiting advisory {number}": "路径/预报不确定范围等待第 {number} 号公告",
|
||||
"Track/cone unavailable": "路径/预报不确定范围不可用",
|
||||
@@ -808,7 +808,7 @@
|
||||
"Low-accuracy score 2–10%": "低精度评分 2–10%",
|
||||
"Low-accuracy score > 10%": "低精度评分 > 10%",
|
||||
"GNSS integrity source unavailable": "GNSS 完好性数据源不可用",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内浏览过的视野中的网格",
|
||||
"Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试",
|
||||
"Retrying automatically after provider cooldown": "服务商冷却结束后自动重试",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。",
|
||||
@@ -866,18 +866,18 @@
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待机",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒",
|
||||
"Release the camera from {name}": "让镜头离开{name}",
|
||||
"SITES STANDBY": "设施待机",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用地图标注设施以接收设施提醒",
|
||||
"Release the camera from {name}": "让镜头离开 {name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一颗卫星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"Return to {name}": "返回 {name}",
|
||||
"SATELLITE": "卫星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地点",
|
||||
"Ride {name}": "乘坐 {name}",
|
||||
"Unnamed site": "未命名设施",
|
||||
"OFF-NADIR {angle}°": "偏离天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "让镜头持续对准{name}",
|
||||
"Keep the camera on {name}": "让镜头持续对准 {name}",
|
||||
"ZOOM {zoom}×": "变焦 {zoom}×",
|
||||
"SWATH {value} KM": "幅宽 {value} km",
|
||||
"SWATH ≈ {value} KM": "幅宽 ≈ {value} km",
|
||||
@@ -885,10 +885,10 @@
|
||||
"ORBITAL VIEW · NADIR": "轨道视图 · 天底",
|
||||
"ORBITAL VELOCITY": "轨道速度",
|
||||
"SUB-SATELLITE POINT": "星下点",
|
||||
"NORTH UP": "北向上",
|
||||
"CURRENTLY OVER": "当前上空",
|
||||
"{division}, {country}": "{division},{country}",
|
||||
"{region} · near {place}": "{region} · {place}附近",
|
||||
"NORTH UP": "正北朝上",
|
||||
"CURRENTLY OVER": "当前飞越",
|
||||
"{division}, {country}": "{country} {division}",
|
||||
"{region} · near {place}": "{region} · {place} 附近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "历元 {time}",
|
||||
"TLE": "TLE",
|
||||
@@ -1173,7 +1173,7 @@
|
||||
"Moving dots": "移动圆点",
|
||||
"TomTom key unavailable": "TomTom 密钥不可用",
|
||||
"TomTom daily budget reached": "已达 TomTom 每日配额",
|
||||
"Monthly traffic allowance used": "本月交通流量额度已用完",
|
||||
"Monthly traffic allowance used": "本月交通数据配额已用完",
|
||||
"Traffic rate limit reached": "已达到交通数据请求频率上限",
|
||||
"Traffic service unavailable": "交通服务不可用",
|
||||
"TomTom upstream unreachable": "TomTom 上游服务无法连接",
|
||||
@@ -1606,7 +1606,7 @@
|
||||
"Link copied!": "链接已复制!",
|
||||
"Link shared": "链接已分享",
|
||||
"This share link can't be opened": "无法打开此分享链接",
|
||||
"Shared vessel is unavailable": "分享的船只不可用",
|
||||
"Shared vessel is unavailable": "分享的船舶不可用",
|
||||
"Copy failed": "复制失败",
|
||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||
"Tilt map to oblique view": "将地图倾斜至斜视角",
|
||||
@@ -1676,7 +1676,7 @@
|
||||
"other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此视图外还有 {count} 个摄像头 · 平移或缩小"
|
||||
"other": "视野外还有 {count} 个摄像头 · 请平移或缩小地图"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
|
||||
@@ -1716,7 +1716,7 @@
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飞机信息不完整 · 不代表安全无虞",
|
||||
"CONTEXT STANDBY": "态势待机",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
|
||||
@@ -1750,7 +1750,7 @@
|
||||
"ROUTE DATA UNAVAILABLE": "航线数据不可用",
|
||||
"CONTACT NAVIGATION OFF": "目标导航已关闭",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上",
|
||||
"{range} NM": "{range}海里",
|
||||
"{range} NM": "{range} 海里",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报",
|
||||
"DEST {bearing}": "目的地 {bearing}",
|
||||
"Disable cockpit weather effects": "停用座舱天气效果",
|
||||
@@ -1870,7 +1870,7 @@
|
||||
"GNSS Integrity": "GNSS 完好性",
|
||||
"Rain radar": "降雨雷达",
|
||||
"Lightning density": "闪电密度",
|
||||
"Cyclone advisories": "气旋预警",
|
||||
"Cyclone advisories": "气旋公告",
|
||||
"ALPR Cameras": "ALPR 摄像头",
|
||||
"Live Vessels": "实时船舶",
|
||||
"Bike Share": "共享单车",
|
||||
@@ -2073,8 +2073,8 @@
|
||||
"coverage|partial": "部分",
|
||||
"unknown": "未知",
|
||||
"no imagery": "无影像",
|
||||
"{percent}% cloud": "{percent}% 云量",
|
||||
"{min}–{max}% cloud": "{min}–{max}% 云量",
|
||||
"{percent}% cloud": "云量 {percent}%",
|
||||
"{min}–{max}% cloud": "云量 {min}–{max}%",
|
||||
"NO BOX": "无框选",
|
||||
"SEARCHING": "搜索中",
|
||||
"{count} DAYS": {
|
||||
@@ -2629,7 +2629,7 @@
|
||||
"Contact cockpit summary": "目标座舱摘要",
|
||||
"CONTACT": "目标",
|
||||
"CONTACTS · 250 KM": "目标 · 250 km",
|
||||
"RINGS {spacing} NM": "距标圈 {spacing}海里",
|
||||
"RINGS {spacing} NM": "距标圈 {spacing} 海里",
|
||||
"{count} CONTACTS": {
|
||||
"other": "{count} 个目标"
|
||||
},
|
||||
@@ -2718,7 +2718,7 @@
|
||||
"WEATHER": "天气",
|
||||
"Active weather products": "已启用的天气产品",
|
||||
"RECENT IMAGERY": "近期影像",
|
||||
"Imagery days in the box": "视野内的影像天数",
|
||||
"Imagery days in the box": "框选区域内的影像天数",
|
||||
"CONTEXT": "态势",
|
||||
"Context mode": "态势模式",
|
||||
"CONTACTS": "目标",
|
||||
@@ -3078,7 +3078,7 @@
|
||||
"Left panels": "左侧面板",
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往已绘制的形状",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||
"Voice place search (needs an Overpass server)": "语音地点搜索(需要 Overpass 服务器)",
|
||||
"OSM Places": "OSM 地点",
|
||||
@@ -3214,11 +3214,11 @@
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。",
|
||||
"Economy": "经济",
|
||||
"News from {area}": "{area}新闻",
|
||||
"Within {km} km of the view centre": "视图中心 {km} 公里范围内",
|
||||
"{region}, {country}": "{country}{region}",
|
||||
"News from {area}": "{area} 新闻",
|
||||
"Within {km} km of the view centre": "视野中心 {km} km 范围内",
|
||||
"{region}, {country}": "{country} {region}",
|
||||
"via {source}": "来自 {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的本地:视野中国家的媒体当天的新闻",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的“本地”分区:当前视野所在国家的媒体当天的新闻",
|
||||
"{provider} refused its key": "{provider} 拒绝了密钥",
|
||||
"Check {envVars} in Provider Settings": "请在服务商设置中检查 {envVars}",
|
||||
"{provider} key required": "需要 {provider} 密钥",
|
||||
@@ -3228,16 +3228,16 @@
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列",
|
||||
"Worldwide": "全球",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以后",
|
||||
"Before 1900": "1900 年以前",
|
||||
"After 1945": "1945 年以后",
|
||||
"Date unknown": "日期不详",
|
||||
"Position: centre of the protected area": "位置:保护区中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(误差约 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "实测位置(误差约 {m} 米)",
|
||||
"Vessel: {value}": "船只:{value}",
|
||||
"Position approximate (within about {km} km)": "大致位置(误差约 {km} km)",
|
||||
"Position surveyed (within about {m} m)": "实测位置(误差约 {m} m)",
|
||||
"Vessel: {value}": "船型:{value}",
|
||||
"What happened: {value}": "经过:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 米({quality})",
|
||||
"Depth: {m} m": "深度:{m} 米",
|
||||
"Depth: {m} m ({quality})": "深度:{m} m({quality})",
|
||||
"Depth: {m} m": "深度:{m} m",
|
||||
"Notes: {value}": "备注:{value}",
|
||||
"Cargo or finds: {value}": "货物或出水文物:{value}",
|
||||
"Protected wreck": "受保护沉船",
|
||||
@@ -3253,8 +3253,8 @@
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "遇难:{count}",
|
||||
"Survivors: {count}": "幸存者:{count}",
|
||||
"Fatalities: {count}": "遇难人数:{count}",
|
||||
"Survivors: {count}": "幸存人数:{count}",
|
||||
"Click the card to open the article ↗": "点击卡片打开文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧洲历史沉船来自 EMODnet Human Activities;全球知名沉船来自 Wikidata。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。",
|
||||
"All years": "所有年份",
|
||||
@@ -3264,12 +3264,12 @@
|
||||
"First year of the shipwreck period": "沉船时段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船时段的结束年份",
|
||||
"SIGHTING": "目击",
|
||||
"Show {name} sighting": "显示{name}目击",
|
||||
"Show {name} sighting": "显示 {name} 的目击记录",
|
||||
"seen {age} ago": "{age}前目击",
|
||||
"observed {age} ago": "{age}前观察",
|
||||
"observed {age} ago": "{age}前观察到",
|
||||
"Be Whale Wise": "负责任地观鲸",
|
||||
"Reported by {name} ({entity})": "报告方 {name}({entity})",
|
||||
"Reported by {name}": "报告方 {name}",
|
||||
"Reported by {name} ({entity})": "报告方:{name}({entity})",
|
||||
"Reported by {name}": "报告方:{name}",
|
||||
"Salish Sea": "萨利希海",
|
||||
"Birds": "鸟类",
|
||||
"Mammals": "哺乳类",
|
||||
@@ -3282,18 +3282,18 @@
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他动物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 点击重试",
|
||||
"{label} {detail} — click to retry": "{label} {detail}——点击重试",
|
||||
"Wildlife sightings source unavailable": "野生生物目击来源不可用",
|
||||
"Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击",
|
||||
"No recent sightings in view": "视野内无近期目击",
|
||||
"Acartia feed unavailable": "Acartia数据源不可用",
|
||||
"Acartia feed unavailable": "Acartia 数据源不可用",
|
||||
"Show all taxa": "显示所有分类群",
|
||||
"BIRDS": "鸟类",
|
||||
"Show only birds": "仅显示鸟类",
|
||||
"MAMMALS": "哺乳类",
|
||||
"Show only mammals": "仅显示哺乳类",
|
||||
"WHALES": "鲸类",
|
||||
"Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)",
|
||||
"Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7 天)",
|
||||
"INSECTS": "昆虫",
|
||||
"Show only insects": "仅显示昆虫",
|
||||
"PLANTS": "植物",
|
||||
@@ -3301,31 +3301,31 @@
|
||||
"All rights reserved": "保留所有权利",
|
||||
"Cetacean": "鲸类",
|
||||
"Wildlife Sightings": "野生生物目击",
|
||||
"Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点",
|
||||
"Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域",
|
||||
"Exit cockpit to fly to a sighting": "请先退出座舱,再飞往目击地点",
|
||||
"Exit cockpit to fly to a sighting area": "请先退出座舱,再飞往目击区域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看当地目击",
|
||||
"Loading photo...": "正在加载照片…",
|
||||
"Photo unavailable": "照片不可用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°网格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至 0.2° 网格",
|
||||
"Photo of {name}": "{name} 的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "视野内有 {count} 次目击",
|
||||
"Close sighting": "关闭目击记录",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近 48 小时内观察到的野生生物(鲸类为 7 天)。颜色与徽章图标表示分类群。敏感物种仅以 0.2° 网格显示;Acartia 提供的萨利希海海洋哺乳动物目击位置四舍五入到 0.01°。并非种群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
|
||||
"{days}d": "{days}天",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海)· 各记录单独授权",
|
||||
"{days}d": "{days} 天",
|
||||
"All markers": "所有标记",
|
||||
"Show markers of every age": "显示所有时间的标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "仅显示 {days} 天内的事件;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days} 天内 · 保留无日期标记",
|
||||
"Close camera preview": "关闭摄像头预览",
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
@@ -3333,12 +3333,12 @@
|
||||
"Press OPEN to load the photo": "点击“打开”加载照片",
|
||||
"Captured {month}": "拍摄于 {month}",
|
||||
"this spot": "此处",
|
||||
"the view centre": "视图中心",
|
||||
"the view centre": "视野中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切换地图",
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:点击“{open}”加载",
|
||||
"{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
|
||||
"STATIONS": "空间站",
|
||||
@@ -3352,7 +3352,7 @@
|
||||
"PROBES": "探测器",
|
||||
"Space science, technology demonstration and other payloads": "空间科学、技术验证及其他载荷",
|
||||
"DEBRIS": "碎片",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭残骸、碎片和失效卫星",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭箭体、碎片和失效卫星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分类可能滞后:SATCAT 为缓存副本",
|
||||
"Classes may lag: SATCAT unavailable": "分类可能滞后:SATCAT 不可用",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。",
|
||||
@@ -3362,17 +3362,17 @@
|
||||
"SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km",
|
||||
"SENSOR": "传感器",
|
||||
"Zoom to the imaging sensor swath": "缩放到成像传感器幅宽",
|
||||
"<1 H OLD": "不到1小时前",
|
||||
"{value} H OLD": "{value}小时前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"<1 H OLD": "不到 1 小时前",
|
||||
"{value} H OLD": "{value} 小时前",
|
||||
"{value} D OLD": "{value} 天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "后方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航迹向上",
|
||||
"TRACK UP": "航迹朝上",
|
||||
"COURSE HOLD · AIS": "对地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "缩小 (−)",
|
||||
"Zoom in (+)": "放大(+)",
|
||||
"Zoom out (−)": "缩小(−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
@@ -3383,15 +3383,32 @@
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Rail": "铁路",
|
||||
"Urban rail": "城市轨道",
|
||||
"Local rail": "市郊铁路",
|
||||
"Regional rail": "区域铁路",
|
||||
"National rail": "长途铁路",
|
||||
"International rail": "国际铁路",
|
||||
"Hide {category}": "隐藏{category}",
|
||||
"Show {category}": "显示{category}",
|
||||
"Live GPS": "实时 GPS",
|
||||
"Estimated from station reports": "根据车站报告估算",
|
||||
"To {headsign}": "开往 {headsign}",
|
||||
"Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(晚点 {minutes} 分钟)",
|
||||
"Exit cockpit to fly to a train": "请先退出座舱,再飞往列车",
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置",
|
||||
"Photo: {photographer} · {licence}": "照片:{photographer} · {licence}",
|
||||
"Photo · {licence}": "照片 · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "分享的航班目前无位置更新——显示其最后出现的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "分享的航班目前无位置更新——显示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "分享的军用航班目前无位置更新——显示其最后出现的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "分享的军用航班目前无位置更新——显示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前无位置更新——显示其最后出现的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前无位置更新——显示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "分享的卫星目前无位置更新——显示其最后出现的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "分享的卫星目前无位置更新——显示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
@@ -3411,6 +3428,97 @@
|
||||
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})",
|
||||
"tiles unavailable": "瓦片不可用",
|
||||
"zoom in to load this layer": "放大以加载此图层",
|
||||
"Zoom to layer": "缩放至图层",
|
||||
"Zoom to layer · fly to where this layer has data": "缩放至图层 · 飞往此图层有数据的区域",
|
||||
"Remove from Data Layers": "从数据图层中移除",
|
||||
"Remove from Data Layers · add it again from the library": "从数据图层中移除 · 可从数据资源库重新添加",
|
||||
"Coverage outline": "覆盖范围轮廓",
|
||||
"DRAPED": "贴地",
|
||||
"Below 250 m": "低于 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m 及以上",
|
||||
"Exit cockpit to fly to a layer": "请先退出座舱,再飞往图层",
|
||||
"Library": "资源库",
|
||||
"Environment": "环境",
|
||||
"Imagery": "影像",
|
||||
"Demographics": "人口统计",
|
||||
"DATASET": "数据集",
|
||||
"TILES": "瓦片",
|
||||
"PACK": "数据包",
|
||||
"INCUBATING": "孵化中",
|
||||
"COMMUNITY": "社区",
|
||||
"VERIFIED": "已验证",
|
||||
"ARCHIVED": "已归档",
|
||||
"Datasets": "数据集",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count} 个数据集"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count} 个瓦片图层"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count} 个数据包"
|
||||
},
|
||||
"Themes": "主题",
|
||||
"Added": "已添加",
|
||||
"{count} on the map": {
|
||||
"other": "地图上 {count} 个"
|
||||
},
|
||||
"none yet": "暂无",
|
||||
"Map features": "地图要素",
|
||||
"from {count} datasets": {
|
||||
"other": "来自 {count} 个数据集"
|
||||
},
|
||||
"switch on a dataset to load features": "开启一个数据集以加载要素",
|
||||
"Switched on by this instance · part of the CCTV layer": "已由此实例开启 · 属于 CCTV 图层",
|
||||
"Operator opt-in: set {env}=1 on the server": "需运营者启用:在服务器上设置 {env}=1",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE",
|
||||
"Turned off by this instance's operator": "已被此实例的运营者关闭",
|
||||
"Needs a provider key on the server": "需要在服务器上配置提供方密钥",
|
||||
"Needs this app's server · not available in a static build": "需要此应用的服务器 · 静态构建中不可用",
|
||||
"Checking what this instance serves…": "正在检查此实例提供的内容…",
|
||||
"Regional": "区域",
|
||||
"Orthophoto": "正射影像",
|
||||
"Satellite mosaic": "卫星镶嵌图",
|
||||
"Oblique": "倾斜影像",
|
||||
"Historic imagery": "历史影像",
|
||||
"True orthophoto": "真正射影像",
|
||||
"Type": "类型",
|
||||
"Resolution": "分辨率",
|
||||
"Flown": "航摄时间",
|
||||
"Updates": "更新频率",
|
||||
"Ortho quality": "正射质量",
|
||||
"DATA LIBRARY": "数据资源库",
|
||||
"Close data library": "关闭数据资源库",
|
||||
"Data Library at a glance": "数据资源库概览",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心图层之外的实时数据源。每个数据源开启时直接从其提供方获取;本应用不存储任何数据。添加后,它会出现在数据图层的“资源库”分组中。",
|
||||
"Name, provider or country": "名称、提供方或国家",
|
||||
"Theme": "主题",
|
||||
"All themes": "全部主题",
|
||||
"Only what this instance serves": "仅显示此实例提供的内容",
|
||||
"OPT-IN": "需启用",
|
||||
"KEY": "需密钥",
|
||||
"KEYLESS": "免密钥",
|
||||
"ZOOM": "缩放",
|
||||
"Zoom to {name}": "缩放至 {name}",
|
||||
"WORKING…": "处理中…",
|
||||
"ADD": "添加",
|
||||
"Remove {name} from Data Layers": "从数据图层中移除 {name}",
|
||||
"Add {name} to Data Layers": "将 {name} 添加到数据图层",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "确认 · 全部移除 {count} 项"
|
||||
},
|
||||
"REMOVE ALL": "全部移除",
|
||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库",
|
||||
"The globe stopped drawing": "地球已停止绘制",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
|
||||
|
||||
+268
-160
@@ -1,60 +1,60 @@
|
||||
{
|
||||
"News feed unavailable": "新聞來源無法使用",
|
||||
"News feed unavailable": "新聞源不可用",
|
||||
"Developing: {name}": "事態發展:{name}",
|
||||
"{count} events today": {
|
||||
"other": "今日 {count} 起事件"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"other": "今日 {count} 則報導"
|
||||
"other": "今日 {count} 條報道"
|
||||
},
|
||||
"Disputed: {name}": "爭議地區:{name}",
|
||||
"Video": "影片",
|
||||
"Video": "視頻",
|
||||
"Photos": "照片",
|
||||
"Claimed by {who}": "{who}聲稱",
|
||||
"Claimed by {who}": "{who} 聲稱",
|
||||
"Claimed": "聲稱",
|
||||
"Not yet confirmed by others": "尚未獲他方證實",
|
||||
"Reported by {side} sources": "{side}方來源報導",
|
||||
"Reported by {sides}": "報導來源:{sides}",
|
||||
"{side} sources": "{side}方來源",
|
||||
"Reported by {side} sources": "報道來源:{side}",
|
||||
"Reported by {sides}": "報道來源:{sides}",
|
||||
"{side} sources": "{side}",
|
||||
"Official": "官方",
|
||||
"Reported by both sides": "雙方均有報導",
|
||||
"Independently reported": "有獨立來源報導",
|
||||
"Reported by both sides": "雙方均有報道",
|
||||
"Independently reported": "有獨立來源報道",
|
||||
"Single source": "單一來源",
|
||||
"International": "國際",
|
||||
"Independent": "獨立",
|
||||
"Unattributed": "未註明來源",
|
||||
"Outside party": "外部方",
|
||||
"Outside party": "第三方",
|
||||
"Ukraine–Russia": "烏克蘭–俄羅斯",
|
||||
"Israel–Gaza–Lebanon": "以色列–加薩–黎巴嫩",
|
||||
"Iran–Hormuz": "伊朗–荷莫茲",
|
||||
"Yemen–Red Sea": "葉門–紅海",
|
||||
"Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩",
|
||||
"Iran–Hormuz": "伊朗–霍爾木茲",
|
||||
"Yemen–Red Sea": "也門–紅海",
|
||||
"Sudan": "蘇丹",
|
||||
"Sahel": "薩赫爾",
|
||||
"Sahel": "薩赫勒",
|
||||
"Elsewhere": "其他地區",
|
||||
"Strike": "打擊",
|
||||
"Alert": "警報",
|
||||
"Air defence": "防空",
|
||||
"Ground fighting": "地面戰鬥",
|
||||
"Casualties": "傷亡",
|
||||
"Humanitarian": "人道",
|
||||
"Humanitarian": "人道主義",
|
||||
"Disaster": "災害",
|
||||
"Protest": "抗議",
|
||||
"Political": "政治",
|
||||
"No physical event": "無實際事件",
|
||||
"Minor incident": "輕微事件",
|
||||
"Significant": "重大",
|
||||
"Major": "嚴重",
|
||||
"Significant": "較大",
|
||||
"Major": "重大",
|
||||
"age|now": "剛剛",
|
||||
"{minutes} min": "{minutes} 分鐘",
|
||||
"{hours} h": "{hours} 小時",
|
||||
"{minutes} min": "{minutes} 分鐘前",
|
||||
"{hours} h": "{hours} 小時前",
|
||||
"{count} sources": {
|
||||
"other": "{count} 個來源"
|
||||
},
|
||||
"News feed not configured on this server": "此伺服器未設定新聞來源",
|
||||
"Malformed news hotspots response": "新聞熱點回應格式錯誤",
|
||||
"Malformed news feed response": "新聞來源回應格式錯誤",
|
||||
"News feed not configured on this server": "此服務器未配置新聞源",
|
||||
"Malformed news hotspots response": "新聞熱點響應格式錯誤",
|
||||
"Malformed news feed response": "新聞源響應格式錯誤",
|
||||
"{place} (placed by model)": "{place}(由模型定位)",
|
||||
"English headline by the model": "模型產生的英文標題",
|
||||
"English headline by the model": "模型生成的英文標題",
|
||||
"Not placed on the map": "未在地圖上標出",
|
||||
"Close": "關閉",
|
||||
"Any severity": "任意嚴重程度",
|
||||
@@ -72,16 +72,16 @@
|
||||
"Hotspots": "熱點",
|
||||
"Severity": "嚴重程度",
|
||||
"Category": "類別",
|
||||
"Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導",
|
||||
"Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報道",
|
||||
"Latest news": "最新新聞",
|
||||
"No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞",
|
||||
"Load older news": "載入更早的新聞",
|
||||
"Load older news": "加載更早的新聞",
|
||||
"How to read the map": "如何解讀地圖",
|
||||
"Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方或獨立來源均有報導。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的嚴重或快速發展的事件。",
|
||||
"Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報導是否屬實。",
|
||||
"Exit cockpit to fly to a news report": "退出駕駛艙以飛往新聞報導",
|
||||
"Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖標:事件類型",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方均有報道,或有獨立來源報道。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的重大或快速發展的事件。",
|
||||
"Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報道是否屬實。",
|
||||
"Exit cockpit to fly to a news report": "請先退出座艙,再飛往新聞報道地點",
|
||||
"News items in the feed": "列表中的新聞數",
|
||||
"Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。",
|
||||
"Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。",
|
||||
@@ -407,7 +407,7 @@
|
||||
"LOADING": "加載中",
|
||||
"DEGRADED": "降級",
|
||||
"STALE": "過期",
|
||||
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
|
||||
"SIGNAL LOST · {time}": "信號丟失 · {time}",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "備用",
|
||||
"view band|STREET": "街道",
|
||||
@@ -504,7 +504,7 @@
|
||||
"Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)",
|
||||
"Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形",
|
||||
"More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制台註冊一個應用,並粘貼其 Client Token",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。",
|
||||
"Air quality request failed": "空氣質量請求失敗",
|
||||
"No readings available": "暫無讀數",
|
||||
@@ -598,7 +598,7 @@
|
||||
"FOV {value}° (DATASHEET)": "FOV {value}°(規格書)",
|
||||
"FOV {value}°": "FOV {value}°",
|
||||
"SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)",
|
||||
"COVERAGE {value}km²": "覆蓋 {value}km²",
|
||||
"COVERAGE {value}km²": "覆蓋 {value} km²",
|
||||
"OVERLAP {count} cams": "重疊 {count} 個攝像頭",
|
||||
"ISOLATED VIEW": "獨立視角",
|
||||
"PROJ MONITOR": "投影:監視器",
|
||||
@@ -615,8 +615,8 @@
|
||||
"Hurricane": "颶風",
|
||||
"Tropical storm": "熱帶風暴",
|
||||
"Tropical depression": "熱帶低壓",
|
||||
"Subtropical storm": "亞熱帶風暴",
|
||||
"Subtropical depression": "亞熱帶低壓",
|
||||
"Subtropical storm": "副熱帶風暴",
|
||||
"Subtropical depression": "副熱帶低壓",
|
||||
"Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致",
|
||||
"Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告",
|
||||
"Track/cone unavailable": "路徑/預報不確定範圍不可用",
|
||||
@@ -803,7 +803,7 @@
|
||||
"Low-accuracy score 2–10%": "低精度評分 2–10%",
|
||||
"Low-accuracy score > 10%": "低精度評分 > 10%",
|
||||
"GNSS integrity source unavailable": "GNSS 完好性數據源不可用",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格",
|
||||
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內瀏覽過的視野中的網格",
|
||||
"Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試",
|
||||
"Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試",
|
||||
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。",
|
||||
@@ -861,18 +861,18 @@
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地點待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒",
|
||||
"Release the camera from {name}": "讓鏡頭離開{name}",
|
||||
"SITES STANDBY": "設施待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用地圖標註設施以接收設施提醒",
|
||||
"Release the camera from {name}": "讓鏡頭離開 {name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一顆衛星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"Return to {name}": "返回 {name}",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地點",
|
||||
"Ride {name}": "乘坐 {name}",
|
||||
"Unnamed site": "未命名設施",
|
||||
"OFF-NADIR {angle}°": "偏離天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "讓鏡頭持續對準{name}",
|
||||
"Keep the camera on {name}": "讓鏡頭持續對準 {name}",
|
||||
"ZOOM {zoom}×": "變焦 {zoom}×",
|
||||
"SWATH {value} KM": "幅寬 {value} km",
|
||||
"SWATH ≈ {value} KM": "幅寬 ≈ {value} km",
|
||||
@@ -880,10 +880,10 @@
|
||||
"ORBITAL VIEW · NADIR": "軌道視圖 · 天底",
|
||||
"ORBITAL VELOCITY": "軌道速度",
|
||||
"SUB-SATELLITE POINT": "星下點",
|
||||
"NORTH UP": "北向上",
|
||||
"CURRENTLY OVER": "目前上空",
|
||||
"{division}, {country}": "{division},{country}",
|
||||
"{region} · near {place}": "{region} · {place}附近",
|
||||
"NORTH UP": "正北朝上",
|
||||
"CURRENTLY OVER": "當前飛越",
|
||||
"{division}, {country}": "{country} {division}",
|
||||
"{region} · near {place}": "{region} · {place} 附近",
|
||||
"NORAD {id}": "NORAD {id}",
|
||||
"EPOCH {time}": "曆元 {time}",
|
||||
"TLE": "TLE",
|
||||
@@ -1163,14 +1163,14 @@
|
||||
"Hybrid": "混合",
|
||||
"Lines": "線條",
|
||||
"Dots": "圓點",
|
||||
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
|
||||
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
|
||||
"Road lines colored by live flow: green to red": "按實時路況著色的道路線條:從綠到紅",
|
||||
"Moving dots colored by live flow": "按實時路況著色的移動圓點",
|
||||
"Moving dots": "移動圓點",
|
||||
"TomTom key unavailable": "TomTom 密鑰不可用",
|
||||
"TomTom daily budget reached": "已達 TomTom 每日配額",
|
||||
"Monthly traffic allowance used": "本月交通流量額度已用完",
|
||||
"Traffic rate limit reached": "已達交通資料請求頻率上限",
|
||||
"Traffic service unavailable": "交通服務無法使用",
|
||||
"Monthly traffic allowance used": "本月交通數據配額已用完",
|
||||
"Traffic rate limit reached": "已達到交通數據請求頻率上限",
|
||||
"Traffic service unavailable": "交通服務不可用",
|
||||
"TomTom upstream unreachable": "TomTom 上游服務無法連接",
|
||||
"TomTom flow unavailable": "TomTom 路況不可用",
|
||||
"SIMULATED — {reason}": "模擬——{reason}",
|
||||
@@ -1668,7 +1668,7 @@
|
||||
"other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小"
|
||||
"other": "視野外還有 {count} 個攝像頭 · 請平移或縮小地圖"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
|
||||
@@ -1694,7 +1694,7 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。",
|
||||
"ACQUIRING REGIONAL NEWS": "正在獲取地區新聞",
|
||||
"REGIONAL NEWS UNAVAILABLE": "地區新聞不可用",
|
||||
"CACHED · REFRESH FAILED": "快取 · 重新整理失敗",
|
||||
"CACHED · REFRESH FAILED": "緩存 · 刷新失敗",
|
||||
"RESOLVING REGION": "正在解析區域",
|
||||
"REGION UNAVAILABLE": "區域不可用",
|
||||
"NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞",
|
||||
@@ -1702,13 +1702,13 @@
|
||||
"{value} KM/H": "{value} km/h",
|
||||
"CLOUD {pct}%": "雲量 {pct}%",
|
||||
"CLOUD UNKNOWN": "雲量未知",
|
||||
"{source} · CACHED {age}": "{source} · 快取 {age}",
|
||||
"{source} · CACHED {age}": "{source} · 緩存 {age}",
|
||||
"REGIONAL NEWS": "地區新聞",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飛機信息不完整 · 不代表安全無虞",
|
||||
"CONTEXT STANDBY": "態勢待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
|
||||
@@ -1741,8 +1741,8 @@
|
||||
"ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向",
|
||||
"ROUTE DATA UNAVAILABLE": "航線數據不可用",
|
||||
"CONTACT NAVIGATION OFF": "目標導航已關閉",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上",
|
||||
"{range} NM": "{range}海里",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛臺保持在所選船舶上",
|
||||
"{range} NM": "{range} 海里",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報",
|
||||
"DEST {bearing}": "目的地 {bearing}",
|
||||
"Disable cockpit weather effects": "停用座艙天氣效果",
|
||||
@@ -1862,7 +1862,7 @@
|
||||
"GNSS Integrity": "GNSS 完好性",
|
||||
"Rain radar": "降雨雷達",
|
||||
"Lightning density": "閃電密度",
|
||||
"Cyclone advisories": "氣旋預警",
|
||||
"Cyclone advisories": "氣旋公告",
|
||||
"ALPR Cameras": "ALPR 攝像頭",
|
||||
"Live Vessels": "實時船舶",
|
||||
"Bike Share": "共享單車",
|
||||
@@ -1887,7 +1887,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天氣",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音訊",
|
||||
"Audio": "音頻",
|
||||
"Other layers": "其他圖層",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對",
|
||||
@@ -2063,8 +2063,8 @@
|
||||
"coverage|partial": "部分",
|
||||
"unknown": "未知",
|
||||
"no imagery": "無影像",
|
||||
"{percent}% cloud": "{percent}% 雲量",
|
||||
"{min}–{max}% cloud": "{min}–{max}% 雲量",
|
||||
"{percent}% cloud": "雲量 {percent}%",
|
||||
"{min}–{max}% cloud": "雲量 {min}–{max}%",
|
||||
"NO BOX": "無框選",
|
||||
"SEARCHING": "搜索中",
|
||||
"{count} DAYS": {
|
||||
@@ -2576,7 +2576,7 @@
|
||||
"DONE": "完成",
|
||||
"HERE": "此處",
|
||||
"Aircraft cockpit view": "飛機座艙視圖",
|
||||
"Ship bridge view": "船舶駕駛台視圖",
|
||||
"Ship bridge view": "船舶駕駛臺視圖",
|
||||
"Satellite orbital view": "衛星軌道視圖",
|
||||
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"LOOK {elevation}": "視線 {elevation}",
|
||||
"Estimated destination direction": "預估目的地方向",
|
||||
"DEST ---°": "目的地 ---°",
|
||||
"BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方",
|
||||
"BRIDGE WINDOW · FWD": "駕駛臺窗口 · 前方",
|
||||
"OPTICAL PLANE · 01": "光學平面 · 01",
|
||||
"HEADING HOLD · AIS": "船首向保持 · AIS",
|
||||
"NO HEADING · AIS": "無船首向 · AIS",
|
||||
@@ -2597,7 +2597,7 @@
|
||||
"SPEED OVER GROUND · KTS": "對地航速 · kn",
|
||||
"ALTITUDE · FT": "高度 · ft",
|
||||
"FIRST PERSON": "第一人稱",
|
||||
"BRIDGE VIEW": "駕駛台視圖",
|
||||
"BRIDGE VIEW": "駕駛臺視圖",
|
||||
"LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準",
|
||||
"Cockpit vision style": "座艙視覺樣式",
|
||||
"Previous cockpit vision style": "上一個座艙視覺樣式",
|
||||
@@ -2606,7 +2606,7 @@
|
||||
"Next cockpit vision style": "下一個座艙視覺樣式",
|
||||
"Next vision style": "下一個視覺樣式",
|
||||
"Current aircraft heading": "飛機當前航向",
|
||||
"Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首",
|
||||
"Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛臺視圖轉回船首",
|
||||
"Look ahead (Home or 0)": "看向前方(Home 或 0)",
|
||||
"LOOK AHEAD": "看向前方",
|
||||
"Current vessel heading": "船舶當前船首向",
|
||||
@@ -2619,12 +2619,12 @@
|
||||
"Contact cockpit summary": "目標座艙摘要",
|
||||
"CONTACT": "目標",
|
||||
"CONTACTS · 250 KM": "目標 · 250 km",
|
||||
"RINGS {spacing} NM": "距標圈 {spacing}海里",
|
||||
"RINGS {spacing} NM": "距標圈 {spacing} 海里",
|
||||
"{count} CONTACTS": {
|
||||
"other": "{count} 個目標"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。",
|
||||
"Bridge radar": "駕駛台雷達",
|
||||
"Bridge radar": "駕駛臺雷達",
|
||||
"Contact navigation": "目標導航",
|
||||
"CONTEXT ONLY": "僅供參考",
|
||||
"Nearby cohort counts": "附近各類目標數量",
|
||||
@@ -2680,10 +2680,10 @@
|
||||
"Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖",
|
||||
"RESET": "重置",
|
||||
"Exit cockpit view": "退出座艙視圖",
|
||||
"Exit bridge view": "退出駕駛台視圖",
|
||||
"Exit bridge view": "退出駕駛臺視圖",
|
||||
"Exit orbital view": "退出軌道視圖",
|
||||
"EXIT COCKPIT": "退出座艙",
|
||||
"EXIT BRIDGE": "退出駕駛台",
|
||||
"EXIT BRIDGE": "退出駕駛臺",
|
||||
"EXIT ORBIT": "退出軌道視圖",
|
||||
"Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件",
|
||||
"Expand Visual Presets": "展開視覺預設",
|
||||
@@ -2708,7 +2708,7 @@
|
||||
"WEATHER": "天氣",
|
||||
"Active weather products": "已啟用的天氣產品",
|
||||
"RECENT IMAGERY": "近期影像",
|
||||
"Imagery days in the box": "視野內的影像天數",
|
||||
"Imagery days in the box": "框選區域內的影像天數",
|
||||
"CONTEXT": "態勢",
|
||||
"Context mode": "態勢模式",
|
||||
"CONTACTS": "目標",
|
||||
@@ -2717,7 +2717,7 @@
|
||||
"SELECT CONTEXT": "選擇態勢",
|
||||
"Contact Context actions": "目標態勢操作",
|
||||
"COCKPIT": "座艙",
|
||||
"BRIDGE": "駕駛台",
|
||||
"BRIDGE": "駕駛臺",
|
||||
"SEARCH NEARBY SITES": "搜索附近設施",
|
||||
"Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "目標態勢已關閉",
|
||||
@@ -2726,7 +2726,7 @@
|
||||
"AVAILABLE MISSIONS": "可用任務",
|
||||
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
|
||||
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
|
||||
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務不可用:無法加載任務索引。請稍後重試。",
|
||||
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
|
||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
|
||||
"Internet radio and local SDR": "網絡電臺與本地 SDR",
|
||||
@@ -2984,7 +2984,7 @@
|
||||
"Take the exit {direction}": "從出口{direction}駛出",
|
||||
"Link shared": "鏈接已分享",
|
||||
"This share link can't be opened": "無法打開此分享鏈接",
|
||||
"Shared vessel is unavailable": "分享的船隻不可用",
|
||||
"Shared vessel is unavailable": "分享的船舶不可用",
|
||||
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
||||
"Press Enter to try again.": "按 Enter 重試。",
|
||||
"Recent speech": "最近語音",
|
||||
@@ -3078,7 +3078,7 @@
|
||||
"Detection confidence": "探測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"Voice place search (needs an Overpass server)": "語音地點搜索(需要 Overpass 服務器)",
|
||||
"OSM Places": "OSM 地點",
|
||||
@@ -3212,64 +3212,64 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每條報道都鏈接到其發布者;地點和類別由文本和數據自動讀取,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報道是否屬實。",
|
||||
"Economy": "經濟",
|
||||
"News from {area}": "{area}新聞",
|
||||
"Within {km} km of the view centre": "視圖中心 {km} 公里範圍內",
|
||||
"{region}, {country}": "{country}{region}",
|
||||
"News from {area}": "{area} 新聞",
|
||||
"Within {km} km of the view centre": "視野中心 {km} km 範圍內",
|
||||
"{region}, {country}": "{country} {region}",
|
||||
"via {source}": "來自 {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的本地:視野中國家的媒體當天的新聞",
|
||||
"{provider} refused its key": "{provider} 拒絕了金鑰",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的“本地”分區:當前視野所在國家的媒體當天的新聞",
|
||||
"{provider} refused its key": "{provider} 拒絕了密鑰",
|
||||
"Check {envVars} in Provider Settings": "請在服務商設置中檢查 {envVars}",
|
||||
"{provider} key required": "需要 {provider} 金鑰",
|
||||
"{provider} key required": "需要 {provider} 密鑰",
|
||||
"{provider} limit reached for today": "{provider} 已達今日上限",
|
||||
"News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費方案",
|
||||
"News key required": "需要新聞金鑰",
|
||||
"News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費套餐",
|
||||
"News key required": "需要新聞密鑰",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列",
|
||||
"Worldwide": "全球",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以後",
|
||||
"Before 1900": "1900 年以前",
|
||||
"After 1945": "1945 年以後",
|
||||
"Date unknown": "日期不詳",
|
||||
"Position: centre of the protected area": "位置:保護區中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(誤差約 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "實測位置(誤差約 {m} 公尺)",
|
||||
"Vessel: {value}": "船隻:{value}",
|
||||
"Position approximate (within about {km} km)": "大致位置(誤差約 {km} km)",
|
||||
"Position surveyed (within about {m} m)": "實測位置(誤差約 {m} m)",
|
||||
"Vessel: {value}": "船型:{value}",
|
||||
"What happened: {value}": "經過:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 公尺({quality})",
|
||||
"Depth: {m} m": "深度:{m} 公尺",
|
||||
"Depth: {m} m ({quality})": "深度:{m} m({quality})",
|
||||
"Depth: {m} m": "深度:{m} m",
|
||||
"Notes: {value}": "備註:{value}",
|
||||
"Cargo or finds: {value}": "貨物或出水文物:{value}",
|
||||
"Protected wreck": "受保護沉船",
|
||||
"Source: {value}": "來源:{value}",
|
||||
"Click the card to open EMODnet ↗": "點擊卡片開啟 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "點擊卡片開啟來源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 無法使用;僅顯示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 無法使用;僅顯示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船資料無法使用",
|
||||
"Click the card to open EMODnet ↗": "點擊卡片打開 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "點擊卡片打開來源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 不可用;僅顯示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 不可用;僅顯示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船數據不可用",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(歐洲海域)· Wikidata(全球)",
|
||||
"{count} wrecks": {
|
||||
"other": "{count} 艘沉船"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "罹難:{count}",
|
||||
"Survivors: {count}": "倖存者:{count}",
|
||||
"Click the card to open the article ↗": "點擊卡片開啟文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。",
|
||||
"Fatalities: {count}": "遇難人數:{count}",
|
||||
"Survivors: {count}": "倖存人數:{count}",
|
||||
"Click the card to open the article ↗": "點擊卡片打開文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨遇難人數增加。點擊標記查看卡片。",
|
||||
"All years": "所有年份",
|
||||
"Period": "時段",
|
||||
"From": "起",
|
||||
"To": "迄",
|
||||
"To": "止",
|
||||
"First year of the shipwreck period": "沉船時段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船時段的結束年份",
|
||||
"SIGHTING": "目擊",
|
||||
"Show {name} sighting": "顯示{name}目擊",
|
||||
"Show {name} sighting": "顯示 {name} 的目擊記錄",
|
||||
"seen {age} ago": "{age}前目擊",
|
||||
"observed {age} ago": "{age}前觀察",
|
||||
"Be Whale Wise": "負責任地賞鯨",
|
||||
"Reported by {name} ({entity})": "通報方 {name}({entity})",
|
||||
"Reported by {name}": "通報方 {name}",
|
||||
"observed {age} ago": "{age}前觀察到",
|
||||
"Be Whale Wise": "負責任地觀鯨",
|
||||
"Reported by {name} ({entity})": "報告方:{name}({entity})",
|
||||
"Reported by {name}": "報告方:{name}",
|
||||
"Salish Sea": "薩利希海",
|
||||
"Birds": "鳥類",
|
||||
"Mammals": "哺乳類",
|
||||
@@ -3282,18 +3282,18 @@
|
||||
"Fungi": "真菌",
|
||||
"Other animals": "其他動物",
|
||||
"Other or unknown": "其他或未知",
|
||||
"{label} {detail} — click to retry": "{label} {detail} — 點擊重試",
|
||||
"Wildlife sightings source unavailable": "野生生物目擊來源無法使用",
|
||||
"Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊",
|
||||
"{label} {detail} — click to retry": "{label} {detail}——點擊重試",
|
||||
"Wildlife sightings source unavailable": "野生生物目擊來源不可用",
|
||||
"Show only community-confirmed (research-grade) sightings": "僅顯示社區確認(研究級)的目擊",
|
||||
"No recent sightings in view": "視野內無近期目擊",
|
||||
"Acartia feed unavailable": "Acartia資料來源無法使用",
|
||||
"Acartia feed unavailable": "Acartia 數據源不可用",
|
||||
"Show all taxa": "顯示所有分類群",
|
||||
"BIRDS": "鳥類",
|
||||
"Show only birds": "僅顯示鳥類",
|
||||
"MAMMALS": "哺乳類",
|
||||
"Show only mammals": "僅顯示哺乳類",
|
||||
"WHALES": "鯨類",
|
||||
"Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)",
|
||||
"Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7 天)",
|
||||
"INSECTS": "昆蟲",
|
||||
"Show only insects": "僅顯示昆蟲",
|
||||
"PLANTS": "植物",
|
||||
@@ -3301,97 +3301,114 @@
|
||||
"All rights reserved": "保留所有權利",
|
||||
"Cetacean": "鯨類",
|
||||
"Wildlife Sightings": "野生生物目擊",
|
||||
"Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點",
|
||||
"Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域",
|
||||
"Exit cockpit to fly to a sighting": "請先退出座艙,再飛往目擊地點",
|
||||
"Exit cockpit to fly to a sighting area": "請先退出座艙,再飛往目擊區域",
|
||||
"Wildlife": "野生生物",
|
||||
"Zoom in for local sightings": "放大查看當地目擊",
|
||||
"Loading photo...": "正在載入照片…",
|
||||
"Photo unavailable": "照片無法使用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至0.2°網格",
|
||||
"Photo of {name}": "{name}的照片",
|
||||
"Loading photo...": "正在加載照片…",
|
||||
"Photo unavailable": "照片不可用",
|
||||
"Location obscured to a 0.2° cell": "位置已模糊至 0.2° 網格",
|
||||
"Photo of {name}": "{name} 的照片",
|
||||
"Acartia": "Acartia",
|
||||
"CC BY 4.0": "CC BY 4.0",
|
||||
"{count} sightings in view": "視野內有 {count} 次目擊",
|
||||
"Close sighting": "關閉目擊紀錄",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。",
|
||||
"Close sighting": "關閉目擊記錄",
|
||||
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近 48 小時內觀察到的野生生物(鯨類為 7 天)。顏色與徽章圖標表示分類群。敏感物種僅以 0.2° 網格顯示;Acartia 提供的薩利希海海洋哺乳動物目擊位置四捨五入到 0.01°。並非種群普查。",
|
||||
"Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}",
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
|
||||
"{days}d": "{days}天",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各記錄單獨授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海)· 各記錄單獨授權",
|
||||
"{days}d": "{days} 天",
|
||||
"All markers": "所有標記",
|
||||
"Show markers of every age": "顯示所有時間的標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "僅顯示 {days} 天內的事件;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
|
||||
"Close camera preview": "關閉攝影機預覽",
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days} 天內 · 保留無日期標記",
|
||||
"Close camera preview": "關閉攝像頭預覽",
|
||||
"Refresh map still": "刷新地圖靜止畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜止畫面通過單獨的請求獲取,可能顯示不同的時刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "視頻從 Québec 511 播放,Québec 511 會看到你的 IP 地址。",
|
||||
"Press OPEN to load the photo": "點擊“打開”加載照片",
|
||||
"Captured {month}": "拍攝於 {month}",
|
||||
"this spot": "此處",
|
||||
"the view centre": "視圖中心",
|
||||
"the view centre": "視野中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切換地圖",
|
||||
"Opening imagery": "正在打開影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:點擊“{open}”加載",
|
||||
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
|
||||
"STATIONS": "太空站",
|
||||
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
|
||||
"STATIONS": "空間站",
|
||||
"Crewed stations and the craft that visit them": "載人空間站及來訪飛行器",
|
||||
"IMAGING": "成像",
|
||||
"Earth observation: optical, radar, weather and science": "對地觀測:光學、雷達、氣象與科學",
|
||||
"TELECOM": "通訊",
|
||||
"Communications and navigation satellites, Starlink excepted": "通訊與導航衛星(不含 Starlink)",
|
||||
"TELECOM": "通信",
|
||||
"Communications and navigation satellites, Starlink excepted": "通信與導航衛星(不含 Starlink)",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Starlink 星座(數千個額外點位)",
|
||||
"PROBES": "探測器",
|
||||
"Space science, technology demonstration and other payloads": "太空科學、技術驗證及其他酬載",
|
||||
"Space science, technology demonstration and other payloads": "空間科學、技術驗證及其他載荷",
|
||||
"DEBRIS": "碎片",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭殘骸、碎片與失效衛星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類可能落後:SATCAT 為快取副本",
|
||||
"Classes may lag: SATCAT unavailable": "分類可能落後:SATCAT 無法取得",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭箭體、碎片和失效衛星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類可能滯後:SATCAT 為緩存副本",
|
||||
"Classes may lag: SATCAT unavailable": "分類可能滯後:SATCAT 不可用",
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 給出每個物體的類型和運行狀態。沒有最新副本時,分類僅依據 UCS 數據庫、名稱和 CelesTrak 分組,因此此後已失效的衛星可能仍顯示為在軌工作。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "感測器幅寬 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km",
|
||||
"SENSOR": "感測器",
|
||||
"Zoom to the imaging sensor swath": "縮放到成像感測器幅寬",
|
||||
"<1 H OLD": "不到1小時前",
|
||||
"{value} H OLD": "{value}小時前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"SENSOR SWATH {value} KM": "傳感器幅寬 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "傳感器幅寬 ≈ {value} km",
|
||||
"SENSOR": "傳感器",
|
||||
"Zoom to the imaging sensor swath": "縮放到成像傳感器幅寬",
|
||||
"<1 H OLD": "不到 1 小時前",
|
||||
"{value} H OLD": "{value} 小時前",
|
||||
"{value} D OLD": "{value} 天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航跡向上",
|
||||
"TRACK UP": "航跡朝上",
|
||||
"COURSE HOLD · AIS": "對地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "縮小 (−)",
|
||||
"Zoom in (+)": "放大(+)",
|
||||
"Zoom out (−)": "縮小(−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時",
|
||||
"Back to live": "返回實時",
|
||||
"Live": "實時",
|
||||
"1 h/s": "1 小時/秒",
|
||||
"1 d/s": "1 天/秒",
|
||||
"1 wk/s": "1 週/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滾動或雙指縮放,拖動以移動 · 雙擊查看全部",
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Rail": "鐵路",
|
||||
"Urban rail": "城市軌道",
|
||||
"Local rail": "市郊鐵路",
|
||||
"Regional rail": "區域鐵路",
|
||||
"National rail": "長途鐵路",
|
||||
"International rail": "國際鐵路",
|
||||
"Hide {category}": "隱藏{category}",
|
||||
"Show {category}": "顯示{category}",
|
||||
"Live GPS": "實時 GPS",
|
||||
"Estimated from station reports": "根據車站報告估算",
|
||||
"To {headsign}": "開往 {headsign}",
|
||||
"Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}",
|
||||
"Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}",
|
||||
"(+{minutes} min late)": "(晚點 {minutes} 分鐘)",
|
||||
"Exit cockpit to fly to a train": "請先退出座艙,再飛往列車",
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"Photo: {photographer} · {licence}": "照片:{photographer} · {licence}",
|
||||
"Photo · {licence}": "照片 · {licence}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "分享的航班目前無位置更新——顯示其最後出現的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "分享的航班目前無位置更新——顯示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "分享的軍用航班目前無位置更新——顯示其最後出現的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "分享的軍用航班目前無位置更新——顯示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前無位置更新——顯示其最後出現的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前無位置更新——顯示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "分享的衛星目前無位置更新——顯示其最後出現的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "分享的衛星目前無位置更新——顯示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
@@ -3411,6 +3428,97 @@
|
||||
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})",
|
||||
"tiles unavailable": "瓦片不可用",
|
||||
"zoom in to load this layer": "放大以加載此圖層",
|
||||
"Zoom to layer": "縮放至圖層",
|
||||
"Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有數據的區域",
|
||||
"Remove from Data Layers": "從數據圖層中移除",
|
||||
"Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新添加",
|
||||
"Coverage outline": "覆蓋範圍輪廓",
|
||||
"DRAPED": "貼地",
|
||||
"Below 250 m": "低於 250 m",
|
||||
"250–500 m": "250–500 m",
|
||||
"500–1,000 m": "500–1,000 m",
|
||||
"1,000–2,000 m": "1,000–2,000 m",
|
||||
"2,000–3,000 m": "2,000–3,000 m",
|
||||
"3,000 m and above": "3,000 m 及以上",
|
||||
"Exit cockpit to fly to a layer": "請先退出座艙,再飛往圖層",
|
||||
"Library": "資源庫",
|
||||
"Environment": "環境",
|
||||
"Imagery": "影像",
|
||||
"Demographics": "人口統計",
|
||||
"DATASET": "數據集",
|
||||
"TILES": "瓦片",
|
||||
"PACK": "數據包",
|
||||
"INCUBATING": "孵化中",
|
||||
"COMMUNITY": "社區",
|
||||
"VERIFIED": "已驗證",
|
||||
"ARCHIVED": "已歸檔",
|
||||
"Datasets": "數據集",
|
||||
"{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}",
|
||||
"{count} datasets": {
|
||||
"other": "{count} 個數據集"
|
||||
},
|
||||
"{count} tile layers": {
|
||||
"other": "{count} 個瓦片圖層"
|
||||
},
|
||||
"{count} packs": {
|
||||
"other": "{count} 個數據包"
|
||||
},
|
||||
"Themes": "主題",
|
||||
"Added": "已添加",
|
||||
"{count} on the map": {
|
||||
"other": "地圖上 {count} 個"
|
||||
},
|
||||
"none yet": "暫無",
|
||||
"Map features": "地圖要素",
|
||||
"from {count} datasets": {
|
||||
"other": "來自 {count} 個數據集"
|
||||
},
|
||||
"switch on a dataset to load features": "開啟一個數據集以加載要素",
|
||||
"Switched on by this instance · part of the CCTV layer": "已由此實例開啟 · 屬於 CCTV 圖層",
|
||||
"Operator opt-in: set {env}=1 on the server": "需運營者啟用:在服務器上設置 {env}=1",
|
||||
"Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需運營者啟用:在服務器上將 {id} 添加到 GEV_LIBRARY_ENABLE",
|
||||
"Turned off by this instance's operator": "已被此實例的運營者關閉",
|
||||
"Needs a provider key on the server": "需要在服務器上配置提供方密鑰",
|
||||
"Needs this app's server · not available in a static build": "需要此應用的服務器 · 靜態構建中不可用",
|
||||
"Checking what this instance serves…": "正在檢查此實例提供的內容…",
|
||||
"Regional": "區域",
|
||||
"Orthophoto": "正射影像",
|
||||
"Satellite mosaic": "衛星鑲嵌圖",
|
||||
"Oblique": "傾斜影像",
|
||||
"Historic imagery": "歷史影像",
|
||||
"True orthophoto": "真正射影像",
|
||||
"Type": "類型",
|
||||
"Resolution": "分辨率",
|
||||
"Flown": "航攝時間",
|
||||
"Updates": "更新頻率",
|
||||
"Ortho quality": "正射質量",
|
||||
"DATA LIBRARY": "數據資源庫",
|
||||
"Close data library": "關閉數據資源庫",
|
||||
"Data Library at a glance": "數據資源庫概覽",
|
||||
"Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層之外的實時數據源。每個數據源開啟時直接從其提供方獲取;本應用不存儲任何數據。添加後,它會出現在數據圖層的“資源庫”分組中。",
|
||||
"Name, provider or country": "名稱、提供方或國家",
|
||||
"Theme": "主題",
|
||||
"All themes": "全部主題",
|
||||
"Only what this instance serves": "僅顯示此實例提供的內容",
|
||||
"OPT-IN": "需啟用",
|
||||
"KEY": "需密鑰",
|
||||
"KEYLESS": "免密鑰",
|
||||
"ZOOM": "縮放",
|
||||
"Zoom to {name}": "縮放至 {name}",
|
||||
"WORKING…": "處理中…",
|
||||
"ADD": "添加",
|
||||
"Remove {name} from Data Layers": "從數據圖層中移除 {name}",
|
||||
"Add {name} to Data Layers": "將 {name} 添加到數據圖層",
|
||||
"CONFIRM · REMOVE ALL {count}": {
|
||||
"other": "確認 · 全部移除 {count} 項"
|
||||
},
|
||||
"REMOVE ALL": "全部移除",
|
||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫",
|
||||
"The globe stopped drawing": "地球已停止繪製",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @module i18n/page
|
||||
* @description The page's interface language, for an application that
|
||||
* composes Core and offers its own language choice (a menu item, a settings
|
||||
* page) instead of the globe picker. It starts the language the standalone
|
||||
* application starts, keeps `<html lang>`/`<html dir>` and the page's
|
||||
* `data-i18n` markup in step, and switches in place, stored for the next
|
||||
* visit, exactly as the picker does. Guide: docs/I18N.md.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
LOCALE_NAMES,
|
||||
applyDocumentLanguage,
|
||||
isRtlLocale,
|
||||
normalizeLocale,
|
||||
resolveLocale,
|
||||
} from './locale.js';
|
||||
import {
|
||||
applyDocumentTranslations,
|
||||
getLocale,
|
||||
onLocaleChange,
|
||||
persistLocale,
|
||||
setLocale,
|
||||
} from './index.js';
|
||||
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
LOCALE_NAMES,
|
||||
applyDocumentTranslations,
|
||||
getLocale,
|
||||
isRtlLocale,
|
||||
onLocaleChange,
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the page in its language: `?lang=`, then the stored choice, then the
|
||||
* browser's languages, then English. Call it before any view renders, so
|
||||
* every lookup and `<html lang>`/`<html dir>` agree from the first frame; the
|
||||
* page's static `data-i18n` markup (a loading screen) is translated now.
|
||||
* @param {Document} [doc]
|
||||
* @param {Parameters<typeof resolveLocale>[0]} [inputs] Location, storage
|
||||
* and languages, for tests.
|
||||
* @returns {string} The active locale.
|
||||
*/
|
||||
export function startPageLocale(doc = globalThis.document, inputs) {
|
||||
const locale = setLocale(resolveLocale(inputs));
|
||||
applyDocumentLanguage(doc, locale);
|
||||
applyDocumentTranslations(doc);
|
||||
return locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `<html lang>`/`<html dir>` and the page's `data-i18n` markup in step
|
||||
* with every language switch.
|
||||
* @param {Document} [doc]
|
||||
* @returns {() => void} Stops following.
|
||||
*/
|
||||
export function followPageLocale(doc = globalThis.document) {
|
||||
return onLocaleChange(() => {
|
||||
applyDocumentLanguage(doc, getLocale());
|
||||
applyDocumentTranslations(doc);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the page to `locale` in place and keep it for the next visit. The
|
||||
* active language, or an unknown code that would fall back to it, changes
|
||||
* nothing.
|
||||
* @param {string} locale One of LOCALES.
|
||||
* @param {Parameters<typeof persistLocale>[1]} [options] Location, history
|
||||
* and storage, for tests.
|
||||
* @returns {'unchanged'|'switched'}
|
||||
*/
|
||||
export function switchLocale(locale, options) {
|
||||
const next = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
if (next === getLocale()) return 'unchanged';
|
||||
setLocale(persistLocale(next, options));
|
||||
return 'switched';
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// src/i18n/page.test.mjs — an application with its own language choice starts,
|
||||
// follows and switches the page's language as the globe picker does.
|
||||
import { afterEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LOCALE_STORAGE_KEY } from './locale.js';
|
||||
import {
|
||||
followPageLocale,
|
||||
getLocale,
|
||||
startPageLocale,
|
||||
switchLocale,
|
||||
} from './page.js';
|
||||
import { setLocale } from './index.js';
|
||||
|
||||
afterEach(() => setLocale('en'));
|
||||
|
||||
function memoryStorage() {
|
||||
const data = new Map();
|
||||
return {
|
||||
data,
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
};
|
||||
}
|
||||
|
||||
function page(elements = []) {
|
||||
return {
|
||||
documentElement: { lang: '', dir: '' },
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '[data-i18n]' ? elements : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A data-i18n element, as applyDocumentTranslations reads one. */
|
||||
function marked(text) {
|
||||
return {
|
||||
textContent: text,
|
||||
hasAttribute: (name) => name === 'data-i18n',
|
||||
getAttribute: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
test('the page starts in ?lang=, else the stored choice, with lang and dir set', () => {
|
||||
const doc = page();
|
||||
const storage = memoryStorage();
|
||||
storage.setItem(LOCALE_STORAGE_KEY, 'de');
|
||||
assert.equal(
|
||||
startPageLocale(doc, {
|
||||
location: { search: '?lang=ar' },
|
||||
storage,
|
||||
languages: ['ru'],
|
||||
}),
|
||||
'ar',
|
||||
);
|
||||
assert.equal(getLocale(), 'ar');
|
||||
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
|
||||
assert.equal(
|
||||
startPageLocale(doc, { location: { search: '' }, storage, languages: [] }),
|
||||
'de',
|
||||
);
|
||||
assert.deepEqual(doc.documentElement, { lang: 'de', dir: 'ltr' });
|
||||
});
|
||||
|
||||
test("the page's data-i18n markup is in its language from the start", () => {
|
||||
const close = marked('Close');
|
||||
startPageLocale(page([close]), {
|
||||
location: { search: '?lang=de' },
|
||||
storage: memoryStorage(),
|
||||
languages: [],
|
||||
});
|
||||
assert.equal(close.textContent, 'Schließen');
|
||||
});
|
||||
|
||||
test('a switch is stored, drops ?lang, and the followed page takes it', () => {
|
||||
const doc = page();
|
||||
const stop = followPageLocale(doc);
|
||||
const storage = memoryStorage();
|
||||
const replaced = [];
|
||||
const options = {
|
||||
location: { href: 'https://example.test/?lang=fr#view' },
|
||||
history: { replaceState: (_s, _t, url) => replaced.push(url) },
|
||||
storage,
|
||||
};
|
||||
assert.equal(switchLocale('he', options), 'switched');
|
||||
assert.equal(getLocale(), 'he');
|
||||
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'he');
|
||||
assert.deepEqual(replaced, ['https://example.test/#view']);
|
||||
assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' });
|
||||
stop();
|
||||
switchLocale('ja', options);
|
||||
assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' });
|
||||
});
|
||||
|
||||
test('choosing the active language, or one that falls back to it, changes nothing', () => {
|
||||
const storage = memoryStorage();
|
||||
const options = {
|
||||
location: { href: 'https://example.test/' },
|
||||
history: { replaceState() {} },
|
||||
storage,
|
||||
};
|
||||
assert.equal(switchLocale('en', options), 'unchanged');
|
||||
assert.equal(switchLocale('xx', options), 'unchanged');
|
||||
assert.equal(storage.data.size, 0);
|
||||
assert.equal(switchLocale('pt-BR', options), 'switched');
|
||||
assert.equal(getLocale(), 'pt');
|
||||
assert.equal(switchLocale('pt', options), 'unchanged');
|
||||
});
|
||||
@@ -244,7 +244,12 @@
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"COMMUNITY",
|
||||
"Regional",
|
||||
"OPT-IN",
|
||||
"{minutes} min",
|
||||
"1 min/s"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
@@ -404,7 +409,9 @@
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"Regional",
|
||||
"{datasets} · {tiles} · {packs}"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
@@ -619,7 +626,14 @@
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
"Photo © {photographer}",
|
||||
"Photo · {licence}",
|
||||
"PACK",
|
||||
"{count} packs",
|
||||
"Orthophoto",
|
||||
"Oblique",
|
||||
"Type",
|
||||
"{range} NM"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
@@ -822,7 +836,10 @@
|
||||
"SATELLITE",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"DATASET",
|
||||
"{count} datasets",
|
||||
"ZOOM"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -1049,7 +1066,8 @@
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"Regional"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
@@ -1085,7 +1103,8 @@
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
"NORAD: {id}",
|
||||
"{datasets} · {tiles} · {packs}"
|
||||
],
|
||||
"sv": [
|
||||
"STATION",
|
||||
|
||||
@@ -182,7 +182,22 @@ export function createPanel({ state: layerState, parts }) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The tracked airliner's photo with its credit, or null. The subject
|
||||
/** The credit line: the photographer, and an open photo's licence. A
|
||||
* photo whose answer names no photographer shows no photographer. */
|
||||
function photoCredit(photo) {
|
||||
if (photo.licence)
|
||||
return photo.photographer
|
||||
? t('Photo: {photographer} · {licence}', {
|
||||
photographer: photo.photographer,
|
||||
licence: photo.licence,
|
||||
})
|
||||
: t('Photo · {licence}', { licence: photo.licence });
|
||||
return photo.photographer
|
||||
? t('Photo © {photographer}', { photographer: photo.photographer })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** The subject's photo with its credit, or null. The subject
|
||||
* refresh resolves it; only the subject it was resolved for shows it. */
|
||||
function presentPhoto(subject) {
|
||||
const held = layerState.subjectPhoto;
|
||||
@@ -198,10 +213,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
width: photo.width ?? null,
|
||||
height: photo.height ?? null,
|
||||
link: photo.link,
|
||||
// A photo whose answer names no photographer shows no credit line.
|
||||
credit: photo.photographer
|
||||
? t('Photo © {photographer}', { photographer: photo.photographer })
|
||||
: '',
|
||||
credit: photoCredit(photo),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,52 @@ test('the Contacts view has no photo without one, or with another subject’s',
|
||||
);
|
||||
});
|
||||
|
||||
test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => {
|
||||
test('the selected vessel shows its own photo, by its id', () => {
|
||||
const asked = [];
|
||||
const { resolveSubjectPhoto, subjectFromContext } = createSubject({
|
||||
state: {},
|
||||
services: {
|
||||
flights: {},
|
||||
military: {},
|
||||
vessels: {
|
||||
getVesselPhoto: (id) => {
|
||||
asked.push(id);
|
||||
return id === 'uuid-1' ? PHOTO : null;
|
||||
},
|
||||
},
|
||||
installations: {},
|
||||
},
|
||||
parts: { navigation: { isFlightLayer: () => false } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-1' }),
|
||||
PHOTO,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-2' }),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(asked, ['uuid-1', 'uuid-2']);
|
||||
const vessel = (label, mmsi) =>
|
||||
subjectFromContext({
|
||||
layerId: 'ais-live-vessels',
|
||||
id: 'ais-uuid-1',
|
||||
label,
|
||||
latitude: 38.3,
|
||||
longitude: -123.05,
|
||||
properties: { vesselId: 'uuid-1', mmsi },
|
||||
});
|
||||
assert.equal(
|
||||
vessel('USCGC SOCKEYE', '366999709').label,
|
||||
'USCGC SOCKEYE · MMSI 366999709',
|
||||
);
|
||||
assert.equal(vessel('USCGC SOCKEYE', '366999709').id, 'uuid-1');
|
||||
// A vessel named by its MMSI, or without one, is not given it twice.
|
||||
assert.equal(vessel('MMSI 366999709', '366999709').label, 'MMSI 366999709');
|
||||
assert.equal(vessel('USCGC SOCKEYE', null).label, 'USCGC SOCKEYE');
|
||||
});
|
||||
|
||||
test('only the tracked flight has a flight photo: not an installation, a military aircraft or another flight', () => {
|
||||
const tracked = {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
|
||||
@@ -406,17 +406,20 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracked airliner's Planespotters photo, for the Contacts panel.
|
||||
* The subject's photo, for the Contacts panel: the tracked airliner's
|
||||
* Planespotters photo, or the selected vessel's Open Vessel Photos photo.
|
||||
*
|
||||
* Only the flights layer looks photos up, and only for the plane it tracks;
|
||||
* a vessel, an installation, a military aircraft or an untracked flight has
|
||||
* none.
|
||||
* The flights layer looks photos up only for the plane it tracks, the
|
||||
* vessel layer only for the vessel selected; an installation, a military
|
||||
* aircraft or an untracked flight has none.
|
||||
* @param {{layerId: string, id: string}|null} subject Current subject.
|
||||
* @param {?object} [tracked] The subject's tracked descriptor, when the
|
||||
* caller already has it.
|
||||
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}}
|
||||
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string, licence?: string}}
|
||||
*/
|
||||
function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) {
|
||||
if (subject?.layerId === 'ais-live-vessels')
|
||||
return aisLiveVesselsLayer.getVesselPhoto?.(subject.id) ?? null;
|
||||
return subject?.layerId === 'flights' && tracked?.photo
|
||||
? tracked.photo
|
||||
: null;
|
||||
@@ -517,10 +520,15 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
const latitude = Number(record.latitude);
|
||||
const longitude = Number(record.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
// A vessel reads as its name and MMSI; a vessel named by its MMSI alone
|
||||
// already carries it.
|
||||
const label = String(record.label || record.id);
|
||||
const mmsi = String(record.properties?.mmsi || '');
|
||||
return {
|
||||
layerId: record.layerId,
|
||||
id: record.properties?.vesselId || record.id,
|
||||
label: record.label || record.id,
|
||||
// i18n-ignore: MMSI is a radio identity code, as on the vessel card
|
||||
label: mmsi && !label.includes(mmsi) ? `${label} · MMSI ${mmsi}` : label,
|
||||
memberNames: record.properties?.memberNames || [],
|
||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||
};
|
||||
|
||||
@@ -260,6 +260,21 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance along a pitched-down view axis to flat ground at the mount's own
|
||||
* ground height, or null for an axis at or above the horizon.
|
||||
* @param {number} mountHeightM Mount height above its ground.
|
||||
* @param {number} pitchDeg Axis pitch (negative looks down).
|
||||
* @returns {number|null}
|
||||
*/
|
||||
|
||||
function flatGroundAxisDistance(mountHeightM, pitchDeg) {
|
||||
const height = Number(mountHeightM);
|
||||
const pitch = Number(pitchDeg);
|
||||
if (!Number.isFinite(height) || height <= 0 || !(pitch < 0)) return null;
|
||||
return height / Math.sin(Cesium.Math.toRadians(Math.min(89, -pitch)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
|
||||
* entities consume. Fresh objects per call (geometry updates are rare —
|
||||
@@ -616,10 +631,14 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* range, clamp the plane's effective range just short of the first hit so the
|
||||
* "big and dramatic" true end cap never clips into downtown buildings.
|
||||
*
|
||||
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
|
||||
* never per-frame (the zero-raycast invariant applies to steady state). The
|
||||
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
|
||||
* probe entirely. Probe failure/miss keeps the unclamped range.
|
||||
* On a globe map it also picks the globe's terrain along the same axis
|
||||
* (flat ground at the mount's ground when terrain is missed or met beyond
|
||||
* the range) and clamps at the nearer end. These are the ONLY raycasts in the
|
||||
* whole CCTV subsystem — once per activation, never per-frame (the
|
||||
* zero-raycast invariant applies to steady state). The per-camera range
|
||||
* slider overrides the clamp: a user-set rangeScale skips the probe
|
||||
* entirely. A miss on the photoreal stack or a probe failure keeps the
|
||||
* unclamped range.
|
||||
* @param {Object} record - Camera record being activated.
|
||||
*/
|
||||
|
||||
@@ -646,9 +665,33 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
for (const runtime of layerState._projectionEntities) {
|
||||
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
|
||||
}
|
||||
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
|
||||
if (!hit?.position) return;
|
||||
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
|
||||
const ray = new Cesium.Ray(mountPos, dir);
|
||||
// pickFromRay hits only primitives (the photoreal tiles), never the
|
||||
// globe: on a globe map the ray runs on through the Earth and can meet
|
||||
// a primitive thousands of kilometres away, which blocks nothing. Such
|
||||
// a hit or none left the plane at the full nominal range, about a
|
||||
// kilometre wide and hundreds of metres up. A hit at or beyond the range
|
||||
// counts as none. On a globe map the axis also ends on the globe's
|
||||
// terrain, the nearer of that and any primitive hit (a primitive behind
|
||||
// a hill the axis has already entered blocks nothing). Terrain still
|
||||
// loading at a coarse level can be missed or met beyond the range; then
|
||||
// flat ground at the mount's own ground stands in for it.
|
||||
const inRange = (distance) =>
|
||||
Number.isFinite(distance) && distance > 0 && distance < camera.rangeM;
|
||||
const hit = scene.pickFromRay(ray, exclude)?.position;
|
||||
const hitDist = hit ? Cesium.Cartesian3.distance(mountPos, hit) : NaN;
|
||||
const distances = inRange(hitDist) ? [hitDist] : [];
|
||||
if (scene.globe?.show) {
|
||||
const terrain = scene.globe.pick(ray, scene);
|
||||
const terrainDist = terrain
|
||||
? Cesium.Cartesian3.distance(mountPos, terrain)
|
||||
: NaN;
|
||||
const ground = inRange(terrainDist)
|
||||
? terrainDist
|
||||
: flatGroundAxisDistance(camera.mountHeightM, camera.pitchDeg);
|
||||
if (Number.isFinite(ground) && ground > 0) distances.push(ground);
|
||||
}
|
||||
const dist = distances.length ? Math.min(...distances) : null;
|
||||
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
||||
} catch {
|
||||
// probe failure → keep the unclamped range
|
||||
@@ -831,6 +874,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
hasShippedFootprint,
|
||||
footprintPose,
|
||||
activationProbeClampRange,
|
||||
flatGroundAxisDistance,
|
||||
frustumCartesians,
|
||||
frustumFrameEcef,
|
||||
applyFrustumGeometry,
|
||||
|
||||
@@ -73,6 +73,7 @@ export function createCctvLayer({ services, source }) {
|
||||
deriveCalBadge: parts.calibration.deriveCalBadge,
|
||||
computeFrustumGeometry: parts.geometry.computeFrustumGeometry,
|
||||
activationProbeClampRange: parts.geometry.activationProbeClampRange,
|
||||
flatGroundAxisDistance: parts.geometry.flatGroundAxisDistance,
|
||||
frameSignatureFromPixels: parts.frames.frameSignatureFromPixels,
|
||||
_createCctvProjectionPlaneForTest:
|
||||
parts.testing._createCctvProjectionPlaneForTest,
|
||||
|
||||
@@ -21,7 +21,13 @@ export function createDirectionsSource({
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
// Proxy error pages may not be JSON.
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
if (response.ok) throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// Failures keep their English message and carry the HTTP status and the
|
||||
// server's refusal text, so the layer can show them in the language.
|
||||
@@ -39,7 +45,8 @@ export function createDirectionsSource({
|
||||
{ status: 429, refusal },
|
||||
);
|
||||
}
|
||||
if (!response.ok && !payload?.error)
|
||||
// A 5xx body may mislabel an outage as a route refusal.
|
||||
if (response.status >= 500 || (!response.ok && !payload?.error))
|
||||
throw Object.assign(
|
||||
new Error(`Routing unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status },
|
||||
|
||||
@@ -41,3 +41,15 @@ test('route failures are surfaced without fallback requests', async () => {
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('a routing outage stays an outage when its body carries an error', async () => {
|
||||
const source = createDirectionsSource({ fetchImpl: async () =>
|
||||
Response.json({ ok: false, error: 'routing service unavailable' }, { status: 502 }) });
|
||||
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
|
||||
});
|
||||
|
||||
test('a routing outage page that is not JSON still reads as an outage', async () => {
|
||||
const source = createDirectionsSource({ fetchImpl: async () =>
|
||||
new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) });
|
||||
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
|
||||
});
|
||||
+23
-11
@@ -535,14 +535,26 @@ export function createModel({
|
||||
return `${isolate(latPart)} ${isolate(lonPart)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Millisecond delta → `{ unit: '<1h' | 'h' | 'd', count }`, or null for
|
||||
* invalid input. Both age formatters read it, so they pick the same unit.
|
||||
*/
|
||||
|
||||
function ageParts(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return { unit: '<1h', count: 0 };
|
||||
const wholeHours = Math.round(hours);
|
||||
if (wholeHours < 48) return { unit: 'h', count: wholeHours };
|
||||
return { unit: 'd', count: Math.round(hours / 24) };
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return '<1h';
|
||||
if (hours < 48) return `${Math.round(hours)}h`;
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
const age = ageParts(deltaMs);
|
||||
if (!age) return '';
|
||||
return age.unit === '<1h' ? '<1h' : `${age.count}${age.unit}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
|
||||
@@ -573,12 +585,12 @@ export function createModel({
|
||||
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
|
||||
|
||||
function formatAgeLabel(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return t('<1h', { context: 'age' });
|
||||
if (hours < 48)
|
||||
return t('{count}h', { context: 'age', count: Math.round(hours) });
|
||||
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
|
||||
const age = ageParts(deltaMs);
|
||||
if (!age) return '';
|
||||
if (age.unit === '<1h') return t('<1h', { context: 'age' });
|
||||
return age.unit === 'h'
|
||||
? t('{count}h', { context: 'age', count: age.count })
|
||||
: t('{count}d', { context: 'age', count: age.count });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLocale, setLocale } from '../../i18n/index.js';
|
||||
import { createModel } from './model.js';
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
|
||||
test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => {
|
||||
const previousLocale = getLocale();
|
||||
const { formatAge, formatAgeLabel } = createModel({
|
||||
services: { anchors: {} },
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(formatAge(47.4 * HOUR_MS), '47h');
|
||||
assert.equal(formatAge(47.5 * HOUR_MS), '2d');
|
||||
assert.equal(formatAge(47.6 * HOUR_MS), '2d');
|
||||
assert.equal(formatAge(48 * HOUR_MS), '2d');
|
||||
assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h');
|
||||
assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d');
|
||||
assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS));
|
||||
assert.equal(formatAgeLabel(48 * HOUR_MS), '2d');
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
|
||||
test('both fire age formatters agree on invalid, sub-hour and multi-day ages', () => {
|
||||
const previousLocale = getLocale();
|
||||
const { formatAge, formatAgeLabel } = createModel({
|
||||
services: { anchors: {} },
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
for (const format of [formatAge, formatAgeLabel]) {
|
||||
assert.equal(format(-1), '');
|
||||
assert.equal(format(Number.NaN), '');
|
||||
assert.equal(format(0.5 * HOUR_MS), '<1h');
|
||||
assert.equal(format(1 * HOUR_MS), '1h');
|
||||
assert.equal(format(72 * HOUR_MS), '3d');
|
||||
}
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
@@ -32,10 +32,7 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte
|
||||
assert.equal(methods.getStats().at, at);
|
||||
assert.equal(methods.getStats().source, 'NASA FIRMS', 'the row drops LIVE');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
methods.getStats().loadingLabel,
|
||||
'24 Std. bis 21.10. 15:00 UTC',
|
||||
);
|
||||
assert.equal(methods.getStats().loadingLabel, '24 h bis 21.10. 15:00 UTC');
|
||||
layerState._at = null;
|
||||
setLocale('en');
|
||||
assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago');
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
countryGroups,
|
||||
countryOnly,
|
||||
disputedLabel,
|
||||
eventPicture,
|
||||
feedCardView,
|
||||
feedQueryFor,
|
||||
hotspotLabel,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
normalizeFilters,
|
||||
passesFilters,
|
||||
reportMarkerPosition,
|
||||
reportSeverity,
|
||||
reportsInView,
|
||||
severityStyle,
|
||||
shortTitle,
|
||||
@@ -167,6 +169,14 @@ const emptyInView = () => ({
|
||||
* chosen: outlines (disputed areas dotted) and the geocoded reports as
|
||||
* markers, the big events as picture cards. The News panel renders the feed
|
||||
* this layer publishes through getRowControls().feed.
|
||||
*
|
||||
* `pictureCards(cards, { selectedId })`, when given, draws the picture cards
|
||||
* instead of the layer: it is called on every render with one card per
|
||||
* pictured event on the map, at every height (zoomed out too, where the
|
||||
* hotspots stand as beacons), in rank order (`{ id, title, lat, lon,
|
||||
* publishedAt, severity, color, image, video, credit }`), and the selected
|
||||
* report's id or null, whose own card the layer still draws at its marker; it
|
||||
* is called with no cards when the layer is turned off.
|
||||
*/
|
||||
export function createNewsLayer({
|
||||
source,
|
||||
@@ -178,6 +188,7 @@ export function createNewsLayer({
|
||||
openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
||||
getViewCentre = viewCentre,
|
||||
registerCredit = registerDynamicCredit,
|
||||
pictureCards: drawPictureCards = null,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof source?.getHotspots !== 'function' ||
|
||||
@@ -185,6 +196,8 @@ export function createNewsLayer({
|
||||
)
|
||||
throw new TypeError('News requires a news source');
|
||||
if (!overlayHost) throw new TypeError('News requires an overlay host');
|
||||
if (drawPictureCards !== null && typeof drawPictureCards !== 'function')
|
||||
throw new TypeError('News picture cards must be a function');
|
||||
|
||||
let viewer = null,
|
||||
dataSource = null,
|
||||
@@ -679,6 +692,53 @@ export function createNewsLayer({
|
||||
}
|
||||
}
|
||||
|
||||
// What a host's picture cards are chosen from when the hotspots stand as
|
||||
// beacons: every report the close-up view would mark, and the selection.
|
||||
function cardable() {
|
||||
const current = selected();
|
||||
const all = localMode() ? localReports(NEWS_MAP_LIMIT) : mapReports;
|
||||
return current && !all.some((r) => r.id === current.id)
|
||||
? [...all, current]
|
||||
: all;
|
||||
}
|
||||
|
||||
// With a `pictureCards` function the host draws the picture cards: one per
|
||||
// event on the map with a picture (its most severe, then newest report),
|
||||
// most severe then newest first, at its marker. The host chooses which
|
||||
// stand; a click on one calls setParams({ reportId, focus: true }).
|
||||
function hostCards(drawn) {
|
||||
const events = new Set();
|
||||
return drawn
|
||||
.filter((r) => r.pins.length && onMap(r) && eventPicture(r))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
reportSeverity(b) - reportSeverity(a) ||
|
||||
(b.event?.lastAt || b.publishedAt || '').localeCompare(
|
||||
a.event?.lastAt || a.publishedAt || '',
|
||||
),
|
||||
)
|
||||
.filter((report) => {
|
||||
const event = report.event?.id || report.id;
|
||||
if (events.has(event)) return false;
|
||||
events.add(event);
|
||||
return true;
|
||||
})
|
||||
.map((report) => {
|
||||
const picture = eventPicture(report);
|
||||
return {
|
||||
id: report.id,
|
||||
title: report.titleEn || report.title,
|
||||
...reportMarkerPosition(report, 0),
|
||||
publishedAt: report.event?.lastAt || report.publishedAt || null,
|
||||
severity: reportSeverity(report),
|
||||
color: severityStyle(report).color,
|
||||
image: picture.url,
|
||||
video: picture.video || null,
|
||||
credit: picture.credit,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The selected report's card: what, where, how attested, the link to the
|
||||
// original article ("Source: host ↗"), which a click on it opens, and the
|
||||
// service it came through.
|
||||
@@ -775,7 +835,13 @@ export function createNewsLayer({
|
||||
.filter((target) => target.position);
|
||||
horizon.update();
|
||||
updatePulse();
|
||||
pictureCards(drawn, current, labels);
|
||||
if (drawPictureCards)
|
||||
// At every height: zoomed out, a hotspot's reports give way to its
|
||||
// beacon, but their pictures still stand (the host keeps them apart).
|
||||
drawPictureCards(enabled ? hostCards(detail ? drawn : cardable()) : [], {
|
||||
selectedId: enabled ? selectedId : null,
|
||||
});
|
||||
else pictureCards(drawn, current, labels);
|
||||
selectedCard(current, labels);
|
||||
if (enabled)
|
||||
overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false });
|
||||
@@ -1442,6 +1508,7 @@ export function createNewsLayer({
|
||||
}
|
||||
overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false);
|
||||
drawPictureCards?.([], { selectedId: null });
|
||||
requestRender();
|
||||
notify();
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ function fixture(
|
||||
centre = () => ({ lat: 49, lon: 36 }),
|
||||
credits = [],
|
||||
now = () => NOW,
|
||||
pictureCards,
|
||||
} = {},
|
||||
) {
|
||||
const calls = { feed: [], hotspots: 0, outline: 0 };
|
||||
@@ -118,6 +119,7 @@ function fixture(
|
||||
credits.push(credit.key);
|
||||
return true;
|
||||
},
|
||||
...(pictureCards ? { pictureCards } : {}),
|
||||
});
|
||||
// The context store announces selections on `window`, which Node lacks.
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -227,6 +229,113 @@ test('the severity floor changes what is shown, not what is read', async (t) =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => {
|
||||
const picture = (url, extra = {}) => ({
|
||||
size: 1,
|
||||
lastAt: '2026-10-28T09:00:00.000Z',
|
||||
sides: [],
|
||||
image: { url, publisher: 'Example', ...extra },
|
||||
});
|
||||
const reports = [
|
||||
report('single', {
|
||||
severity: 1,
|
||||
event: picture('https://example.com/single.jpg', {
|
||||
video: 'https://example.com/single.mp4',
|
||||
}),
|
||||
}),
|
||||
report('major', {
|
||||
severity: 3,
|
||||
event: {
|
||||
...picture('https://example.com/major.jpg'),
|
||||
id: 'event-major',
|
||||
size: 3,
|
||||
},
|
||||
}),
|
||||
// Another report of the same event: its picture stands once.
|
||||
report('repeat', {
|
||||
severity: 2,
|
||||
event: {
|
||||
...picture('https://example.com/major.jpg'),
|
||||
id: 'event-major',
|
||||
size: 3,
|
||||
},
|
||||
}),
|
||||
report('bare', { severity: 2 }),
|
||||
];
|
||||
const handed = [];
|
||||
const { layer, entries } = fixture(t, {
|
||||
height: 500_000,
|
||||
source: { getFeed: async () => ({ reports, nextBefore: null }) },
|
||||
pictureCards: (cards, state) => handed.push({ cards, ...state }),
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ filters: { minSeverity: 0 } });
|
||||
const { cards, selectedId } = handed.at(-1);
|
||||
assert.equal(selectedId, null);
|
||||
// A single source is enough; a report without a picture has none.
|
||||
assert.deepEqual(
|
||||
cards.map((card) => card.id),
|
||||
['major', 'single'],
|
||||
);
|
||||
assert.equal(cards[1].image, 'https://example.com/single.jpg');
|
||||
assert.equal(cards[1].video, 'https://example.com/single.mp4');
|
||||
assert.equal(cards[0].video, null);
|
||||
assert.equal(cards[0].credit, 'Example');
|
||||
assert.ok(
|
||||
Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5,
|
||||
);
|
||||
// The layer paints none of its own.
|
||||
assert.ok(
|
||||
entries.flat().every((entry) => !entry.id.startsWith('event-card:')),
|
||||
);
|
||||
// The selected report is named, pictured or not.
|
||||
layer.setParams({ reportId: 'bare' });
|
||||
assert.equal(handed.at(-1).selectedId, 'bare');
|
||||
layer.disable();
|
||||
assert.deepEqual(handed.at(-1), { cards: [], selectedId: null });
|
||||
});
|
||||
|
||||
test("a host's picture cards stay at every height, beacons or markers", async (t) => {
|
||||
const reports = [
|
||||
report('major', {
|
||||
severity: 3,
|
||||
event: {
|
||||
size: 1,
|
||||
lastAt: '2026-10-28T09:00:00.000Z',
|
||||
sides: [],
|
||||
image: { url: 'https://example.com/major.jpg', publisher: 'Example' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
const handed = [];
|
||||
const { layer } = fixture(t, {
|
||||
// The whole globe: the hotspot stands as one beacon, its reports unmarked.
|
||||
height: 20_000_000,
|
||||
source: { getFeed: async () => ({ reports, nextBefore: null }) },
|
||||
pictureCards: (cards) => handed.push(cards),
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ filters: { minSeverity: 0 } });
|
||||
assert.deepEqual(
|
||||
handed.at(-1).map((card) => card.id),
|
||||
['major'],
|
||||
);
|
||||
});
|
||||
|
||||
test('picture cards must be drawn by a function', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createNewsLayer({
|
||||
source: { getHotspots() {}, getFeed() {} },
|
||||
overlayHost: {},
|
||||
pictureCards: 'yes',
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('Local reads every report once, lists those near the view and takes the map over', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let centre = { lat: 49, lon: 36 };
|
||||
|
||||
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { RAIL_CATEGORIES } from '../../data/railFeeds.js';
|
||||
import { aggregateTransitFeedHealth } from '../transit/policy.js';
|
||||
import {
|
||||
ACTIVATION_ALTITUDE_M,
|
||||
FEED_STALE_AFTER_MS,
|
||||
RAIL_CATEGORY_COLORS,
|
||||
RAIL_CATEGORY_LABELS,
|
||||
normalizeRailCategories,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Category toggles, the layer row and the stats the panel reads.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createControls({ state, parts, timetable }) {
|
||||
function notify() {
|
||||
state._controlsListener?.();
|
||||
}
|
||||
|
||||
/** Regions with at least one pollable feed, for the empty-coverage note. */
|
||||
function regionCount() {
|
||||
return new Set(parts.viewport.pollableFeeds().map((feed) => feed.region))
|
||||
.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains per category for the legend, shown or not; a train two feeds
|
||||
* carry is one train (the copy dedupe.js hides is not counted).
|
||||
*/
|
||||
function categoryCounts() {
|
||||
const counts = new Map();
|
||||
for (const entry of state._trains.values())
|
||||
if (entry.duplicate !== true)
|
||||
counts.set(entry.category, (counts.get(entry.category) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains drawn per feed: those still held (a train missing from one poll
|
||||
* stays until MISSED_POLLS_TO_DROP) in a shown category, and not hidden as
|
||||
* another feed's copy. The feed status's own count is what the last poll
|
||||
* listed, which the loading and health rules need but the map does not
|
||||
* show, so the coverage line counts drawn trains, as the layer total does.
|
||||
*/
|
||||
function drawnCounts() {
|
||||
const counts = new Map();
|
||||
for (const entry of state._trains.values())
|
||||
if (parts.rendering.categoryShown(entry))
|
||||
counts.set(entry.feedId, (counts.get(entry.feedId) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** The coverage line: each active feed and its train count, aged when quiet. */
|
||||
function coverageLine(active, now) {
|
||||
const drawn = drawnCounts();
|
||||
return active
|
||||
.map((feed) => {
|
||||
const status = parts.ingestion.feedStatus(feed.id);
|
||||
const quiet =
|
||||
status.stale === true ||
|
||||
(Number.isFinite(status.upstreamAt) &&
|
||||
now - status.upstreamAt > FEED_STALE_AFTER_MS);
|
||||
const aged =
|
||||
quiet && Number.isFinite(status.upstreamAt)
|
||||
? ` (${Math.round((now - status.upstreamAt) / 1000)}s old)`
|
||||
: '';
|
||||
return `${feed.name} ${drawn.get(feed.id) || 0}${aged}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** Wired by the application so polls can repaint the panel row. */
|
||||
attachDataManager(manager) {
|
||||
state._dataManager = manager;
|
||||
},
|
||||
setRowControlsListener(listener) {
|
||||
state._controlsListener =
|
||||
typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
/** The category toggles, for share links and restoration. */
|
||||
getParams() {
|
||||
return { ...state._categories };
|
||||
},
|
||||
/** Show or hide categories; unknown keys and non-booleans change nothing. */
|
||||
setParams(params = {}) {
|
||||
const next = normalizeRailCategories(params, state._categories);
|
||||
if (RAIL_CATEGORIES.every((key) => next[key] === state._categories[key]))
|
||||
return true;
|
||||
state._categories = next;
|
||||
const selected = state._trains.get(state._selectedKey);
|
||||
if (selected && next[selected.category] === false)
|
||||
parts.selection.clearSelection();
|
||||
parts.rendering.applyCategoryVisibility();
|
||||
state._revision += 1;
|
||||
notify();
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
return true;
|
||||
},
|
||||
/** One chip per category, with the colour and live count as the legend. */
|
||||
getRowControls() {
|
||||
const counts = categoryCounts();
|
||||
const chips = RAIL_CATEGORIES.map((category) => {
|
||||
const on = state._categories[category] !== false;
|
||||
const label = t(RAIL_CATEGORY_LABELS[category]);
|
||||
return {
|
||||
id: `rail-${category}`,
|
||||
label: label.toUpperCase(),
|
||||
title: on
|
||||
? t('Hide {category}', { category: label })
|
||||
: t('Show {category}', { category: label }),
|
||||
active: on,
|
||||
busy: false,
|
||||
disabled: false,
|
||||
state: on ? 'active' : 'idle',
|
||||
params: { [category]: !on },
|
||||
};
|
||||
});
|
||||
const legend = RAIL_CATEGORIES.filter((category) =>
|
||||
counts.has(category),
|
||||
).map((category) => ({
|
||||
label: t(RAIL_CATEGORY_LABELS[category]),
|
||||
color: RAIL_CATEGORY_COLORS[category],
|
||||
count: counts.get(category),
|
||||
}));
|
||||
return { chips, legend };
|
||||
},
|
||||
/** Route-line, stop, schedule and camera-follow state, for QA probes. */
|
||||
getRouteDisplayStats() {
|
||||
return {
|
||||
...parts.routes.stats(),
|
||||
timetable: timetable.stats(),
|
||||
follow: parts.follow.stats(),
|
||||
heldFeed: state._heldFeedId,
|
||||
};
|
||||
},
|
||||
getStats() {
|
||||
const active = [...state._activeFeeds.values()];
|
||||
const statuses = active.map((feed) =>
|
||||
parts.ingestion.feedStatus(feed.id),
|
||||
);
|
||||
const now = Date.now();
|
||||
const health = aggregateTransitFeedHealth(statuses, now);
|
||||
let count = 0;
|
||||
for (const entry of state._trains.values())
|
||||
if (parts.rendering.categoryShown(entry)) count += 1;
|
||||
// New trains the layer's cap left out of the last polls
|
||||
// (MAX_TRAINS_TOTAL): the map shows part of what the feeds list.
|
||||
const capped = statuses.reduce(
|
||||
(sum, status) => sum + (status.capped || 0),
|
||||
0,
|
||||
);
|
||||
if (state._enabled && active.length === 0) {
|
||||
// English: the row's prompt and the analyst's empty note, both shown
|
||||
// through translateStatusMessage (ui/statusText.js), as Transit's.
|
||||
const note = state._altitudeGateOpen
|
||||
? `No feed here yet · ${regionCount()} regions available`
|
||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`;
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: null,
|
||||
source: 'GTFS-RT',
|
||||
status: 'zoom-in',
|
||||
statusMessage: note,
|
||||
coverage: note,
|
||||
};
|
||||
}
|
||||
return {
|
||||
count,
|
||||
lastUpdate: state._lastUpdate,
|
||||
error: count === 0 ? health.error : null,
|
||||
...(count === 0 && Number.isFinite(health.retryInSec)
|
||||
? { retryInSec: health.retryInSec }
|
||||
: {}),
|
||||
degraded: count > 0 && health.degraded,
|
||||
loading: health.loading && count === 0,
|
||||
loadingLabel:
|
||||
// i18n-ignore: English data, translated on display (ui/statusText.js)
|
||||
health.loading && count === 0
|
||||
? `Loading ${active.map((feed) => feed.name).join(', ')}`
|
||||
: undefined,
|
||||
stale: health.stale,
|
||||
// The panel reads this as "PARTIAL · N of M records accepted".
|
||||
...(capped > 0 && count > 0
|
||||
? {
|
||||
partial: true,
|
||||
acceptedRowCount: count,
|
||||
rawRowCount: count + capped,
|
||||
}
|
||||
: {}),
|
||||
source: 'GTFS-RT',
|
||||
coverage: coverageLine(active, now),
|
||||
feeds: active.map((feed) => feed.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { methods, notify };
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
|
||||
/**
|
||||
* @module rail/dedupe
|
||||
* @description One physical train drawn once when several loaded feeds carry
|
||||
* it (an operator's own feed and a national feed that lists its trains too,
|
||||
* a cross-border train reported on both sides).
|
||||
*
|
||||
* Matching, between copies from different feeds:
|
||||
* - both carry a train number: the same normalised number (its last digit
|
||||
* group without leading zeros: `RJX 160`, `160`), up to NUMBER_RADIUS_KM
|
||||
* apart. Numbers that differ are different trains. A name's number is a
|
||||
* train's when it has three digits or more, or follows a long-distance
|
||||
* product (`EC 7`, `NJ 40`); a trip id that is an operator prefix and a
|
||||
* number (`AM:530`) and a bare-number headsign (where a feed with no
|
||||
* trip names writes the train number) are read too.
|
||||
* - a shorter number after anything else names a line (`S1`, `RB 1`,
|
||||
* `U6`), which many trains run: copies on different lines are different
|
||||
* trains, and copies on one line within MATCH_RADIUS_KM are one train
|
||||
* only in the same category, the nearest pairs first.
|
||||
* A train has at most one copy per feed; the strongest matches join first.
|
||||
*
|
||||
* Choosing the copy drawn:
|
||||
* 1. position source: live GPS, then station reports;
|
||||
* 2. the line: a copy bound to its operator's shape (a built pack), then
|
||||
* one without.
|
||||
* The copy drawn stays drawn until another scores strictly higher, so equal
|
||||
* copies never trade places. A losing copy keeps its playback and is only
|
||||
* hidden; when a feed leaves the viewport its copies' partners come back.
|
||||
* The comparison runs once per poll, never per frame.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copies farther apart than this are different trains (km): two copies of
|
||||
* one train at one moment differ only by where each feed reports it.
|
||||
*/
|
||||
export const MATCH_RADIUS_KM = 15;
|
||||
/**
|
||||
* Copies with the same number match up to this far apart (km): two feeds
|
||||
* report one train at different moments, and one polled every few minutes
|
||||
* can be tens of kilometres behind at speed.
|
||||
*/
|
||||
export const NUMBER_RADIUS_KM = 100;
|
||||
/**
|
||||
* Grid cell for finding nearby copies (degrees). A row (27.8 km) spans
|
||||
* MATCH_RADIUS_KM; a column narrows away from the equator (13.9 km at
|
||||
* 60°N), so as many columns either side are searched as reach it
|
||||
* (columnSpan).
|
||||
*/
|
||||
const CELL_DEG = 0.25;
|
||||
|
||||
const POSITION_RANK = Object.freeze({
|
||||
gps: 2,
|
||||
event: 1,
|
||||
});
|
||||
/**
|
||||
* Products whose trains carry short numbers of their own (`EC 7`, `NJ 40`).
|
||||
* Not `IC`: in Switzerland `IC 1` and `IC 5` are lines run by many trains a
|
||||
* day, and IC train numbers elsewhere have three or more digits anyway.
|
||||
*/
|
||||
const LONG_DISTANCE_PRODUCTS = new Set([
|
||||
'EC',
|
||||
'ECE',
|
||||
'EN',
|
||||
'ICE',
|
||||
'NJ',
|
||||
'RJ',
|
||||
'RJX',
|
||||
'TGV',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A train number comparable across feeds: the last group of digits, without
|
||||
* leading zeros (`RJX 160` → `160`, `09725` → `9725`), or null.
|
||||
* @param {string|null|undefined} label
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function trainNumber(label) {
|
||||
const digits = /(\d{1,6})\D*$/.exec(String(label ?? ''))?.[1];
|
||||
const number = digits?.replace(/^0+/, '') ?? '';
|
||||
return number || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a name's number says: a train's own number (three digits or more,
|
||||
* or after a long-distance product: `RJX 160`, `EC 7`), or a line's
|
||||
* (`S1`, `RB 1`, `U6`: each run by many trains), with its product.
|
||||
* @param {string|null|undefined} label
|
||||
* @returns {{number: string|null, line: {product: string,
|
||||
* number: string}|null}}
|
||||
*/
|
||||
export function labelNumbers(label) {
|
||||
const match = /([A-Za-z]*)\s*(\d{1,6})\D*$/.exec(String(label ?? ''));
|
||||
const number = match?.[2].replace(/^0+/, '') ?? '';
|
||||
if (!number) return { number: null, line: null };
|
||||
const product = match[1].toUpperCase();
|
||||
return number.length >= 3 || LONG_DISTANCE_PRODUCTS.has(product)
|
||||
? { number, line: null }
|
||||
: { number: null, line: { product, number } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's train number and line: the number from its name, unless that
|
||||
* is only the vehicle's own id (a feed that labels each train with its car
|
||||
* number) or names a line; else from a trip id that is an operator
|
||||
* prefix and a number (`AM:530`); else from a headsign that is a bare
|
||||
* number (where a feed with no trip names writes the train number). The line is the name's,
|
||||
* when it names one.
|
||||
* @param {{id?: string|null, label?: string|null, tripId?: string|null,
|
||||
* headsign?: string|null}} record
|
||||
* @returns {{number: string|null, line: {product: string,
|
||||
* number: string}|null}}
|
||||
*/
|
||||
export function recordNumbers(record) {
|
||||
const label = String(record?.label ?? '').trim();
|
||||
const named =
|
||||
label && label !== String(record?.id ?? '')
|
||||
? labelNumbers(label)
|
||||
: { number: null, line: null };
|
||||
if (named.number) return named;
|
||||
const trip = /^[A-Za-z]{1,6}:(\d{1,6})$/.exec(
|
||||
String(record?.tripId ?? '').trim(),
|
||||
);
|
||||
const headsign = String(record?.headsign ?? '').trim();
|
||||
const number = trip
|
||||
? trainNumber(trip[1])
|
||||
: /^\d{3,6}$/.test(headsign)
|
||||
? trainNumber(headsign)
|
||||
: null;
|
||||
return number ? { number, line: null } : named;
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's train number (recordNumbers), or null.
|
||||
* @param {object} record
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function recordTrainNumber(record) {
|
||||
return recordNumbers(record).number;
|
||||
}
|
||||
|
||||
/** Whether two lines can be one: one number, and one product where both name it. */
|
||||
function sameLine(a, b) {
|
||||
return (
|
||||
a.number === b.number &&
|
||||
(!a.product || !b.product || a.product === b.product)
|
||||
);
|
||||
}
|
||||
|
||||
/** A number match outranks any line match. */
|
||||
const NUMBER_MATCH = 2;
|
||||
|
||||
/**
|
||||
* How strongly two copies are the same physical train: 0 when they are not,
|
||||
* NUMBER_MATCH for the same number, else, for one line in one category,
|
||||
* between 0.5 and 1, higher the nearer they are. See the module notes.
|
||||
* @param {object} a A copy (`copyOf` in createDuplicateFilter).
|
||||
* @param {object} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function matchStrength(a, b) {
|
||||
if (a.feedId === b.feedId) return 0;
|
||||
const km = greatCircleKm(a, b);
|
||||
if (a.number && b.number)
|
||||
return km <= NUMBER_RADIUS_KM && a.number === b.number ? NUMBER_MATCH : 0;
|
||||
if (km > MATCH_RADIUS_KM) return 0;
|
||||
if (a.line && b.line && !sameLine(a.line, b.line)) return 0;
|
||||
// Many trains run one line: in one category, the nearest copies pair
|
||||
// first.
|
||||
return a.line && b.line && a.category != null && a.category === b.category
|
||||
? 1 - km / (2 * MATCH_RADIUS_KM)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** Whether two copies are the same physical train. */
|
||||
export function sameTrain(a, b) {
|
||||
return matchStrength(a, b) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid columns either side of a copy's that reach MATCH_RADIUS_KM east and
|
||||
* west of it, measured on the poleward edge of its row's neighbours.
|
||||
* @param {number} lat
|
||||
* @returns {number}
|
||||
*/
|
||||
function columnSpan(lat) {
|
||||
const poleward = Math.min(89, Math.abs(lat) + CELL_DEG);
|
||||
const cellKm = CELL_DEG * 111.32 * Math.cos((poleward * Math.PI) / 180);
|
||||
return Math.max(1, Math.ceil(MATCH_RADIUS_KM / cellKm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups of two or more copies that are one train, at most one copy per
|
||||
* feed: two trains of one feed are two trains, even where a third feed's
|
||||
* copy resembles both (coupled TGVs). The strongest matches are joined
|
||||
* first. Numbered copies are compared wherever they are, the others only
|
||||
* within MATCH_RADIUS_KM's reach on the grid.
|
||||
* @param {object[]} copies
|
||||
* @returns {object[][]}
|
||||
*/
|
||||
export function matchCopies(copies) {
|
||||
const cells = new Map();
|
||||
const cellOf = (lat, lon) =>
|
||||
`${Math.floor(lat / CELL_DEG)}|${Math.floor(lon / CELL_DEG)}`;
|
||||
copies.forEach((copy, i) => {
|
||||
if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return;
|
||||
const key = cellOf(copy.lat, copy.lon);
|
||||
if (!cells.has(key)) cells.set(key, []);
|
||||
cells.get(key).push(i);
|
||||
});
|
||||
const pairs = [];
|
||||
// Numbered copies pair through their number, wherever they are.
|
||||
const byNumber = new Map();
|
||||
copies.forEach((copy, i) => {
|
||||
if (!copy.number) return;
|
||||
if (!byNumber.has(copy.number)) byNumber.set(copy.number, []);
|
||||
byNumber.get(copy.number).push(i);
|
||||
});
|
||||
for (const list of byNumber.values())
|
||||
for (let x = 0; x < list.length; x++)
|
||||
for (let y = x + 1; y < list.length; y++) {
|
||||
const strength = matchStrength(copies[list[x]], copies[list[y]]);
|
||||
if (strength > 0) pairs.push([strength, list[x], list[y]]);
|
||||
}
|
||||
// The others through the grid: only copies near each other.
|
||||
copies.forEach((copy, i) => {
|
||||
if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return;
|
||||
const row = Math.floor(copy.lat / CELL_DEG);
|
||||
const col = Math.floor(copy.lon / CELL_DEG);
|
||||
const span = columnSpan(copy.lat);
|
||||
for (let dr = -1; dr <= 1; dr++)
|
||||
for (let dc = -span; dc <= span; dc++)
|
||||
for (const j of cells.get(`${row + dr}|${col + dc}`) ?? []) {
|
||||
if (j <= i || (copy.number && copies[j].number)) continue;
|
||||
const strength = matchStrength(copy, copies[j]);
|
||||
if (strength > 0) pairs.push([strength, i, j]);
|
||||
}
|
||||
});
|
||||
pairs.sort((a, b) => b[0] - a[0]);
|
||||
const parent = copies.map((_, i) => i);
|
||||
const feeds = copies.map((copy) => new Set([copy.feedId]));
|
||||
const members = copies.map((_, i) => [i]);
|
||||
const find = (i) => {
|
||||
while (parent[i] !== i) i = parent[i] = parent[parent[i]];
|
||||
return i;
|
||||
};
|
||||
for (const [, i, j] of pairs) {
|
||||
const a = find(i);
|
||||
const b = find(j);
|
||||
if (a === b || [...feeds[b]].some((feed) => feeds[a].has(feed))) continue;
|
||||
// Every copy must match every other: a copy resembling two trains (an
|
||||
// unnumbered train at a busy hub) never joins them into one.
|
||||
const all = members[a].every((x) =>
|
||||
members[b].every(
|
||||
(y) => (x === i && y === j) || matchStrength(copies[x], copies[y]) > 0,
|
||||
),
|
||||
);
|
||||
if (!all) continue;
|
||||
parent[b] = a;
|
||||
for (const feed of feeds[b]) feeds[a].add(feed);
|
||||
members[a].push(...members[b]);
|
||||
}
|
||||
const groups = new Map();
|
||||
copies.forEach((copy, i) => {
|
||||
const root = find(i);
|
||||
if (!groups.has(root)) groups.set(root, []);
|
||||
groups.get(root).push(copy);
|
||||
});
|
||||
return [...groups.values()].filter((group) => group.length > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy's score: `[position, line]`, compared in that order.
|
||||
* @param {object} copy
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function scoreCopy(copy) {
|
||||
return [POSITION_RANK[copy.position] ?? 0, copy.onShape === true ? 1 : 0];
|
||||
}
|
||||
|
||||
function better(a, b) {
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy each group draws, keeping last time's choice unless another copy
|
||||
* scores strictly higher.
|
||||
* @param {object[][]} groups From matchCopies.
|
||||
* @param {Map<string, string>} [previous] copy key → key drawn last time.
|
||||
* @returns {{winners: Map<string, string>, hidden: Set<string>}}
|
||||
* `winners`: every grouped copy's key → the key drawn for its train.
|
||||
*/
|
||||
export function chooseCopies(groups, previous = new Map()) {
|
||||
const winners = new Map();
|
||||
const hidden = new Set();
|
||||
for (const group of groups) {
|
||||
const scored = group.map((copy) => ({ copy, score: scoreCopy(copy) }));
|
||||
const keys = new Set(group.map((copy) => copy.key));
|
||||
const incumbentKey = group
|
||||
.map((copy) => previous.get(copy.key))
|
||||
.find((key) => key && keys.has(key));
|
||||
let best =
|
||||
scored.find(({ copy }) => copy.key === incumbentKey) ??
|
||||
scored.reduce((a, b) => (better(b.score, a.score) ? b : a));
|
||||
for (const candidate of scored)
|
||||
if (better(candidate.score, best.score)) best = candidate;
|
||||
for (const { copy } of scored) {
|
||||
winners.set(copy.key, best.copy.key);
|
||||
if (copy.key !== best.copy.key) hidden.add(copy.key);
|
||||
}
|
||||
}
|
||||
return { winners, hidden };
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer's duplicate filter: after each poll it matches the trains every
|
||||
* loaded feed carries, marks the copies not drawn (`entry.duplicate`), and
|
||||
* moves a selection that sat on a copy now hidden to the copy drawn, so the
|
||||
* card follows the same physical train (its feed line names the new
|
||||
* source).
|
||||
* @param {{state: object, parts: object,
|
||||
* defer?: (fn: Function) => unknown}} context
|
||||
*/
|
||||
export function createDuplicateFilter({
|
||||
state,
|
||||
parts,
|
||||
defer = (fn) => setTimeout(fn, 0),
|
||||
}) {
|
||||
let previous = new Map();
|
||||
let pending = false;
|
||||
let lastStats = { groups: 0, hidden: 0 };
|
||||
|
||||
/** The comparable view of one drawn train. */
|
||||
function copyOf(entry) {
|
||||
const record = entry.record ?? {};
|
||||
const { number, line } = recordNumbers(record);
|
||||
return {
|
||||
key: entry.key,
|
||||
feedId: entry.feedId,
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
number,
|
||||
line,
|
||||
category: entry.category ?? record.category ?? null,
|
||||
position: record.positionSource ?? 'gps',
|
||||
onShape: Number.isInteger(entry.path?.shapeIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/** Recompute which copies are drawn. Cheap enough for once per poll. */
|
||||
function refresh() {
|
||||
pending = false;
|
||||
const entries = [...state._trains.values()];
|
||||
const { winners, hidden } = chooseCopies(
|
||||
matchCopies(entries.map(copyOf)),
|
||||
previous,
|
||||
);
|
||||
previous = winners;
|
||||
for (const entry of entries) {
|
||||
const duplicate = hidden.has(entry.key);
|
||||
if (entry.duplicate === duplicate) continue;
|
||||
entry.duplicate = duplicate;
|
||||
parts.rendering.paint(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
// A selection stays on its train: it moves to the copy now drawn.
|
||||
const selected = state._selectedKey;
|
||||
if (selected && hidden.has(selected)) {
|
||||
const drawn = winners.get(selected);
|
||||
if (drawn && state._trains.has(drawn)) parts.selection.selectTrain(drawn);
|
||||
}
|
||||
lastStats = {
|
||||
groups: new Set(winners.values()).size,
|
||||
hidden: hidden.size,
|
||||
};
|
||||
return lastStats;
|
||||
}
|
||||
|
||||
/** Coalesce the refreshes of several feeds' polls into one. */
|
||||
function scheduleRefresh() {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
defer(() => {
|
||||
if (pending) refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
scheduleRefresh,
|
||||
stats: () => ({ ...lastStats }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// One physical train in several loaded live feeds is drawn once: copies are
|
||||
// matched by train number, or by line and category when near each other,
|
||||
// and the copy with the better position source and line is drawn.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NUMBER_RADIUS_KM,
|
||||
chooseCopies,
|
||||
createDuplicateFilter,
|
||||
labelNumbers,
|
||||
matchCopies,
|
||||
recordNumbers,
|
||||
recordTrainNumber,
|
||||
sameTrain,
|
||||
scoreCopy,
|
||||
trainNumber,
|
||||
} from './dedupe.js';
|
||||
import * as dedupe from './dedupe.js';
|
||||
|
||||
const ROSENHEIM = { lat: 47.85, lon: 12.119 };
|
||||
const SALZBURG = { lat: 47.813, lon: 13.046 };
|
||||
const LINZ = { lat: 48.29, lon: 14.291 };
|
||||
|
||||
/** A fake copy of a live train. */
|
||||
function copy(key, feedId, extra = {}) {
|
||||
return {
|
||||
key,
|
||||
feedId,
|
||||
lat: ROSENHEIM.lat,
|
||||
lon: ROSENHEIM.lon,
|
||||
number: null,
|
||||
line: null,
|
||||
category: null,
|
||||
position: 'gps',
|
||||
onShape: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test('train numbers compare across feeds', () => {
|
||||
assert.equal(trainNumber('RJX 160'), '160');
|
||||
assert.equal(trainNumber('160'), '160');
|
||||
assert.equal(trainNumber('09725'), '9725');
|
||||
assert.equal(trainNumber('TGV 10465'), '10465');
|
||||
for (const none of [null, '', 'S', '0', 'RJX'])
|
||||
assert.equal(trainNumber(none), null, String(none));
|
||||
});
|
||||
|
||||
test('the same number is one train; another number is not', () => {
|
||||
const a = copy('a', 'feed-a', { number: '160' });
|
||||
assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '160' })), true);
|
||||
assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '161' })), false);
|
||||
// Never within one feed, never far apart.
|
||||
assert.equal(sameTrain(a, copy('b', 'feed-a', { number: '160' })), false);
|
||||
assert.equal(
|
||||
sameTrain(a, copy('b', 'feed-b', { number: '160', ...LINZ })),
|
||||
false,
|
||||
);
|
||||
// Two feeds report one train at different moments: 70 km on
|
||||
// (Rosenheim–Salzburg) it is still the same numbered train.
|
||||
assert.ok(NUMBER_RADIUS_KM > 70);
|
||||
assert.equal(
|
||||
sameTrain(a, copy('b', 'feed-b', { number: '160', ...SALZBURG })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchCopies([a, copy('b', 'feed-b', { number: '160', ...SALZBURG })])
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('the filter matches nothing from timetables: no stops, dates or homes', () => {
|
||||
// Copies are live trains only; nothing in the filter reads a schedule.
|
||||
for (const gone of [
|
||||
'scheduleRunStops',
|
||||
'scheduleHomeCountry',
|
||||
'copyCountry',
|
||||
'shareTimedStops',
|
||||
'serviceDateKey',
|
||||
])
|
||||
assert.equal(dedupe[gone], undefined, gone);
|
||||
// Unnumbered copies off any line are never joined, however close.
|
||||
assert.equal(sameTrain(copy('a', 'feed-a'), copy('b', 'feed-b')), false);
|
||||
});
|
||||
|
||||
test('a bare-number headsign is the number when there is no name', () => {
|
||||
assert.equal(
|
||||
recordTrainNumber({ label: null, headsign: '839162' }),
|
||||
'839162',
|
||||
);
|
||||
assert.equal(
|
||||
recordTrainNumber({ label: 'RJX 160', headsign: '9999' }),
|
||||
'160',
|
||||
);
|
||||
assert.equal(recordTrainNumber({ label: null, headsign: 'Paris-Est' }), null);
|
||||
assert.equal(recordTrainNumber({ label: null, headsign: 'S 12' }), null);
|
||||
});
|
||||
|
||||
test('a live vehicle labelled with its own car number takes the number from its trip', () => {
|
||||
// A live feed's vehicle 8306 runs train 530.
|
||||
const live = { id: '8306', label: '8306', tripId: 'AM:530' };
|
||||
assert.equal(recordTrainNumber(live), '530');
|
||||
// A label that is not the vehicle id is the train's name.
|
||||
assert.equal(
|
||||
recordTrainNumber({ id: 'v1', label: '531', tripId: 'AM:530' }),
|
||||
'531',
|
||||
);
|
||||
// Long trip ids are not train numbers.
|
||||
assert.equal(
|
||||
recordTrainNumber({ id: '7', label: '7', tripId: '0519321010VRFV' }),
|
||||
null,
|
||||
);
|
||||
// The GPS copy matches a station-report copy of train 530, and wins over
|
||||
// it even on the operator's line.
|
||||
const groups = matchCopies([
|
||||
copy('gps', 'gps-feed', { number: recordTrainNumber(live) }),
|
||||
copy('event', 'event-feed', {
|
||||
number: '530',
|
||||
position: 'event',
|
||||
shape: 'operator',
|
||||
}),
|
||||
]);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(chooseCopies(groups).winners.get('event'), 'gps');
|
||||
});
|
||||
|
||||
test('a group never holds two trains of one feed (coupled trains)', () => {
|
||||
// Two coupled TGVs side by side in one feed; the other feed names one of
|
||||
// them. Each pairs with its own number only.
|
||||
const groups = matchCopies([
|
||||
copy('fr-6194', 'feed-fr', { number: '6194' }),
|
||||
copy('fr-6906', 'feed-fr', { number: '6906' }),
|
||||
copy('ch-6194', 'feed-ch', { number: '6194' }),
|
||||
copy('ch-6906', 'feed-ch', { number: '6906' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
groups.map((group) => group.map((c) => c.key).sort()).sort(),
|
||||
[
|
||||
['ch-6194', 'fr-6194'],
|
||||
['ch-6906', 'fr-6906'],
|
||||
],
|
||||
);
|
||||
// On one line, one feed's two copies still never share a group.
|
||||
const s1 = { ...recordNumbers({ label: 'S1' }), category: 'local' };
|
||||
const unnamed = matchCopies([
|
||||
copy('a1', 'feed-a', s1),
|
||||
copy('a2', 'feed-a', { ...s1, lat: ROSENHEIM.lat + 0.01 }),
|
||||
copy('b1', 'feed-b', s1),
|
||||
]);
|
||||
assert.equal(unnamed.length, 1);
|
||||
assert.equal(new Set(unnamed[0].map((c) => c.feedId)).size, 2);
|
||||
});
|
||||
|
||||
test('a copy resembling two different trains never joins them', () => {
|
||||
// An unnumbered S1 lies near two numbered trains on its line; the two
|
||||
// carry different numbers, so the three are never one train.
|
||||
const s1 = { line: { product: 'S', number: '1' }, category: 'local' };
|
||||
const groups = matchCopies([
|
||||
copy('n1', 'feed-a', { number: '1211', ...s1 }),
|
||||
copy('n2', 'feed-s', { number: '22739', ...s1 }),
|
||||
copy('s1', 'feed-c', s1),
|
||||
]);
|
||||
assert.ok(
|
||||
groups.every((group) => group.length < 3),
|
||||
'never all three',
|
||||
);
|
||||
});
|
||||
|
||||
test('the score: position source, then the line', () => {
|
||||
const base = copy('a', 'x');
|
||||
assert.deepEqual(scoreCopy(base), [2, 0]);
|
||||
assert.deepEqual(scoreCopy({ ...base, position: 'event' }), [1, 0]);
|
||||
assert.deepEqual(scoreCopy({ ...base, onShape: true }), [2, 1]);
|
||||
// A source the layer does not know scores nothing.
|
||||
assert.deepEqual(scoreCopy({ ...base, position: 'psychic' }), [0, 0]);
|
||||
});
|
||||
|
||||
test('equal copies never trade places: the one drawn stays drawn', () => {
|
||||
const twins = [
|
||||
copy('a', 'f1', { number: '160', position: 'event' }),
|
||||
copy('b', 'f2', { number: '160', position: 'event' }),
|
||||
];
|
||||
const first = chooseCopies([twins]);
|
||||
const drawn = first.winners.get('a');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const again = chooseCopies([[...twins].reverse()], first.winners);
|
||||
assert.equal(again.winners.get('a'), drawn);
|
||||
}
|
||||
// A GPS copy takes over from a station-report one at once.
|
||||
const live = chooseCopies(
|
||||
[[twins[0], { ...twins[1], position: 'gps' }]],
|
||||
new Map([
|
||||
['a', 'a'],
|
||||
['b', 'a'],
|
||||
]),
|
||||
);
|
||||
assert.equal(live.winners.get('a'), 'b');
|
||||
});
|
||||
|
||||
/**
|
||||
* A layer's state with one live train per feed, both train RJX 160, and
|
||||
* spies for what the filter calls.
|
||||
*/
|
||||
function fakeLayer({ selected = null } = {}) {
|
||||
const entry = (feedId, onShape) => ({
|
||||
key: `${feedId}:v160`,
|
||||
feedId,
|
||||
record: {
|
||||
id: 'v160',
|
||||
tripId: 't1',
|
||||
label: 'RJX 160',
|
||||
...ROSENHEIM,
|
||||
positionSource: 'gps',
|
||||
},
|
||||
path: onShape ? { shapeIndex: 0 } : null,
|
||||
});
|
||||
const trains = [entry('feed-a', false), entry('feed-b', true)];
|
||||
const state = {
|
||||
_trains: new Map(trains.map((t) => [t.key, t])),
|
||||
_selectedKey: selected,
|
||||
};
|
||||
const calls = { paint: [], selected: [] };
|
||||
const parts = {
|
||||
rendering: {
|
||||
paint: (e) => calls.paint.push([e.key, e.duplicate]),
|
||||
schedulePlayback: () => {},
|
||||
},
|
||||
selection: {
|
||||
selectTrain: (key) => {
|
||||
calls.selected.push(key);
|
||||
state._selectedKey = key;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { state, parts, calls, trains };
|
||||
}
|
||||
|
||||
test('the filter hides the weaker copy and the selection follows its train', () => {
|
||||
const layer = fakeLayer({ selected: 'feed-a:v160' });
|
||||
const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() });
|
||||
assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 });
|
||||
const [unbound, operator] = layer.trains;
|
||||
assert.equal(unbound.duplicate, true, 'not on the operator’s line');
|
||||
assert.equal(operator.duplicate, false);
|
||||
assert.deepEqual(layer.calls.selected, [operator.key]);
|
||||
// When the copy on the operator's line goes (its feed left the view), the
|
||||
// other is drawn.
|
||||
layer.state._trains.delete(operator.key);
|
||||
filter.scheduleRefresh();
|
||||
assert.equal(unbound.duplicate, false);
|
||||
assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 });
|
||||
});
|
||||
|
||||
test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => {
|
||||
for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5'])
|
||||
assert.equal(recordTrainNumber({ label }), null, label);
|
||||
assert.deepEqual(labelNumbers('RB 1').line, { product: 'RB', number: '1' });
|
||||
// Three digits, or a long-distance product's own short number, are a
|
||||
// train's.
|
||||
assert.equal(recordTrainNumber({ label: 'EC 7' }), '7');
|
||||
assert.equal(recordTrainNumber({ label: 'NJ 40' }), '40');
|
||||
assert.equal(recordTrainNumber({ label: 'S 1264' }), '1264');
|
||||
// A line name gives way to a trip id that is the train's number.
|
||||
assert.equal(recordTrainNumber({ label: '5', tripId: 'AM:5' }), '5');
|
||||
|
||||
// An S1 and an RB 1 25 km apart are never joined as one train numbered 1.
|
||||
const s1 = copy('s1', 'feed-a', {
|
||||
...recordNumbers({ label: 'S1' }),
|
||||
lat: 48.14,
|
||||
lon: 11.56,
|
||||
category: 'local',
|
||||
});
|
||||
const rb1 = copy('rb1', 'feed-b', {
|
||||
...recordNumbers({ label: 'RB 1' }),
|
||||
lat: 48.14 + 25 / 111.32,
|
||||
lon: 11.56,
|
||||
category: 'regional',
|
||||
});
|
||||
assert.equal(sameTrain(s1, rb1), false);
|
||||
assert.deepEqual(matchCopies([s1, rb1]), []);
|
||||
// Nearer, still two lines.
|
||||
assert.equal(sameTrain(s1, { ...rb1, lat: 48.15, category: 'local' }), false);
|
||||
});
|
||||
|
||||
test('copies on one line are one train in one category, the nearest first', () => {
|
||||
const at = (key, feedId, km, extra = {}) =>
|
||||
copy(key, feedId, {
|
||||
...recordNumbers({ label: extra.label ?? 'S1' }),
|
||||
lat: 48.14 + km / 111.32,
|
||||
lon: 11.56,
|
||||
category: 'local',
|
||||
...extra,
|
||||
});
|
||||
const gps = at('gps', 'feed-a', 0);
|
||||
// The other feed reports two S1 trains near it; the nearer is the same.
|
||||
const near = at('near', 'feed-b', 1, { position: 'event' });
|
||||
const next = at('next', 'feed-b', 6, { label: 'S 1', position: 'event' });
|
||||
assert.equal(sameTrain(gps, near), true);
|
||||
assert.deepEqual(
|
||||
matchCopies([next, gps, near]).map((group) =>
|
||||
group.map((c) => c.key).sort(),
|
||||
),
|
||||
[['gps', 'near']],
|
||||
);
|
||||
// Another category, or beyond the match radius: not one train.
|
||||
assert.equal(sameTrain(gps, { ...near, category: 'regional' }), false);
|
||||
assert.equal(sameTrain(gps, at('far', 'feed-b', 20)), false);
|
||||
});
|
||||
|
||||
test('copies on one line 14 km apart east-west at 60°N are compared', () => {
|
||||
// A 0.25° grid column is 13.9 km wide there: the copies sit two columns
|
||||
// apart.
|
||||
const line = { ...recordNumbers({ label: 'R1' }), category: 'regional' };
|
||||
const west = copy('west', 'feed-a', { lat: 60, lon: 10.24, ...line });
|
||||
const east = copy('east', 'feed-b', { lat: 60, lon: 10.5, ...line });
|
||||
assert.equal(sameTrain(west, east), true);
|
||||
assert.equal(matchCopies([west, east]).length, 1);
|
||||
});
|
||||
|
||||
test('the filter reads lines and categories from the trains it compares', () => {
|
||||
const entry = (feedId, label, category, lat) => ({
|
||||
key: `${feedId}:${label}`,
|
||||
feedId,
|
||||
category,
|
||||
record: {
|
||||
id: `v-${feedId}`,
|
||||
label,
|
||||
lat,
|
||||
lon: 11.56,
|
||||
positionSource: 'gps',
|
||||
},
|
||||
});
|
||||
const trains = [
|
||||
entry('munich-live', 'S1', 'local', 48.14),
|
||||
entry('other-live', 'S 1', 'local', 48.15),
|
||||
entry('third-live', 'RB 1', 'regional', 48.16),
|
||||
];
|
||||
const state = {
|
||||
_trains: new Map(trains.map((t) => [t.key, t])),
|
||||
_pathPacks: new Map(),
|
||||
_selectedKey: null,
|
||||
};
|
||||
const parts = {
|
||||
rendering: { paint() {}, schedulePlayback() {} },
|
||||
selection: { selectTrain() {} },
|
||||
};
|
||||
const filter = createDuplicateFilter({
|
||||
state,
|
||||
parts,
|
||||
defer: (fn) => fn(),
|
||||
});
|
||||
assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 });
|
||||
assert.equal(trains[2].duplicate, false, 'the RB 1 is its own train');
|
||||
});
|
||||
|
||||
test('a Swiss IC line name is not a train number', () => {
|
||||
assert.deepEqual(labelNumbers('IC 1'), {
|
||||
number: null,
|
||||
line: { product: 'IC', number: '1' },
|
||||
});
|
||||
assert.deepEqual(labelNumbers('IC 2010'), { number: '2010', line: null });
|
||||
assert.deepEqual(labelNumbers('EC 7'), { number: '7', line: null });
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
FOLLOW_HANDOVER_MS,
|
||||
railFollowOffset,
|
||||
railTrainFollowable,
|
||||
} from './policy.js';
|
||||
|
||||
/** The selected train's kind in the shared world-focus framing. */
|
||||
export const RAIL_FOCUS_KIND = 'train';
|
||||
|
||||
/**
|
||||
* The selected train's camera.
|
||||
*
|
||||
* A moving train is followed as a tracked aircraft or satellite is: the layer
|
||||
* gives Cesium's `viewer.trackedEntity` an entity that stands on the train's
|
||||
* marker, framed by the shared world-focus framing for a train, so the camera
|
||||
* rides with the train and the user still orbits and zooms around it. Any
|
||||
* other train gets one fly-to through the shared world-focus request, as a
|
||||
* vessel does. Inside the Cockpit that request is refused with its toast.
|
||||
*
|
||||
* The follow lets go when the train is deselected (an empty click, Escape, its
|
||||
* category hidden, the train leaving its feed, the layer turned off), when
|
||||
* another train is selected (which takes the camera in its turn), and when
|
||||
* another owner takes the camera: an aircraft or satellite tracked by another
|
||||
* layer clears the selection as well, while a navigation that only releases
|
||||
* the follow camera (a search, a fly-to) leaves the train selected. Letting
|
||||
* go leaves the camera where it is.
|
||||
*
|
||||
* When the selection moves to another feed's copy of the same train
|
||||
* (dedupe.js), the tracked entity stays and glides to the new copy.
|
||||
*
|
||||
* Services (all optional; without them a selection has no camera):
|
||||
* services.camera = {
|
||||
* framing: {rangeM, pitchDeg}, // WORLD_FOCUS_FRAMING.train
|
||||
* requestWorldFocus(detail) → boolean, // src/worldFocus.js
|
||||
* applyTrackedCameraFrame(viewer, entity, viewFrom) → stop,
|
||||
* isCockpitActive() → boolean,
|
||||
* }
|
||||
*
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createFollow({ state, services, parts }) {
|
||||
const camera = services.camera ?? null;
|
||||
let removeTrackedListener = null;
|
||||
|
||||
function markerPosition(entry) {
|
||||
const p = entry?.marker?.position;
|
||||
return p &&
|
||||
Number.isFinite(p.x) &&
|
||||
Number.isFinite(p.y) &&
|
||||
Number.isFinite(p.z) &&
|
||||
Cesium.Cartesian3.magnitudeSquared(p) > 0
|
||||
? p
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the tracked entity stands: on the selected train's marker, or
|
||||
* gliding to it after a handover. Never hands out the marker's own vector.
|
||||
*/
|
||||
function followedPosition(follow, result) {
|
||||
const live = markerPosition(state._trains.get(state._selectedKey));
|
||||
if (live) Cesium.Cartesian3.clone(live, follow.last);
|
||||
const out = result ?? new Cesium.Cartesian3();
|
||||
const blend = follow.blend;
|
||||
if (blend) {
|
||||
const t = (performance.now() - blend.startMs) / FOLLOW_HANDOVER_MS;
|
||||
if (t < 1) {
|
||||
const eased = t <= 0 ? 0 : t * t * (3 - 2 * t);
|
||||
Cesium.Cartesian3.lerp(blend.from, follow.last, eased, out);
|
||||
return Cesium.Cartesian3.clone(out, follow.shown);
|
||||
}
|
||||
follow.blend = null;
|
||||
}
|
||||
Cesium.Cartesian3.clone(follow.last, out);
|
||||
Cesium.Cartesian3.clone(out, follow.shown);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the camera follows this train now. */
|
||||
function isFollowing(key) {
|
||||
const follow = state._follow;
|
||||
return Boolean(
|
||||
follow &&
|
||||
follow.key === key &&
|
||||
state._viewer?.trackedEntity === follow.entity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let go of the train. The camera stays where the follow left it.
|
||||
* @param {{untrack?: boolean}} [options] `untrack: false` when another
|
||||
* owner already took the camera: its tracked entity is left alone.
|
||||
*/
|
||||
function release({ untrack = true } = {}) {
|
||||
const follow = state._follow;
|
||||
if (!follow) return;
|
||||
// Cleared first: the tracked-entity change below is this layer's own.
|
||||
state._follow = null;
|
||||
follow.stopFrame?.();
|
||||
const viewer = state._viewer;
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
if (untrack && viewer.trackedEntity === follow.entity)
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.entities.remove(follow.entity);
|
||||
}
|
||||
|
||||
function startFollow(entry, position) {
|
||||
const viewer = state._viewer;
|
||||
const offset = railFollowOffset(camera.framing, viewer.camera?.heading);
|
||||
const follow = {
|
||||
key: entry.key,
|
||||
entity: null,
|
||||
last: Cesium.Cartesian3.clone(position),
|
||||
shown: Cesium.Cartesian3.clone(position),
|
||||
blend: null,
|
||||
stopFrame: null,
|
||||
};
|
||||
// Cesium's tracking camera engages only on an entity it can bound, so the
|
||||
// entity has a point: a transparent one, never drawn or picked, leaving
|
||||
// the marker the train's only visual.
|
||||
const entity = viewer.entities.add({
|
||||
position: new Cesium.CallbackProperty(
|
||||
(time, result) => followedPosition(follow, result),
|
||||
false,
|
||||
),
|
||||
// The frame the close-range guard (applyTrackedCameraFrame) uses too.
|
||||
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
|
||||
viewFrom: new Cesium.Cartesian3(offset.x, offset.y, offset.z),
|
||||
point: { pixelSize: 1, color: Cesium.Color.TRANSPARENT },
|
||||
});
|
||||
entity.gevSelectionOrigin = 'user';
|
||||
follow.entity = entity;
|
||||
state._follow = follow;
|
||||
// A flight in progress would keep Cesium from applying the frame.
|
||||
viewer.camera?.cancelFlight?.();
|
||||
viewer.trackedEntity = entity;
|
||||
follow.stopFrame =
|
||||
camera.applyTrackedCameraFrame?.(viewer, entity, entity.viewFrom) || null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the camera to a train the user selected: follow it when it moves,
|
||||
* else fly to it once. A train already followed keeps its follow.
|
||||
* @param {object} entry
|
||||
* @returns {boolean} Whether the camera was taken or kept.
|
||||
*/
|
||||
function focus(entry) {
|
||||
if (!camera || !entry) return false;
|
||||
if (isFollowing(entry.key)) return true;
|
||||
release();
|
||||
const viewer = state._viewer;
|
||||
const position = markerPosition(entry);
|
||||
if (!viewer || !position) return false;
|
||||
if (
|
||||
camera.framing &&
|
||||
!camera.isCockpitActive?.() &&
|
||||
railTrainFollowable(entry)
|
||||
)
|
||||
return startFollow(entry, position);
|
||||
const label = String(entry.record?.label || entry.record?.id || '').trim();
|
||||
return (
|
||||
camera.requestWorldFocus?.({
|
||||
kind: RAIL_FOCUS_KIND,
|
||||
id: entry.key,
|
||||
label: label || entry.key,
|
||||
position: Cesium.Cartesian3.clone(position),
|
||||
}) === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection moved to another feed's copy of the followed train: the
|
||||
* tracked entity stays, and glides from where it stands to the new copy.
|
||||
* @param {object} entry The copy now selected.
|
||||
*/
|
||||
function handover(entry) {
|
||||
const follow = state._follow;
|
||||
if (!follow || !entry || follow.key === entry.key) return;
|
||||
follow.key = entry.key;
|
||||
follow.blend = {
|
||||
from: Cesium.Cartesian3.clone(follow.shown),
|
||||
startMs: performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function onTrackedEntityChanged() {
|
||||
const follow = state._follow;
|
||||
const tracked = state._viewer?.trackedEntity;
|
||||
if (!follow || tracked === follow.entity) return;
|
||||
// An aircraft or satellite another layer tracks takes the scene, as it
|
||||
// does from a selected vessel. A navigation that only releases the follow
|
||||
// camera keeps the train selected.
|
||||
if (tracked) parts.selection.clearSelection({ untrack: false });
|
||||
else release({ untrack: false });
|
||||
}
|
||||
|
||||
/** Watch for other camera owners while the layer is on. */
|
||||
function install(viewer) {
|
||||
if (removeTrackedListener || !viewer?.trackedEntityChanged) return;
|
||||
removeTrackedListener = viewer.trackedEntityChanged.addEventListener(
|
||||
onTrackedEntityChanged,
|
||||
);
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
removeTrackedListener?.();
|
||||
removeTrackedListener = null;
|
||||
}
|
||||
|
||||
/** For QA probes: which train the camera follows, if any. */
|
||||
function stats() {
|
||||
const follow = state._follow;
|
||||
return follow
|
||||
? { key: follow.key, tracked: isFollowing(follow.key) }
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
focus,
|
||||
release,
|
||||
handover,
|
||||
isFollowing,
|
||||
install,
|
||||
uninstall,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// The selected train's camera and its visibility from far out: a moving train
|
||||
// is followed through Cesium's tracked entity (as an aircraft is), any other
|
||||
// train is flown to once, and the selected train's feed stays polled at any
|
||||
// distance while the rest of the network hides as before.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
FOLLOW_HANDOVER_MS,
|
||||
RAIL_POLL_MS,
|
||||
railFollowOffset,
|
||||
railTrainFollowable,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
import { WORLD_FOCUS_FRAMING } from '../../worldFocus.js';
|
||||
import { createTrack, insertFix } from '../../data/contactPlayback.js';
|
||||
import {
|
||||
BOSTON,
|
||||
RAIL_HARNESS_T0 as T0,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
|
||||
const MADRID = { lat: 40.4168, lon: -3.7038 };
|
||||
const ORBITAL_M = 20_000_000;
|
||||
|
||||
/**
|
||||
* Feed a train reports that move it, poll after poll, until its playback is
|
||||
* gliding along them.
|
||||
*/
|
||||
async function bringToMotion(app, feedId, id, start) {
|
||||
let step = 0;
|
||||
app.feeds.set(feedId, () => [
|
||||
train(id, start.lat + step * 0.003, start.lon),
|
||||
train(`${id}-parked`, start.lat - 0.05, start.lon - 0.05),
|
||||
]);
|
||||
await app.enable();
|
||||
const key = railTrainKey(feedId, id);
|
||||
for (let i = 0; i < 12 && !railTrainFollowable(app.entry(key)); i += 1) {
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.ok(railTrainFollowable(app.entry(key)), 'the train is moving');
|
||||
return key;
|
||||
}
|
||||
|
||||
test('the follow offset is the shared train framing seen along the heading', () => {
|
||||
const framing = WORLD_FOCUS_FRAMING.train;
|
||||
const north = railFollowOffset(framing, 0);
|
||||
// Behind (south of) the train and above it, looking north and down.
|
||||
assert.ok(Math.abs(north.x) < 1e-9);
|
||||
assert.ok(north.y < 0 && north.z > 0);
|
||||
const range = Math.hypot(north.x, north.y, north.z);
|
||||
assert.ok(Math.abs(range - framing.rangeM) < 1e-6);
|
||||
const pitch = (Math.atan2(north.z, -north.y) * 180) / Math.PI;
|
||||
assert.ok(Math.abs(pitch + framing.pitchDeg) < 1e-9);
|
||||
// Facing east: the camera stands west of the train.
|
||||
const east = railFollowOffset(framing, Math.PI / 2);
|
||||
assert.ok(east.x < 0 && Math.abs(east.y) < 1e-6);
|
||||
});
|
||||
|
||||
/** A playback track of reports 15 s apart at these [lat, lon] places. */
|
||||
function trackOf(points) {
|
||||
const track = createTrack();
|
||||
points.forEach(([lat, lon], i) =>
|
||||
insertFix(track, { t: T0 + i * 15_000, lat, lon }, { replayed: true }),
|
||||
);
|
||||
return track;
|
||||
}
|
||||
|
||||
test('a train is followed while it moves, and not once it is parked or unheard', () => {
|
||||
const entry = (fields, points = [[BOSTON.lat, BOSTON.lon]]) => ({
|
||||
sample: { phase: 'playing', latestReportAgeMs: 20_000, ...fields },
|
||||
track: trackOf(points),
|
||||
});
|
||||
const moved = [
|
||||
[BOSTON.lat, BOSTON.lon],
|
||||
[BOSTON.lat + 0.003, BOSTON.lon],
|
||||
];
|
||||
const parked = [
|
||||
[BOSTON.lat, BOSTON.lon],
|
||||
[BOSTON.lat + 0.00005, BOSTON.lon],
|
||||
];
|
||||
// Gliding between two reports.
|
||||
assert.equal(railTrainFollowable(entry({ segmentSpeedMps: 25 })), true);
|
||||
// Dwelling at a stop with its departure already reported.
|
||||
assert.equal(
|
||||
railTrainFollowable(
|
||||
entry({
|
||||
segmentSpeedMps: 0,
|
||||
motion: 'stopped',
|
||||
displayT: 1_000,
|
||||
nextMotionT: 31_000,
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Between reports: the playback waits at the latest one (or is still
|
||||
// buffering) after the last two moved it; this is a moving train.
|
||||
for (const phase of ['held', 'buffering'])
|
||||
assert.equal(
|
||||
railTrainFollowable(
|
||||
entry({ phase, segmentSpeedMps: 0, motion: 'unknown' }, moved),
|
||||
),
|
||||
true,
|
||||
phase,
|
||||
);
|
||||
// Parked: the last reports are one place (GPS jitter aside), and nothing
|
||||
// says it will move.
|
||||
assert.equal(
|
||||
railTrainFollowable(
|
||||
entry(
|
||||
{
|
||||
segmentSpeedMps: 0,
|
||||
motion: 'stopped',
|
||||
displayT: 1_000,
|
||||
nextMotionT: Infinity,
|
||||
},
|
||||
parked,
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
// A single report, a train not heard from lately, or no playback at all.
|
||||
assert.equal(
|
||||
railTrainFollowable(
|
||||
entry({ phase: 'held', segmentSpeedMps: 0, motion: 'unknown' }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
railTrainFollowable(
|
||||
entry({ segmentSpeedMps: 25, latestReportAgeMs: 120_000 }, moved),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(railTrainFollowable({}), false);
|
||||
assert.equal(railTrainFollowable(null), false);
|
||||
});
|
||||
|
||||
test('selecting a moving train hands Cesium a tracked entity that rides on its marker', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.viewer.camera.heading = Math.PI / 4;
|
||||
const cancelled = app.viewer.camera.flightsCancelled;
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
|
||||
const entity = app.viewer.trackedEntity;
|
||||
assert.ok(entity, 'the camera tracks an entity');
|
||||
assert.equal(app.focusRequests.length, 0, 'no one-shot fly-to');
|
||||
assert.equal(app.viewer.camera.flightsCancelled, cancelled + 1);
|
||||
// The tracked-frame guard the aircraft use frames it once, from the
|
||||
// train's own framing seen along the camera's heading.
|
||||
assert.equal(app.frames.length, 1);
|
||||
assert.equal(app.frames[0].entity, entity);
|
||||
const viewFrom = entity.viewFrom.getValue(Cesium.JulianDate.now());
|
||||
const expected = railFollowOffset(WORLD_FOCUS_FRAMING.train, Math.PI / 4);
|
||||
assert.ok(Math.abs(viewFrom.x - expected.x) < 1e-6);
|
||||
assert.ok(Math.abs(viewFrom.z - expected.z) < 1e-6);
|
||||
// Transparent and unbounded by anything but a point: the marker stays the
|
||||
// only thing drawn and picked there.
|
||||
assert.equal(entity.point.color.getValue().alpha, 0);
|
||||
|
||||
// The entity stands where the marker is drawn, frame after frame.
|
||||
const marker = app.entry(key).marker;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
app.run(1_000);
|
||||
const shown = app.trackedPosition();
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(shown, marker.position) < 1e-6,
|
||||
'on the marker',
|
||||
);
|
||||
}
|
||||
assert.ok(app.layer.getRouteDisplayStats().follow.tracked);
|
||||
|
||||
// Picking the followed train again keeps the follow as it is.
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
assert.equal(app.viewer.trackedEntity, entity);
|
||||
assert.equal(app.frames.length, 1);
|
||||
});
|
||||
|
||||
test('a parked train, or one known from a single report, is flown to once', async (t) => {
|
||||
const app = harness(t);
|
||||
await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
const parked = railTrainKey('mbta-rail', 'T1-parked');
|
||||
assert.equal(railTrainFollowable(app.entry(parked)), false);
|
||||
app.parts().selection.selectAndFocusTrain(parked);
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.frames.length, 0);
|
||||
assert.equal(app.focusRequests.length, 1);
|
||||
const request = app.focusRequests[0];
|
||||
assert.equal(request.kind, 'train');
|
||||
assert.equal(request.id, parked);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(
|
||||
request.position,
|
||||
app.entry(parked).marker.position,
|
||||
) < 1e-6,
|
||||
);
|
||||
// A copy, never the billboard's own vector.
|
||||
assert.notEqual(request.position, app.entry(parked).marker.position);
|
||||
|
||||
// A second click flies again, as for a vessel.
|
||||
app.parts().selection.selectAndFocusTrain(parked);
|
||||
assert.equal(app.focusRequests.length, 2);
|
||||
|
||||
// A train with one report has no motion to follow.
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', BOSTON.lat, BOSTON.lon),
|
||||
train('T1-parked', BOSTON.lat - 0.05, BOSTON.lon - 0.05),
|
||||
train('NEW', BOSTON.lat + 0.02, BOSTON.lon + 0.02),
|
||||
]);
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
const fresh = railTrainKey('mbta-rail', 'NEW');
|
||||
app.parts().selection.selectAndFocusTrain(fresh);
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.focusRequests.at(-1).id, fresh);
|
||||
});
|
||||
|
||||
test('inside the Cockpit a moving train is not followed: the shared request refuses', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.setCockpit(true);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.focusRequests.length, 1, 'the request carries the refusal');
|
||||
assert.equal(app.state()._selectedKey, key, 'the train is still selected');
|
||||
});
|
||||
|
||||
test('deselecting or Escape lets go of the camera where it is', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
const entity = app.viewer.trackedEntity;
|
||||
app.parts().selection.clearSelection();
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.viewer.entities.contains(entity), false);
|
||||
assert.equal(app.frames[0].stopped, true, 'the frame guard is released');
|
||||
assert.equal(app.state()._follow, null);
|
||||
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
assert.ok(app.viewer.trackedEntity);
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.viewer.entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('orbiting and zooming around the train keep the follow, as for an aircraft', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
const entity = app.viewer.trackedEntity;
|
||||
// Camera input while tracked moves the camera around the target; nothing
|
||||
// here changes the tracked entity, so the follow stays.
|
||||
await app.moveCamera({ altitude: 60_000 });
|
||||
app.run(2_000);
|
||||
await app.moveCamera({ altitude: 8_000 });
|
||||
assert.equal(app.viewer.trackedEntity, entity);
|
||||
assert.equal(app.frames[0].stopped, false);
|
||||
assert.ok(app.parts().follow.isFollowing(key));
|
||||
});
|
||||
|
||||
test('another camera owner ends the follow: a tracked aircraft takes the selection too', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
const ours = app.viewer.trackedEntity;
|
||||
|
||||
// An aircraft layer tracks its contact.
|
||||
const aircraft = app.viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(-71, 42.3, 3000),
|
||||
point: { pixelSize: 4 },
|
||||
});
|
||||
app.viewer.trackedEntity = aircraft;
|
||||
assert.equal(app.viewer.trackedEntity, aircraft, 'its entity is untouched');
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.viewer.entities.contains(ours), false);
|
||||
assert.equal(app.frames[0].stopped, true);
|
||||
});
|
||||
|
||||
test('a navigation that releases the follow camera keeps the train selected', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
const ours = app.viewer.trackedEntity;
|
||||
// What the shell's release does before a search or a fly-to.
|
||||
app.viewer.trackedEntity = undefined;
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
assert.equal(app.state()._follow, null);
|
||||
assert.equal(app.viewer.entities.contains(ours), false);
|
||||
// Picking the train again takes the camera back.
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
assert.ok(app.parts().follow.isFollowing(key));
|
||||
});
|
||||
|
||||
test('selecting another train hands the camera over with a fresh frame', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
const first = app.viewer.trackedEntity;
|
||||
const parked = railTrainKey('mbta-rail', 'T1-parked');
|
||||
app.parts().selection.selectAndFocusTrain(parked);
|
||||
assert.equal(app.viewer.trackedEntity, undefined, 'the follow let go');
|
||||
assert.equal(app.viewer.entities.contains(first), false);
|
||||
assert.equal(app.focusRequests.at(-1).id, parked, 'and the new train flew');
|
||||
});
|
||||
|
||||
test('from orbit the selected train and its feed stay; the other trains go as before', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
const parked = railTrainKey('mbta-rail', 'T1-parked');
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
|
||||
await app.moveCamera({ altitude: ORBITAL_M });
|
||||
assert.ok(ORBITAL_M > ACTIVATION_EXIT_ALTITUDE_M);
|
||||
const state = app.state();
|
||||
assert.equal(state._heldFeedId, 'mbta-rail');
|
||||
assert.ok(state._activeFeeds.has('mbta-rail'), 'its feed is still polled');
|
||||
assert.ok(app.entry(key)?.marker?.show, 'the selected train is drawn');
|
||||
assert.equal(app.entry(parked), undefined, 'the others have gone');
|
||||
assert.ok(app.viewer.trackedEntity, 'and the follow holds');
|
||||
|
||||
// The feed keeps updating, for this train alone.
|
||||
const before = app.requested.length;
|
||||
const position = Cesium.Cartesian3.clone(app.entry(key).marker.position);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.ok(app.requested.length > before);
|
||||
assert.equal(app.entry(parked), undefined);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(position, app.entry(key).marker.position) > 1,
|
||||
'it still moves',
|
||||
);
|
||||
assert.equal(app.layer.getRouteDisplayStats().heldFeed, 'mbta-rail');
|
||||
|
||||
// Deselecting returns the feed to the viewport's rules: from orbit, gone.
|
||||
app.parts().selection.clearSelection();
|
||||
await app.recheck();
|
||||
assert.equal(state._heldFeedId, null);
|
||||
assert.equal(state._activeFeeds.has('mbta-rail'), false);
|
||||
assert.equal(state._trains.size, 0);
|
||||
});
|
||||
|
||||
test('a held feed the view wants again brings its other trains back at once', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
const parked = railTrainKey('mbta-rail', 'T1-parked');
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
await app.moveCamera({ altitude: ORBITAL_M });
|
||||
assert.equal(app.entry(parked), undefined);
|
||||
const before = app.requested.length;
|
||||
await app.moveCamera({ altitude: 30_000 });
|
||||
assert.equal(app.state()._heldFeedId, null);
|
||||
assert.ok(app.requested.length > before, 'polled at once');
|
||||
assert.ok(app.entry(parked), 'the others are back');
|
||||
assert.ok(app.entry(key), 'and the selection is kept');
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
});
|
||||
|
||||
test('far side of the globe: the selected train is hidden behind the Earth', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON);
|
||||
app.parts().selection.selectAndFocusTrain(key);
|
||||
app.viewer.trackedEntity = undefined; // the camera is free to go round
|
||||
await app.moveCamera({ altitude: ORBITAL_M });
|
||||
app.viewer.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
const marker = app.entry(key).marker;
|
||||
app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
BOSTON.lon,
|
||||
BOSTON.lat,
|
||||
ORBITAL_M,
|
||||
);
|
||||
app.run(250);
|
||||
assert.equal(marker.show, true, 'seen from above');
|
||||
app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
BOSTON.lon + 180,
|
||||
-BOSTON.lat,
|
||||
ORBITAL_M,
|
||||
);
|
||||
app.run(250);
|
||||
assert.equal(marker.show, false, 'hidden from the antipode');
|
||||
});
|
||||
|
||||
test('the follow glides to the copy another feed now draws, without letting go', async (t) => {
|
||||
const app = harness(t, { at: MADRID });
|
||||
// One Alvia in two feeds: a station-event copy and, a poll later, a GPS
|
||||
// copy that outranks it, so the duplicate filter moves the selection.
|
||||
let step = 0;
|
||||
let gpsCopy = false;
|
||||
const near = (offset) => ({
|
||||
lat: MADRID.lat + offset + step * 0.003,
|
||||
lon: MADRID.lon,
|
||||
});
|
||||
app.feeds.set('renfe-cercanias', () => [
|
||||
train('A1', near(0).lat, near(0).lon, {
|
||||
label: 'ALVIA 4073',
|
||||
positionSource: 'event',
|
||||
}),
|
||||
]);
|
||||
app.feeds.set('renfe-larga-distancia', () =>
|
||||
gpsCopy
|
||||
? [
|
||||
train('B1', near(0.01).lat, near(0.01).lon, {
|
||||
label: '4073',
|
||||
positionSource: 'gps',
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
await app.enable();
|
||||
const a = railTrainKey('renfe-cercanias', 'A1');
|
||||
const b = railTrainKey('renfe-larga-distancia', 'B1');
|
||||
for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) {
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.ok(railTrainFollowable(app.entry(a)));
|
||||
app.parts().selection.selectAndFocusTrain(a);
|
||||
const entity = app.viewer.trackedEntity;
|
||||
assert.ok(entity);
|
||||
const onA = app.trackedPosition();
|
||||
|
||||
gpsCopy = true;
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.equal(app.state()._selectedKey, b, 'the selection moved');
|
||||
assert.equal(app.viewer.trackedEntity, entity, 'the same follow');
|
||||
assert.equal(app.frames.length, 1, 'no new frame: no jump');
|
||||
assert.equal(app.focusRequests.length, 0);
|
||||
assert.ok(app.parts().follow.isFollowing(b));
|
||||
// It starts where it stood, then glides onto the new copy.
|
||||
const start = app.trackedPosition();
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(start, onA) < 50,
|
||||
'no jump at the handover',
|
||||
);
|
||||
app.run(FOLLOW_HANDOVER_MS + 250);
|
||||
const settled = app.trackedPosition();
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(settled, app.entry(b).marker.position) < 1e-6,
|
||||
'on the new copy',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Where trains and the selected trip's stops stand while their floor cell is
|
||||
* cold: a train on the floor it last stood on (or a neighbouring cell's), a
|
||||
* stop on the ellipsoid only until its cell is warm, and the cell is warmed
|
||||
* off the frame path. Seen on Google's photorealistic tiles, where a train
|
||||
* running onto unsampled cells dropped about 150 m under its line near
|
||||
* Chicago, then jumped back once a cell warmed.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
BOSTON,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
import {
|
||||
COLD_CELL_FLUSH_MS,
|
||||
COLD_CELL_RETRY_MS,
|
||||
COLD_CELLS_PER_FLUSH,
|
||||
FLOOR_CELLS_BURST,
|
||||
FLOOR_CELLS_PER_MINUTE,
|
||||
FLOOR_WARM_MAX_DIST_KM,
|
||||
STOP_REFRESH_MS,
|
||||
TRACK_MAX_GRADE,
|
||||
TRACK_PRIOR_BELOW_M,
|
||||
TRACK_SAMPLE_MAX_AGE_MS,
|
||||
TRACK_SAMPLE_MAX_CAMERA_M,
|
||||
TRACK_SAMPLE_REFRESH_MS,
|
||||
TRACK_SAMPLE_RETRY_MS,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js';
|
||||
|
||||
const CHICAGO = { lat: 41.8789, lon: -87.6359 };
|
||||
const WEST = { lat: 41.8789, lon: -87.7 };
|
||||
/** A camera low enough over Chicago for the mesh to be sampled. */
|
||||
const LOW_M = 2_000;
|
||||
const cellKey = ({ lat, lon }) =>
|
||||
`${Math.round(lat * 1000)},${Math.round(lon * 1000)}`;
|
||||
|
||||
/** A floor service: warm cells by 0.001° key, and the warm requests made. */
|
||||
function floorService({ neighbour = null } = {}) {
|
||||
const warm = new Map();
|
||||
const requested = [];
|
||||
const near = { m: neighbour, coarseCalls: 0 };
|
||||
return {
|
||||
warm,
|
||||
requested,
|
||||
near,
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M: 1.5,
|
||||
cachedGroundFloor: (lat, lon) => warm.get(cellKey({ lat, lon })) ?? null,
|
||||
warmGroundFloor(cells) {
|
||||
requested.push(...cells.map(cellKey));
|
||||
},
|
||||
coarseFloorCoord(lat, lon) {
|
||||
near.coarseCalls += 1;
|
||||
return {
|
||||
lat: Math.round(lat * 1000) / 1000,
|
||||
lon: Math.round(lon * 1000) / 1000,
|
||||
};
|
||||
},
|
||||
neighborFloorM: () => near.m,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Photorealistic tiles in the scene: a shown tileset whose streaming state
|
||||
* the test sets, and the mesh height every probe reads (recorded).
|
||||
*/
|
||||
function photoreal(app, { loaded = true, height = 158 } = {}) {
|
||||
const tileset = { show: true, tilesLoaded: loaded };
|
||||
const mesh = { height, probes: [] };
|
||||
app.viewer.scene.primitives.add(tileset);
|
||||
app.viewer.scene.sampleHeight = (carto) => {
|
||||
mesh.probes.push({
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
});
|
||||
return mesh.height;
|
||||
};
|
||||
return { tileset, mesh };
|
||||
}
|
||||
|
||||
test('a train on a cold cell keeps its last floor, and the cell is warmed', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(CHICAGO), 150);
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { heightFor } = app.parts().rendering;
|
||||
const train = {};
|
||||
assert.equal(heightFor(CHICAGO.lat, CHICAGO.lon, train), 151.5);
|
||||
// West of the city, not sampled yet: still on the Chicago floor.
|
||||
assert.equal(heightFor(WEST.lat, WEST.lon, train), 151.5);
|
||||
assert.deepEqual(floors.requested, []);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.deepEqual(floors.requested, [cellKey(WEST)]);
|
||||
// Every frame reads it again; it is asked for once until the retry time.
|
||||
heightFor(WEST.lat, WEST.lon, train);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(floors.requested.length, 1);
|
||||
t.mock.timers.tick(COLD_CELL_RETRY_MS);
|
||||
heightFor(WEST.lat, WEST.lon, train);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(floors.requested.length, 2);
|
||||
// Once warm, its own floor.
|
||||
floors.warm.set(cellKey(WEST), 160);
|
||||
assert.equal(heightFor(WEST.lat, WEST.lon, train), 161.5);
|
||||
// A point with no floor of its own to keep is on the ellipsoid.
|
||||
assert.equal(heightFor(41.9, -87.8), 0);
|
||||
});
|
||||
|
||||
test('a warm neighbouring cell comes before the last floor', async (t) => {
|
||||
const floors = floorService({ neighbour: 170 });
|
||||
floors.warm.set(cellKey(CHICAGO), 150);
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { heightFor } = app.parts().rendering;
|
||||
const train = {};
|
||||
heightFor(CHICAGO.lat, CHICAGO.lon, train);
|
||||
assert.equal(heightFor(WEST.lat, WEST.lon, train), 171.5);
|
||||
});
|
||||
|
||||
test('a train gliding across a cold cell asks for its coarse cell once', async (t) => {
|
||||
const floors = floorService({ neighbour: 170 });
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { heightFor } = app.parts().rendering;
|
||||
const train = {};
|
||||
floors.near.coarseCalls = 0;
|
||||
// Frames inside one 0.001° cell (centred on WEST), then the next cell.
|
||||
for (let i = 0; i < 20; i += 1)
|
||||
assert.equal(heightFor(WEST.lat, WEST.lon + i * 0.00002, train), 171.5);
|
||||
assert.equal(floors.near.coarseCalls, 1);
|
||||
heightFor(WEST.lat, WEST.lon + 0.001, train);
|
||||
assert.equal(floors.near.coarseCalls, 2);
|
||||
});
|
||||
|
||||
test('from far above, cold cells are not warmed', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: 500_000,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
app.parts().rendering.heightFor(WEST.lat, WEST.lon, {});
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.deepEqual(floors.requested, []);
|
||||
});
|
||||
|
||||
test('a high floor next to the line is a bump, not a jump (grade limit)', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(CHICAGO), 150);
|
||||
// The next cell's floor was read on a roof 30 m above the rails.
|
||||
const NEXT = { lat: CHICAGO.lat, lon: CHICAGO.lon + 0.0013 };
|
||||
floors.warm.set(cellKey(NEXT), 180);
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k1' };
|
||||
assert.equal(displayHeightFor(train, CHICAGO.lat, CHICAGO.lon), 151.5);
|
||||
const drawn = train.drawnHeight;
|
||||
const movedM = 0.0013 * 111_320 * Math.cos((CHICAGO.lat * Math.PI) / 180);
|
||||
const next = displayHeightFor(train, NEXT.lat, NEXT.lon);
|
||||
assert.ok(next > 151.5, 'climbs toward the higher floor');
|
||||
assert.ok(
|
||||
next <= 151.5 + TRACK_MAX_GRADE * movedM + 0.01,
|
||||
`no faster than the grade limit (${next})`,
|
||||
);
|
||||
// The drawn height is kept in place, not rebuilt every frame.
|
||||
assert.equal(train.drawnHeight, drawn);
|
||||
assert.equal(drawn.h, next);
|
||||
});
|
||||
|
||||
test('a real floor replacing the ellipsoid fallback is taken at once', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k2' };
|
||||
assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0);
|
||||
floors.warm.set(cellKey(WEST), 160);
|
||||
assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 161.5);
|
||||
});
|
||||
|
||||
test('a neighbouring floor replacing the ellipsoid is taken at once', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k2n' };
|
||||
assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0);
|
||||
// Its own cell is still cold, a neighbour's is warm: no 5% ramp up from
|
||||
// the ellipsoid, 150 m under the mesh.
|
||||
floors.near.m = 170;
|
||||
const step = { lat: WEST.lat, lon: WEST.lon + 0.0002 };
|
||||
assert.equal(displayHeightFor(train, step.lat, step.lon), 171.5);
|
||||
// Between two fallbacks, the grade limit still holds.
|
||||
floors.near.m = 190;
|
||||
const after = displayHeightFor(train, step.lat, step.lon + 0.0002);
|
||||
assert.ok(after > 171.5 && after < 180, `ramps (${after})`);
|
||||
});
|
||||
|
||||
test('on photorealistic tiles the mesh is sampled on the line itself', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(WEST), 150);
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: LOW_M,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { mesh } = photoreal(app);
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k3' };
|
||||
// The exact point on the track, not the floor cell's centre.
|
||||
const ON_TRACK = { lat: WEST.lat + 0.00031, lon: WEST.lon - 0.00027 };
|
||||
assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 151.5);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 1);
|
||||
assert.ok(Math.abs(mesh.probes[0].lat - ON_TRACK.lat) < 1e-9);
|
||||
assert.ok(Math.abs(mesh.probes[0].lon - ON_TRACK.lon) < 1e-9);
|
||||
// The sample is the target now; the drawn height settles toward it.
|
||||
t.mock.timers.tick(20_000);
|
||||
assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 159.5);
|
||||
// Not asked again for the same bucket until the refresh time.
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 1);
|
||||
});
|
||||
|
||||
test('a sample far from the floor under it is a coarse tile and is dropped', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(WEST), 150);
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: LOW_M,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { mesh } = photoreal(app, { height: 400 });
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k4' };
|
||||
displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS + TRACK_SAMPLE_RETRY_MS);
|
||||
assert.equal(mesh.probes.length, 1);
|
||||
assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 151.5);
|
||||
// A valley floor read under a viaduct, well below the floor: dropped too.
|
||||
// (The next track bucket along, in the same floor cell.)
|
||||
const viaduct = { key: 'k4v' };
|
||||
const NEAR = { lat: WEST.lat, lon: WEST.lon - 0.0004 };
|
||||
mesh.height = 150 - TRACK_PRIOR_BELOW_M - 5;
|
||||
displayHeightFor(viaduct, NEAR.lat, NEAR.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.ok(mesh.probes.some((probe) => Math.abs(probe.lon - NEAR.lon) < 1e-9));
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(displayHeightFor(viaduct, NEAR.lat, NEAR.lon), 151.5);
|
||||
});
|
||||
|
||||
test('from far above, the line is not sampled', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(WEST), 150);
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: 500_000,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { mesh } = photoreal(app, { height: 151 });
|
||||
app.parts().rendering.displayHeightFor({ key: 'k5' }, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 0);
|
||||
});
|
||||
|
||||
test('the line is sampled only from a low camera, once the tiles are loaded', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(WEST), 150);
|
||||
// 20 km up the tiles near the camera are coarse, loaded or not.
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: 20_000,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { tileset, mesh } = photoreal(app);
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k6' };
|
||||
displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 0, 'not from 20 km');
|
||||
// Low enough, but the tiles are still streaming.
|
||||
await app.moveCamera({ altitude: 150 + TRACK_SAMPLE_MAX_CAMERA_M - 100 });
|
||||
tileset.tilesLoaded = false;
|
||||
t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS);
|
||||
displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 0, 'not while streaming');
|
||||
tileset.tilesLoaded = true;
|
||||
t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS);
|
||||
displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.equal(mesh.probes.length, 1);
|
||||
});
|
||||
|
||||
test('a sample read from lower down replaces one from higher up; an old one goes', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(WEST), 150);
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: 4_000,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { mesh } = photoreal(app, { height: 140 });
|
||||
const { displayHeightFor } = app.parts().rendering;
|
||||
const train = { key: 'k7' };
|
||||
/** Read the bucket again, and let the drawn height settle on its target. */
|
||||
const settled = () => {
|
||||
displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
t.mock.timers.tick(60_000);
|
||||
return displayHeightFor(train, WEST.lat, WEST.lon);
|
||||
};
|
||||
// From 4 km, a coarse tile 10 m low: inside the bounds, so kept for now.
|
||||
assert.equal(settled(), 141.5);
|
||||
// From 1 km the tiles are finer: the new reading replaces it.
|
||||
await app.moveCamera({ altitude: 1_000 });
|
||||
mesh.height = 158;
|
||||
t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS);
|
||||
assert.equal(settled(), 159.5);
|
||||
// Back up at 4.5 km, the coarse reading does not win over it...
|
||||
await app.moveCamera({ altitude: 4_500 });
|
||||
mesh.height = 140;
|
||||
const reads = mesh.probes.length;
|
||||
t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS);
|
||||
assert.equal(settled(), 159.5);
|
||||
assert.equal(mesh.probes.length, reads + 1, 'it was read again');
|
||||
// ...until the one from lower down is old.
|
||||
t.mock.timers.tick(TRACK_SAMPLE_MAX_AGE_MS);
|
||||
assert.equal(settled(), 141.5);
|
||||
});
|
||||
|
||||
test('disabling the layer drops the cells and samples still queued', async (t) => {
|
||||
const floors = floorService();
|
||||
floors.warm.set(cellKey(CHICAGO), 150);
|
||||
const app = harness(t, {
|
||||
at: CHICAGO,
|
||||
altitude: LOW_M,
|
||||
ground: floors.ground,
|
||||
});
|
||||
await app.enable();
|
||||
const { mesh } = photoreal(app);
|
||||
const { heightFor, displayHeightFor } = app.parts().rendering;
|
||||
heightFor(WEST.lat, WEST.lon, {});
|
||||
displayHeightFor({ key: 'k8' }, CHICAGO.lat, CHICAGO.lon);
|
||||
app.layer.disable(app.viewer);
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS * 3);
|
||||
assert.deepEqual(floors.requested, []);
|
||||
assert.equal(mesh.probes.length, 0);
|
||||
// Enabled again, the cell is asked for at once, not after a retry time.
|
||||
await app.enable();
|
||||
heightFor(WEST.lat, WEST.lon, {});
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.deepEqual(floors.requested, [cellKey(WEST)]);
|
||||
});
|
||||
|
||||
test('trains far from the camera do not warm floor cells', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
const { heightFor } = app.parts().rendering;
|
||||
// Well past FLOOR_WARM_MAX_DIST_KM from the camera over Chicago.
|
||||
const FAR = {
|
||||
lat: CHICAGO.lat,
|
||||
lon: CHICAGO.lon + (FLOOR_WARM_MAX_DIST_KM + 20) / 83,
|
||||
};
|
||||
heightFor(FAR.lat, FAR.lon, {});
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
assert.deepEqual(floors.requested, []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Cold cells for hundreds of trains near the camera, `rounds` flushes of
|
||||
* them; the cells the floor service was asked for.
|
||||
*/
|
||||
function crowd(t, app, floors, rounds) {
|
||||
const { heightFor } = app.parts().rendering;
|
||||
let asked = 0;
|
||||
for (let round = 0; round < rounds; round += 1) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const n = round * 100 + i;
|
||||
heightFor(
|
||||
CHICAGO.lat + (n % 60) * 0.002,
|
||||
CHICAGO.lon + Math.floor(n / 60) * 0.002,
|
||||
{},
|
||||
);
|
||||
}
|
||||
const before = floors.requested.length;
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
const flushed = floors.requested.length - before;
|
||||
assert.ok(flushed <= COLD_CELLS_PER_FLUSH, `one flush ${flushed}`);
|
||||
asked += flushed;
|
||||
}
|
||||
return asked;
|
||||
}
|
||||
|
||||
test('floor warming stays within the shared terrain budget', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
// The budget starts with its burst and refills at FLOOR_CELLS_PER_MINUTE:
|
||||
// 40 flushes (40 s) may spend at most the burst plus 40 s of refill.
|
||||
const asked = crowd(t, app, floors, 40);
|
||||
const secondsElapsed = (40 * COLD_CELL_FLUSH_MS) / 1000;
|
||||
assert.ok(
|
||||
asked <=
|
||||
FLOOR_CELLS_BURST + (FLOOR_CELLS_PER_MINUTE * secondsElapsed) / 60 + 1,
|
||||
`in ${secondsElapsed} s ${asked}`,
|
||||
);
|
||||
assert.ok(asked > 0);
|
||||
});
|
||||
|
||||
test('a minute after an idle spell spends no more than a minute of budget plus the burst', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, { at: CHICAGO, ground: floors.ground });
|
||||
await app.enable();
|
||||
// Idle long enough for any bucket to fill to the brim.
|
||||
t.mock.timers.tick(10 * 60_000);
|
||||
const asked = crowd(t, app, floors, 60);
|
||||
assert.ok(
|
||||
asked <= FLOOR_CELLS_PER_MINUTE + FLOOR_CELLS_BURST,
|
||||
`in a minute ${asked}`,
|
||||
);
|
||||
// About 24 of the 90 terrain requests a minute, not 38.
|
||||
assert.ok(Math.ceil(asked / 64) <= 24, `${Math.ceil(asked / 64)} requests`);
|
||||
// And the refill is spent: the crowd is not starved.
|
||||
assert.ok(asked >= FLOOR_CELLS_PER_MINUTE, `in a minute ${asked}`);
|
||||
});
|
||||
|
||||
/** A trip through Boston, its terminus 120 km away (Portland, Maine). */
|
||||
const STOPS = [
|
||||
{ stopId: 'A', name: 'South Station', lat: 42.3523, lon: -71.0552 },
|
||||
{ stopId: 'B', name: 'North Station', lat: 42.3656, lon: -71.0613 },
|
||||
{ stopId: 'C', name: 'Woburn', lat: 42.5168, lon: -71.1442 },
|
||||
{ stopId: 'D', name: 'Portland', lat: 43.6532, lon: -70.3122 },
|
||||
].map((stop, i) => ({
|
||||
...stop,
|
||||
state: i === 0 ? 'passed' : i === 1 ? 'next' : 'upcoming',
|
||||
relative: i === 0 ? 'T+3' : `T−${i * 10}`,
|
||||
expectedArrival: '14:32',
|
||||
}));
|
||||
|
||||
const heightOf = (position) =>
|
||||
Cesium.Cartographic.fromCartesian(position).height;
|
||||
|
||||
test('stops placed before their cells were warm move up onto the floor', async (t) => {
|
||||
const floors = floorService();
|
||||
const app = harness(t, {
|
||||
at: BOSTON,
|
||||
altitude: LOW_M,
|
||||
ground: floors.ground,
|
||||
stops: { stopsFor: () => ({ stops: STOPS }) },
|
||||
});
|
||||
app.feeds.set('mbta-rail', () => [train('T1', BOSTON.lat, BOSTON.lon)]);
|
||||
await app.enable();
|
||||
const key = railTrainKey('mbta-rail', 'T1');
|
||||
app.parts().selection.selectTrain(key);
|
||||
const dots = app.primitives.find(
|
||||
(primitive) => primitive instanceof Cesium.PointPrimitiveCollection,
|
||||
);
|
||||
const dotHeights = () =>
|
||||
Array.from({ length: dots.length }, (_, i) =>
|
||||
heightOf(dots.get(i).position),
|
||||
);
|
||||
const labelHeights = () =>
|
||||
app.overlayEntries
|
||||
.get(RAIL_STOP_OVERLAY_SOURCE_ID)
|
||||
.map((entry) => heightOf(entry.position));
|
||||
assert.equal(dots.length, STOPS.length);
|
||||
// Every stop's cell is cold: on the ellipsoid for now.
|
||||
for (const h of [...dotHeights(), ...labelHeights()])
|
||||
assert.ok(Math.abs(h) < 0.01, `on the ellipsoid (${h})`);
|
||||
// Their cells are warmed, the terminus too though it is 120 km off.
|
||||
t.mock.timers.tick(COLD_CELL_FLUSH_MS);
|
||||
for (const stop of STOPS)
|
||||
assert.ok(floors.requested.includes(cellKey(stop)), stop.name);
|
||||
// Once warm, the next refresh stands them on the floor.
|
||||
for (const stop of STOPS) floors.warm.set(cellKey(stop), 20);
|
||||
t.mock.timers.tick(STOP_REFRESH_MS);
|
||||
assert.equal(dots.length, STOPS.length);
|
||||
for (const h of [...dotHeights(), ...labelHeights()])
|
||||
assert.ok(Math.abs(h - 21.5) < 0.01, `on the floor (${h})`);
|
||||
// Placed on a real floor, a stop is not read again.
|
||||
floors.warm.clear();
|
||||
t.mock.timers.tick(STOP_REFRESH_MS * 3);
|
||||
for (const h of dotHeights()) assert.ok(Math.abs(h - 21.5) < 0.01);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createHistoryBudget } from '../../data/contactPlayback.js';
|
||||
import { createRailSource } from './source.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createFollow } from './follow.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createRouteDisplay } from './routeDisplay.js';
|
||||
import { createRailSchedules } from './schedules.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import { DEFAULT_RAIL_CATEGORY_VISIBILITY, RAIL_POLL_MS } from './policy.js';
|
||||
|
||||
/** One Rail layer's mutable scene state; nothing is module-global. */
|
||||
function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
services.overlays;
|
||||
return {
|
||||
_overlayHost: Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
}),
|
||||
_historyBudget: createHistoryBudget(),
|
||||
_viewer: null,
|
||||
_markers: null,
|
||||
_enabled: false,
|
||||
_generation: 0,
|
||||
_cameraChangedAttached: false,
|
||||
_cameraDebounceTimer: null,
|
||||
_altitudeGateOpen: false,
|
||||
_preRenderRemove: null,
|
||||
_renderHeld: false,
|
||||
_clickHandler: null,
|
||||
/** Trains mid-glide; the frame pass walks only these. @type {Set<object>} */
|
||||
_moving: new Set(),
|
||||
/** feedId → registry entry currently polled. */
|
||||
_activeFeeds: new Map(),
|
||||
_feedStatus: new Map(),
|
||||
_inFlight: new Map(),
|
||||
/** train key → runtime entry. */
|
||||
_trains: new Map(),
|
||||
/**
|
||||
* feedId → path pack slot `{status, pack, retryAt, failures}`. Kept
|
||||
* across disable/enable, and released with the feed's network and
|
||||
* schedule pack once the feed is out of use FEED_RELEASE_AFTER_MS
|
||||
* (routeDisplay.js releaseIdleFeeds).
|
||||
*/
|
||||
_pathPacks: new Map(),
|
||||
/**
|
||||
* The server's feed catalog (GET /api/rail/feeds), which lists a feed
|
||||
* that reads its routes from a schedule pack only once that pack is
|
||||
* built; null until it loads. Until then only the bundled feeds that
|
||||
* need no pack are used, and a catalog that fails is asked for again
|
||||
* (viewport.js).
|
||||
*/
|
||||
_catalog: null,
|
||||
/** The catalog request in flight: `{controller}`. */
|
||||
_catalogRequest: null,
|
||||
/** Failed catalog requests in a row, and the timer of the next try. */
|
||||
_catalogFailures: 0,
|
||||
_catalogRetryTimer: null,
|
||||
/** feedId → faint route network built from that feed's path pack. */
|
||||
_networks: new Map(),
|
||||
/** Network opacity from camera altitude, 0..1. */
|
||||
_networkFade: 0,
|
||||
/** The selected train's line, split where the train is. */
|
||||
_route: null,
|
||||
/** The selected trip's stops and headsign from the stops provider. */
|
||||
_trip: null,
|
||||
_stopsSignature: '',
|
||||
_stopLabelsSignature: '',
|
||||
/**
|
||||
* Stop → `{lat, lon, h, q, position}` for the selected trip, kept per
|
||||
* selection; one on a fallback height (q 0-1) is read again each refresh.
|
||||
*/
|
||||
_stopAnchors: new Map(),
|
||||
_categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY },
|
||||
_selectedKey: null,
|
||||
_selectedCardAt: 0,
|
||||
_selectedCardText: null,
|
||||
/** The camera's follow of the selected train (follow.js), or null. */
|
||||
_follow: null,
|
||||
/** A feed polled only for the selected train (viewport.js), or null. */
|
||||
_heldFeedId: null,
|
||||
_revision: 0,
|
||||
_controlsListener: null,
|
||||
_dataManager: null,
|
||||
_lastUpdate: null,
|
||||
_error: null,
|
||||
/** Whether the train cap (MAX_TRAINS_TOTAL) was reported once. */
|
||||
_capWarned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct one Rail layer with its own scene state and supplied services.
|
||||
*
|
||||
* Trains come from the `/api/rail` proxy (src/sources/railService.js), already
|
||||
* filtered to trains and tagged with a category. Every train drawn is where
|
||||
* its operator last reported it (GPS or station reports); none is placed or
|
||||
* moved from a timetable. Motion between reports is the Transit layer's
|
||||
* delayed playback, so a train glides at 1x behind real time along its line
|
||||
* instead of jumping between reports.
|
||||
*
|
||||
* Each layer owns a schedule tier (schedules.js): feeds' optional schedule
|
||||
* packs and trip updates, read through the same source for the selected
|
||||
* train's stops, headsign and delays. It is the default stops provider:
|
||||
*
|
||||
* stops.stopsFor({feedId, tripId, routeId, record}, nowMs)
|
||||
* → {stops: RailTripStop[], headsign?: string} | null
|
||||
* stops.onSelect?(train | null)
|
||||
*
|
||||
* `stopsFor` is called about once a second while a train is selected,
|
||||
* synchronously; it returns null while it has nothing (yet) and loads what it
|
||||
* needs on its own. RailTripStop is described in stopLabels.js. Pass
|
||||
* `stops: null` to draw the selected line without stops.
|
||||
*
|
||||
* A selected train takes the camera through the optional `services.camera`
|
||||
* (follow.js): a moving train is followed, any other flown to once.
|
||||
*
|
||||
* @param {{services: object, source?: object,
|
||||
* stops?: {stopsFor: Function, onSelect?: Function}|null}} options
|
||||
* @returns {object} The data-layer module the manager registers.
|
||||
*/
|
||||
export function createRailLayer({
|
||||
services,
|
||||
source = createRailSource(),
|
||||
stops,
|
||||
}) {
|
||||
if (
|
||||
!services?.overlays ||
|
||||
!services?.render ||
|
||||
!services?.sprites ||
|
||||
!services?.ground
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A rail layer needs overlay, render, sprite and ground services',
|
||||
);
|
||||
}
|
||||
if (typeof source?.requestSnapshot !== 'function')
|
||||
throw new TypeError('A rail source needs a snapshot operation');
|
||||
if (
|
||||
stops !== undefined &&
|
||||
stops !== null &&
|
||||
typeof stops?.stopsFor !== 'function'
|
||||
)
|
||||
throw new TypeError('A rail stops provider needs a stopsFor operation');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const timetable = createRailSchedules({
|
||||
source,
|
||||
// The server's catalog says which feeds it can read trip updates for.
|
||||
hasTripUpdates: (feedId) => {
|
||||
const listed = state._catalog?.find((feed) => feed.id === feedId);
|
||||
if (listed) return listed.tripUpdates === true;
|
||||
return typeof getRegisteredRailFeed(feedId)?.tripUpdatesUrl === 'string';
|
||||
},
|
||||
pathPackFor: (feedId) => state._pathPacks.get(feedId)?.pack || null,
|
||||
});
|
||||
const context = {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
timetable,
|
||||
stops: stops === undefined ? timetable : stops,
|
||||
};
|
||||
parts.rendering = createRendering(context);
|
||||
parts.routes = createRouteDisplay(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.follow = createFollow(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
|
||||
return Object.assign(
|
||||
{ updateInterval: RAIL_POLL_MS },
|
||||
parts.lifecycle.methods,
|
||||
parts.controls.methods,
|
||||
{
|
||||
/** Test seams: this instance's own state and parts. */
|
||||
_railStateForTest: () => state,
|
||||
_railPartsForTest: () => parts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
RAIL_CATEGORY_COLORS,
|
||||
RAIL_POLL_MS,
|
||||
buildRailSelectionCopy,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
@@ -0,0 +1,527 @@
|
||||
import {
|
||||
FIX_FLAGS,
|
||||
createLagReservoir,
|
||||
destroyTrack,
|
||||
readFix,
|
||||
} from '../../data/contactPlayback.js';
|
||||
import { createRailPathPack } from '../../data/railPaths.js';
|
||||
import {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
syncPlayback,
|
||||
updatePlayback,
|
||||
} from '../transit/movement.js';
|
||||
import {
|
||||
FEED_EVICT_AFTER_MS,
|
||||
FEED_STALE_AFTER_MS,
|
||||
FUTURE_STAMP_TOLERANCE_MS,
|
||||
MAX_TRAINS_TOTAL,
|
||||
MISSED_POLLS_TO_DROP,
|
||||
isLiveRailPositionSource,
|
||||
isStaleTrainFix,
|
||||
playbackModeFor,
|
||||
railTrainKey,
|
||||
trainReportTimeMs,
|
||||
} from './policy.js';
|
||||
import { bindTrainPath, followFix, rebindTrainPath } from './pathBinding.js';
|
||||
import { scheduleRetryDelayMs } from './schedules.js';
|
||||
import { createDuplicateFilter } from './dedupe.js';
|
||||
|
||||
/**
|
||||
* Fetching rail feeds through the proxy and turning snapshots into trains.
|
||||
* The aging rules are the Transit layer's: a train is drawn from its own
|
||||
* report time, a feed is judged by when its operator last answered, and both
|
||||
* a quiet train and a quiet feed end in removal rather than a frozen marker.
|
||||
*
|
||||
* Only live positions are drawn (isLiveRailPositionSource). A feed whose
|
||||
* positions fail is shown as unavailable: its trains are never placed from
|
||||
* its timetable, and those already drawn age out as any quiet train does.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createIngestion({ state, services, parts, source }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const { registerDynamicCredit, railFeedCredit } = services.credits;
|
||||
// One physical train in several loaded feeds is drawn once (dedupe.js).
|
||||
const duplicates = createDuplicateFilter({ state, parts });
|
||||
|
||||
function feedStatus(feedId) {
|
||||
let status = state._feedStatus.get(feedId);
|
||||
if (!status) {
|
||||
status = {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
upstreamAt: null,
|
||||
error: null,
|
||||
retryInSec: null,
|
||||
stale: false,
|
||||
pollSeq: 0,
|
||||
loading: false,
|
||||
lag: null,
|
||||
/** New trains its last poll listed that MAX_TRAINS_TOTAL left out. */
|
||||
capped: 0,
|
||||
};
|
||||
state._feedStatus.set(feedId, status);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function removeTrain(key) {
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry) return;
|
||||
if (state._selectedKey === key) parts.selection.clearSelection();
|
||||
if (entry.marker) state._markers?.remove(entry.marker);
|
||||
entry.marker = null;
|
||||
state._moving.delete(entry);
|
||||
parts.rendering.cancelWake(entry);
|
||||
destroyTrack(entry.track);
|
||||
state._trains.delete(key);
|
||||
state._revision += 1;
|
||||
// A copy it hid may now be the one to draw.
|
||||
duplicates.scheduleRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feed's trains, except `keepKey` when given (the selected train,
|
||||
* whose feed is held for it alone: viewport.js).
|
||||
* @param {string} feedId
|
||||
* @param {string|null} [keepKey]
|
||||
*/
|
||||
function removeFeedTrains(feedId, keepKey = null) {
|
||||
for (const [key, entry] of state._trains) {
|
||||
if (entry.feedId === feedId && key !== keepKey) removeTrain(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop trains whose report aged out and feeds whose operator went quiet.
|
||||
* @param {number} nowMs
|
||||
* @returns {number} Trains removed.
|
||||
*/
|
||||
function sweepAgedTrains(nowMs) {
|
||||
let removed = 0;
|
||||
const deadFeeds = new Set();
|
||||
for (const [feedId, status] of state._feedStatus) {
|
||||
if (
|
||||
Number.isFinite(status.upstreamAt) &&
|
||||
nowMs - status.upstreamAt > FEED_EVICT_AFTER_MS
|
||||
)
|
||||
deadFeeds.add(feedId);
|
||||
}
|
||||
for (const [key, entry] of state._trains) {
|
||||
if (
|
||||
deadFeeds.has(entry.feedId) ||
|
||||
isStaleTrainFix(entry.record, nowMs, entry.fetchedAt)
|
||||
) {
|
||||
removeTrain(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
for (const feedId of deadFeeds) {
|
||||
const status = state._feedStatus.get(feedId);
|
||||
if (status) {
|
||||
status.count = 0;
|
||||
status.stale = true;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('rail-evict');
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a feed's path pack once. A 404 means the feed has no paths and its
|
||||
* trains keep straight-line motion; any other failure (a 503 from a pack
|
||||
* the server cannot read now, say) is retried on the schedule packs'
|
||||
* stepped delay, 30 s doubling to ten minutes (scheduleRetryDelayMs).
|
||||
* @param {object} feed Registry entry.
|
||||
*/
|
||||
function ensurePaths(feed) {
|
||||
const known = state._pathPacks.get(feed.id);
|
||||
if (known && !(known.status === 'none' && Date.now() >= known.retryAt))
|
||||
return;
|
||||
if (typeof source.requestPaths !== 'function') {
|
||||
state._pathPacks.set(feed.id, {
|
||||
status: 'none',
|
||||
pack: null,
|
||||
retryAt: Infinity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const slot = {
|
||||
status: 'loading',
|
||||
pack: null,
|
||||
retryAt: Infinity,
|
||||
/** Failed loads in a row. */
|
||||
failures: known?.failures ?? 0,
|
||||
};
|
||||
state._pathPacks.set(feed.id, slot);
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await source.requestPaths(feed.id);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`rail paths HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const raw = await response.json();
|
||||
const pack = createRailPathPack(raw);
|
||||
if (pack.feedId !== feed.id)
|
||||
throw new Error('rail path pack is for another feed');
|
||||
if (state._pathPacks.get(feed.id) !== slot) return;
|
||||
slot.pack = pack;
|
||||
slot.status = 'ready';
|
||||
bindFeedTrains(feed.id);
|
||||
// The faint network is drawn from the same pack, built once here.
|
||||
parts.routes.buildNetwork(feed.id, raw);
|
||||
} catch (error) {
|
||||
slot.status = 'none';
|
||||
slot.failures += 1;
|
||||
slot.retryAt =
|
||||
error?.status === 404
|
||||
? Infinity
|
||||
: Date.now() + scheduleRetryDelayMs(slot.failures);
|
||||
if (error?.status !== 404)
|
||||
console.warn(
|
||||
`[Data:Rail] ${feed.id} paths unavailable:`,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a train on its railway line: bind it when its trip or route changes,
|
||||
* follow each new report, and move it to another of its route's shapes (or
|
||||
* back to straight lines) when a report lands off the bound one.
|
||||
*/
|
||||
function followPath(entry, record, fix) {
|
||||
const pack = state._pathPacks.get(entry.feedId)?.pack;
|
||||
if (!pack) return;
|
||||
const ids = { tripId: record.tripId || '', routeId: record.routeId || '' };
|
||||
const key = `${ids.tripId}\n${ids.routeId}`;
|
||||
if (entry.pathKey !== key) {
|
||||
entry.pathKey = key;
|
||||
entry.path = bindTrainPath(pack, ids, fix);
|
||||
return;
|
||||
}
|
||||
if (!entry.path || followFix(entry.path, fix)) return;
|
||||
entry.path = rebindTrainPath(pack, ids, entry.path, fix);
|
||||
}
|
||||
|
||||
/** Bind the trains a feed already shows once its pack arrives. */
|
||||
function bindFeedTrains(feedId) {
|
||||
const newest = {};
|
||||
for (const entry of state._trains.values()) {
|
||||
if (entry.feedId !== feedId || entry.pathKey !== undefined) continue;
|
||||
if (!entry.track?.count) continue;
|
||||
readFix(entry.track, entry.track.count - 1, newest);
|
||||
followPath(entry, entry.record, newest);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
governorRequestRender('rail-paths');
|
||||
}
|
||||
|
||||
/** A playback fix for one record, with the time it was really reported. */
|
||||
function fixFor(record, fetchedAt) {
|
||||
const stamped = trainReportTimeMs(record, fetchedAt);
|
||||
const reportAt =
|
||||
stamped > fetchedAt + FUTURE_STAMP_TOLERANCE_MS ? fetchedAt : stamped;
|
||||
return {
|
||||
t: reportAt,
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
bearingDeg: Number.isFinite(record.bearing) ? record.bearing : NaN,
|
||||
flags:
|
||||
stamped !== reportAt
|
||||
? FIX_FLAGS.RECEIPT_TIME
|
||||
: record.timestampSource === 'vehicle'
|
||||
? FIX_FLAGS.VEHICLE_TIME
|
||||
: record.timestampSource === 'header'
|
||||
? FIX_FLAGS.FEED_TIME
|
||||
: FIX_FLAGS.RECEIPT_TIME,
|
||||
};
|
||||
}
|
||||
|
||||
/** Record a new report for a train already drawn. */
|
||||
function advanceTrain(entry, record, fix, observation) {
|
||||
const heard = recordFix(entry, fix, observation);
|
||||
if (!heard.accepted) return heard;
|
||||
followPath(entry, record, fix);
|
||||
if (entry.category !== record.category || entry.railMode !== record.mode) {
|
||||
entry.category = record.category;
|
||||
entry.railMode = record.mode;
|
||||
entry.mode = playbackModeFor(record.mode);
|
||||
parts.rendering.paint(entry);
|
||||
}
|
||||
updatePlayback(entry, observation.wallNowMs, observation.monoNowMs);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
return heard;
|
||||
}
|
||||
|
||||
/** Create and draw a train the layer has not seen before. */
|
||||
function addTrain(key, feed, record, fix, observation, status) {
|
||||
const entry = {
|
||||
key,
|
||||
feedId: feed.id,
|
||||
marker: null,
|
||||
category: record.category,
|
||||
railMode: record.mode,
|
||||
mode: playbackModeFor(record.mode),
|
||||
// A route the classifier named is a fact about the train, so its
|
||||
// speed ceiling applies; the movement module reads this flag.
|
||||
modeInferred: false,
|
||||
courseDeg: Number.isFinite(record.bearing) ? record.bearing : null,
|
||||
courseAt: observation.wallNowMs,
|
||||
courseEvalAt: observation.monoNowMs,
|
||||
record,
|
||||
fetchedAt: observation.fetchedAt,
|
||||
pollSeq: status.pollSeq,
|
||||
/** Binding to a railway line (pathBinding.js), null for straight lines. */
|
||||
path: null,
|
||||
/** Trip and route the binding was made for; undefined until tried. */
|
||||
pathKey: undefined,
|
||||
};
|
||||
initializePlayback(entry, state._historyBudget, status.lag);
|
||||
const first = recordFix(entry, fix, observation);
|
||||
if (!first.accepted) {
|
||||
destroyTrack(entry.track);
|
||||
return null;
|
||||
}
|
||||
followPath(entry, record, fix);
|
||||
syncPlayback(entry, observation.wallNowMs, observation.monoNowMs);
|
||||
entry.marker = parts.rendering.addMarker(entry);
|
||||
state._trains.set(key, entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function applySnapshot(feed, snapshot, { stale, contactedAt = null }) {
|
||||
const now = Date.now();
|
||||
const fetchedAt = Math.min(
|
||||
Number.isFinite(snapshot?.fetchedAt) ? snapshot.fetchedAt : now,
|
||||
now,
|
||||
);
|
||||
const contacted = Math.min(
|
||||
Number.isFinite(contactedAt) ? contactedAt : fetchedAt,
|
||||
now,
|
||||
);
|
||||
const status = feedStatus(feed.id);
|
||||
status.upstreamAt = Math.max(contacted, fetchedAt);
|
||||
const upstreamAgeMs = now - status.upstreamAt;
|
||||
status.loading = false;
|
||||
status.error = null;
|
||||
status.retryInSec = null;
|
||||
status.stale = stale === true || upstreamAgeMs > FEED_STALE_AFTER_MS;
|
||||
if (upstreamAgeMs > FEED_EVICT_AFTER_MS) {
|
||||
removeFeedTrains(feed.id);
|
||||
status.count = 0;
|
||||
parts.rendering.syncRenderHold();
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
return;
|
||||
}
|
||||
status.pollSeq += 1;
|
||||
if (!status.lag) status.lag = createLagReservoir();
|
||||
const replayed = status.snapshotAt === fetchedAt || stale === true;
|
||||
status.snapshotAt = fetchedAt;
|
||||
const monoNow = performance.now();
|
||||
// A feed held only for the selected train (viewport.js) carries it alone,
|
||||
// or a train its operator now lists on the same trip.
|
||||
const only =
|
||||
state._heldFeedId === feed.id
|
||||
? state._trains.get(state._selectedKey)
|
||||
: null;
|
||||
let seen = 0;
|
||||
// The cap counts the trains drawn: a copy another feed draws better
|
||||
// (dedupe.js) is hidden and does not count.
|
||||
let held = 0;
|
||||
for (const entry of state._trains.values())
|
||||
if (entry.duplicate !== true) held += 1;
|
||||
let capped = 0;
|
||||
for (const record of snapshot.trains || []) {
|
||||
// Only a reported position is drawn, never one from a timetable.
|
||||
if (!isLiveRailPositionSource(record?.positionSource)) continue;
|
||||
if (isStaleTrainFix(record, now, fetchedAt)) continue;
|
||||
const key = railTrainKey(feed.id, record.id);
|
||||
if (
|
||||
only &&
|
||||
key !== only.key &&
|
||||
!(only.record?.tripId && record.tripId === only.record.tripId)
|
||||
)
|
||||
continue;
|
||||
const fix = fixFor(record, fetchedAt);
|
||||
const observation = {
|
||||
receivedAt: now,
|
||||
monoNowMs: monoNow,
|
||||
wallNowMs: now,
|
||||
fetchedAt,
|
||||
replayed,
|
||||
modeInferred: false,
|
||||
context: {
|
||||
trip: record.tripId || '',
|
||||
route: record.routeId || '',
|
||||
mode: playbackModeFor(record.mode),
|
||||
},
|
||||
};
|
||||
let entry = state._trains.get(key);
|
||||
if (entry) {
|
||||
const heard = advanceTrain(entry, record, fix, observation);
|
||||
entry.pollSeq = status.pollSeq;
|
||||
seen += 1;
|
||||
if (!heard.accepted) continue;
|
||||
} else {
|
||||
if (held >= MAX_TRAINS_TOTAL) {
|
||||
capped += 1;
|
||||
continue;
|
||||
}
|
||||
entry = addTrain(key, feed, record, fix, observation, status);
|
||||
if (!entry) continue;
|
||||
held += 1;
|
||||
seen += 1;
|
||||
}
|
||||
entry.record = record;
|
||||
entry.fetchedAt = fetchedAt;
|
||||
}
|
||||
// Said in the layer's stats as a partial snapshot (controls.js).
|
||||
status.capped = capped;
|
||||
if (capped > 0 && !state._capWarned) {
|
||||
state._capWarned = true;
|
||||
console.warn(
|
||||
`[Data:Rail] train cap ${MAX_TRAINS_TOTAL} reached; ${capped} trains of ${feed.id} are not drawn`,
|
||||
);
|
||||
}
|
||||
for (const [key, entry] of state._trains) {
|
||||
if (
|
||||
entry.feedId === feed.id &&
|
||||
status.pollSeq - entry.pollSeq >= MISSED_POLLS_TO_DROP
|
||||
)
|
||||
removeTrain(key);
|
||||
}
|
||||
sweepAgedTrains(now);
|
||||
duplicates.scheduleRefresh();
|
||||
status.count = seen;
|
||||
status.lastUpdate = fetchedAt;
|
||||
state._lastUpdate = Math.max(state._lastUpdate || 0, fetchedAt);
|
||||
state._error = null;
|
||||
if (state._viewer)
|
||||
registerDynamicCredit(state._viewer, railFeedCredit(feed));
|
||||
if (state._trains.get(state._selectedKey)?.feedId === feed.id)
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
parts.rendering.warmFloors(feed.id);
|
||||
state._revision += 1;
|
||||
parts.rendering.syncRenderHold();
|
||||
governorRequestRender('rail-poll');
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
parts.controls.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* A feed whose live positions failed (or that its server has none for):
|
||||
* its row says it is unavailable. Nothing takes the place of its trains;
|
||||
* those already drawn go as their reports age (sweepAgedTrains).
|
||||
* @param {object} feed
|
||||
* @param {object} status
|
||||
* @param {{retryInSec?: number}|null} [error]
|
||||
*/
|
||||
function markUnavailable(feed, status, error = null) {
|
||||
// i18n-ignore: English data, translated on display (ui/statusText.js)
|
||||
status.error = `${feed.name} feed unavailable`;
|
||||
status.retryInSec = Number.isFinite(error?.retryInSec)
|
||||
? error.retryInSec
|
||||
: null;
|
||||
status.loading = false;
|
||||
state._error = status.error;
|
||||
sweepAgedTrains(Date.now());
|
||||
state._dataManager?.refreshLayerStats?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one feed. A request already in flight is reused rather than aborted,
|
||||
* so enable() and the manager's first update() share one request.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {number} generation Enable generation the poll belongs to.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function pollFeed(feed, generation) {
|
||||
if (!state._enabled || generation !== state._generation)
|
||||
return Promise.resolve();
|
||||
ensurePaths(feed);
|
||||
const status = feedStatus(feed.id);
|
||||
const existing = state._inFlight.get(feed.id);
|
||||
if (existing) return existing.promise;
|
||||
const controller = new AbortController();
|
||||
status.loading = status.count === 0;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await source.requestSnapshot(feed.id, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
const error = new Error(`rail proxy HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
if (Number.isFinite(detail?.retryInSec))
|
||||
error.retryInSec = detail.retryInSec;
|
||||
throw error;
|
||||
}
|
||||
const snapshot = await response.json();
|
||||
if (
|
||||
!state._enabled ||
|
||||
generation !== state._generation ||
|
||||
!state._activeFeeds.has(feed.id)
|
||||
)
|
||||
return;
|
||||
// An answer whose positions are not live (anything but GPS or
|
||||
// station reports) has no live data for this feed: none of its
|
||||
// trains are drawn, and the feed shows unavailable.
|
||||
if (!isLiveRailPositionSource(snapshot?.positions)) {
|
||||
console.warn(
|
||||
`[Data:Rail] ${feed.id} has no live positions on this server`,
|
||||
);
|
||||
markUnavailable(feed, status);
|
||||
return;
|
||||
}
|
||||
const contact = Number.parseInt(
|
||||
response.headers.get('x-rail-contact') || '',
|
||||
10,
|
||||
);
|
||||
applySnapshot(feed, snapshot, {
|
||||
stale: response.headers.get('x-gev-cache') === 'STALE-ERROR',
|
||||
contactedAt: Number.isFinite(contact) ? contact : null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (generation !== state._generation) return;
|
||||
console.warn(
|
||||
`[Data:Rail] ${feed.id} poll failed:`,
|
||||
error?.message || error,
|
||||
);
|
||||
markUnavailable(feed, status, error);
|
||||
} finally {
|
||||
if (state._inFlight.get(feed.id)?.controller === controller)
|
||||
state._inFlight.delete(feed.id);
|
||||
}
|
||||
})();
|
||||
state._inFlight.set(feed.id, { controller, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
function abortAllInFlight() {
|
||||
for (const { controller } of state._inFlight.values()) controller.abort();
|
||||
state._inFlight.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
feedStatus,
|
||||
removeTrain,
|
||||
removeFeedTrains,
|
||||
sweepAgedTrains,
|
||||
applySnapshot,
|
||||
pollFeed,
|
||||
abortAllInFlight,
|
||||
duplicates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// What the layer row and stats count: trains drawn, a train two feeds carry
|
||||
// once, and a partial snapshot when the train cap leaves trains out.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTrack } from '../../data/contactPlayback.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
import { MAX_TRAINS_TOTAL, railTrainKey } from './policy.js';
|
||||
import {
|
||||
BOSTON,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
|
||||
const near = (i) => ({
|
||||
lat: BOSTON.lat + 0.001 * i,
|
||||
lon: BOSTON.lon + 0.001 * i,
|
||||
});
|
||||
|
||||
test('a train another feed draws is not counted twice', async (t) => {
|
||||
const app = harness(t, { at: BOSTON });
|
||||
app.feeds.set('mbta-rail', () =>
|
||||
[1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon)),
|
||||
);
|
||||
await app.enable();
|
||||
assert.equal(app.layer.getStats().count, 3);
|
||||
// The duplicate filter hides T3 as another feed's copy of a train.
|
||||
app.entry(railTrainKey('mbta-rail', 'T3')).duplicate = true;
|
||||
const stats = app.layer.getStats();
|
||||
assert.equal(stats.count, 2);
|
||||
assert.match(stats.coverage, /MBTA 2\b/);
|
||||
const legend = app.layer.getRowControls().legend;
|
||||
assert.deepEqual(
|
||||
legend.map((item) => item.count),
|
||||
[2],
|
||||
);
|
||||
});
|
||||
|
||||
/** Trains of another feed held by the layer, outside the view. */
|
||||
function fill(app, count, { duplicate }) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const key = railTrainKey('elsewhere', `F${i}`);
|
||||
app.state()._trains.set(key, {
|
||||
key,
|
||||
feedId: 'elsewhere',
|
||||
category: 'regional',
|
||||
duplicate,
|
||||
marker: null,
|
||||
track: createTrack(),
|
||||
fetchedAt: Date.now(),
|
||||
record: {
|
||||
id: `F${i}`,
|
||||
lat: -40 + (i % 80),
|
||||
lon: 100 + Math.floor(i / 80),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('copies hidden as duplicates do not count toward the train cap', async (t) => {
|
||||
const app = harness(t, { at: BOSTON });
|
||||
let trains = [];
|
||||
app.feeds.set('mbta-rail', () => trains);
|
||||
await app.enable();
|
||||
fill(app, MAX_TRAINS_TOTAL - 1, { duplicate: true });
|
||||
trains = [1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon));
|
||||
await app.poll();
|
||||
for (const i of [1, 2, 3])
|
||||
assert.ok(app.entry(railTrainKey('mbta-rail', `T${i}`)), `T${i}`);
|
||||
assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0);
|
||||
assert.notEqual(app.layer.getStats().partial, true);
|
||||
});
|
||||
|
||||
test('trains the cap leaves out make the snapshot partial', async (t) => {
|
||||
const app = harness(t, { at: BOSTON });
|
||||
let trains = [];
|
||||
app.feeds.set('mbta-rail', () => trains);
|
||||
await app.enable();
|
||||
const warn = t.mock.method(console, 'warn', () => {});
|
||||
fill(app, MAX_TRAINS_TOTAL - 2, { duplicate: false });
|
||||
trains = [1, 2, 3, 4, 5].map((i) => train(`T${i}`, near(i).lat, near(i).lon));
|
||||
await app.poll();
|
||||
const kept = [1, 2, 3, 4, 5].filter((i) =>
|
||||
app.entry(railTrainKey('mbta-rail', `T${i}`)),
|
||||
);
|
||||
assert.equal(kept.length, 2);
|
||||
assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 3);
|
||||
const stats = app.layer.getStats();
|
||||
assert.equal(stats.partial, true);
|
||||
assert.equal(stats.rawRowCount - stats.acceptedRowCount, 3);
|
||||
// The panel row reads it as PARTIAL (a feed in range that the harness
|
||||
// does not answer would read as degraded first).
|
||||
assert.equal(layerFeedState({ ...stats, degraded: false }), 'partial');
|
||||
assert.equal(warn.mock.callCount(), 1);
|
||||
// Room again: the next poll draws them all, and the note goes.
|
||||
for (const [key, entry] of app.state()._trains)
|
||||
if (entry.feedId === 'elsewhere') app.state()._trains.delete(key);
|
||||
await app.poll();
|
||||
assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0);
|
||||
assert.notEqual(app.layer.getStats().partial, true);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { destroyTrack } from '../../data/contactPlayback.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
import {
|
||||
CAMERA_PERCENTAGE_CHANGED,
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_ID,
|
||||
} from './policy.js';
|
||||
import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js';
|
||||
import {
|
||||
RAIL_ENABLED_FEEDS,
|
||||
railFeedNeedsSchedulePack,
|
||||
} from '../../data/railFeeds.js';
|
||||
|
||||
/**
|
||||
* Init / enable / disable / update / destroy for one Rail layer instance.
|
||||
* Everything acquired here is released here: the primitive collection, the
|
||||
* sprite registration, the pick owner, listeners, the render hold and the
|
||||
* camera sensitivity this layer borrowed.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLifecycle({ state, services, parts, timetable }) {
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
unregisterSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
} = services.sprites;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
|
||||
function dropAllTrains() {
|
||||
for (const entry of state._trains.values()) {
|
||||
parts.rendering.cancelWake(entry);
|
||||
destroyTrack(entry.track);
|
||||
}
|
||||
state._trains.clear();
|
||||
state._moving.clear();
|
||||
state._markers?.removeAll();
|
||||
}
|
||||
|
||||
const methods = {
|
||||
id: 'rail',
|
||||
name: 'Rail',
|
||||
icon: '🚆',
|
||||
source: 'GTFS-RT',
|
||||
|
||||
/** Create the marker collection. Called once at bootstrap. */
|
||||
init(viewer) {
|
||||
state._viewer = viewer;
|
||||
state._markers = new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
viewer.scene.primitives.add(state._markers);
|
||||
registerSpriteCollection('rail', state._markers);
|
||||
state._markers.show = false;
|
||||
state._enabled = false;
|
||||
state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false);
|
||||
restoreSpriteOrder(viewer);
|
||||
// A feed that needs its schedule pack runs only once the server's
|
||||
// catalog lists it (its pack is built); the catalog's own count is
|
||||
// logged when it arrives (viewport.js).
|
||||
const packless = RAIL_ENABLED_FEEDS.filter(
|
||||
(feed) => !railFeedNeedsSchedulePack(feed),
|
||||
).length;
|
||||
console.log(
|
||||
`[Data:Rail] Initialized: ${RAIL_ENABLED_FEEDS.length} train feeds registered, ${packless} need no schedule pack`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Show trains, watch the camera, and poll every feed in range. */
|
||||
enable(viewer) {
|
||||
state._enabled = true;
|
||||
state._generation += 1;
|
||||
state._error = null;
|
||||
state._markers.show = true;
|
||||
state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, true);
|
||||
parts.selection.installClickHandler(viewer);
|
||||
registerPickOwner('rail', (pickedId) => state._trains.has(pickedId));
|
||||
if (!state._cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
claimCameraSensitivity(
|
||||
viewer.camera,
|
||||
'rail',
|
||||
CAMERA_PERCENTAGE_CHANGED,
|
||||
);
|
||||
state._cameraChangedAttached = true;
|
||||
}
|
||||
if (!state._preRenderRemove)
|
||||
state._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||
parts.rendering.onPreRender,
|
||||
);
|
||||
state._networkFade = -1;
|
||||
parts.viewport.loadCatalog();
|
||||
parts.viewport.runProximityCheck();
|
||||
restoreSpriteOrder(viewer);
|
||||
},
|
||||
|
||||
/** Hide everything, stop polling, drop all trains. */
|
||||
disable(viewer) {
|
||||
state._enabled = false;
|
||||
state._generation += 1;
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
parts.selection.clearSelection();
|
||||
state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false);
|
||||
state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false);
|
||||
parts.selection.removeClickHandler();
|
||||
unregisterPickOwner('rail');
|
||||
if (state._cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(viewer.camera, 'rail');
|
||||
state._cameraChangedAttached = false;
|
||||
}
|
||||
state._preRenderRemove?.();
|
||||
state._preRenderRemove = null;
|
||||
parts.viewport.cancelCatalog();
|
||||
timetable.stop();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
state._heldFeedId = null;
|
||||
state._feedStatus.clear();
|
||||
dropAllTrains();
|
||||
// Floor cells and track samples queued for trains now gone.
|
||||
parts.rendering.reset();
|
||||
parts.routes.hideAll();
|
||||
if (state._markers) state._markers.show = false;
|
||||
state._altitudeGateOpen = false;
|
||||
if (state._renderHeld) {
|
||||
releaseContinuousRender('rail');
|
||||
state._renderHeld = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Manager tick (every RAIL_POLL_MS): sweep, then re-poll active feeds. */
|
||||
async update() {
|
||||
if (!state._enabled) return;
|
||||
parts.ingestion.sweepAgedTrains(Date.now());
|
||||
parts.routes.releaseIdleFeeds(Date.now());
|
||||
const generation = state._generation;
|
||||
await Promise.all(
|
||||
[...state._activeFeeds.values()].map((feed) =>
|
||||
parts.ingestion.pollFeed(feed, generation),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Tear down the collection and release every shared registration. */
|
||||
destroy(viewer) {
|
||||
this.disable(viewer);
|
||||
parts.routes.destroy();
|
||||
if (state._markers) {
|
||||
unregisterSpriteCollection('rail', state._markers);
|
||||
viewer?.scene?.primitives?.remove(state._markers);
|
||||
state._markers = null;
|
||||
}
|
||||
state._overlayHost.clearSource(RAIL_SELECTED_OVERLAY_SOURCE_ID);
|
||||
state._dataManager = null;
|
||||
state._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Only live trains are drawn. A live feed whose positions fail is shown as
|
||||
// unavailable: no train is placed or moved from its timetable, nothing is
|
||||
// asked for to place one, and the trains it drew go as their reports age.
|
||||
// A schedule pack is read only for a selected live train's stops. Feeds
|
||||
// that read their routes from a schedule pack wait for the server's
|
||||
// catalog, which is asked for again with a backoff when it fails.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CATALOG_RETRY_FIRST_MS,
|
||||
CATALOG_RETRY_MAX_MS,
|
||||
FEED_EVICT_AFTER_MS,
|
||||
RAIL_POLL_MS,
|
||||
railCatalogRetryDelayMs,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
import * as policy from './policy.js';
|
||||
import {
|
||||
getRegisteredRailFeed,
|
||||
publicRailCatalog,
|
||||
railFeedNeedsSchedulePack,
|
||||
} from '../../data/railFeeds.js';
|
||||
import {
|
||||
RAIL_HARNESS_T0 as T0,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
|
||||
const FEED = 'septa-regional-rail';
|
||||
const PHILADELPHIA = { lat: 40.0, lon: -75.25 };
|
||||
const MADRID = { lat: 40.42, lon: -3.7 };
|
||||
/** The server's catalog, narrowed to SEPTA so no other feed is polled. */
|
||||
const CATALOG = publicRailCatalog().filter((feed) => feed.id === FEED);
|
||||
const LAT = 40.0;
|
||||
const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180);
|
||||
const DAY = new Date(T0).toISOString().slice(0, 10);
|
||||
/** Seconds after midnight (UTC) at the tests' start. */
|
||||
const T0_SEC = (T0 - Date.parse(`${DAY}T00:00:00Z`)) / 1000;
|
||||
|
||||
/**
|
||||
* A SEPTA schedule pack of one Paoli/Thorndale run, T1, west to east across
|
||||
* three stops an hour long, that left its first stop ten minutes before the
|
||||
* tests start. It is served, and must never place a train.
|
||||
*/
|
||||
const SEPTA_PACK = Object.freeze({
|
||||
version: 1,
|
||||
feedId: FEED,
|
||||
timezone: 'UTC',
|
||||
window: { from: DAY, to: DAY },
|
||||
routes: { PAO: { type: 2 } },
|
||||
stops: [
|
||||
{ id: 'A', name: 'Paoli', lat: LAT, lon: -75.45 },
|
||||
{ id: 'B', name: 'Bryn Mawr', lat: LAT, lon: -75.35 },
|
||||
{ id: 'C', name: '30th Street', lat: LAT, lon: -75.25 },
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
shape: 0,
|
||||
stops: [0, 1, 2],
|
||||
seq: [1, 2, 3],
|
||||
dist: [0, STEP_M, 2 * STEP_M],
|
||||
},
|
||||
],
|
||||
profiles: [[0, 0, 1800, 1860, 3600, 3600]],
|
||||
services: { WK: [DAY] },
|
||||
trips: {
|
||||
T1: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: T0_SEC - 600,
|
||||
route: 'PAO',
|
||||
service: 'WK',
|
||||
headsign: '30th Street',
|
||||
name: '9512',
|
||||
},
|
||||
},
|
||||
});
|
||||
const LIVE_V1 = railTrainKey(FEED, 'V1');
|
||||
|
||||
/** SEPTA's live answer, switched by the test; its schedule pack is served. */
|
||||
function septa(app, answer = { status: 502 }) {
|
||||
const live = { answer };
|
||||
app.schedules.set(FEED, SEPTA_PACK);
|
||||
app.feeds.set(FEED, () =>
|
||||
typeof live.answer === 'function' ? live.answer() : live.answer,
|
||||
);
|
||||
return live;
|
||||
}
|
||||
|
||||
const feedStatus = (app) => app.state()._feedStatus.get(FEED);
|
||||
const feedKeys = (app) =>
|
||||
[...app.state()._trains.keys()].filter((key) =>
|
||||
key.startsWith(`rail:${FEED}:`),
|
||||
);
|
||||
const asked = (app) => app.requested.filter((id) => id === FEED).length;
|
||||
|
||||
/** Live V1 on trip T1, near where the timetable has the train. */
|
||||
function liveV1() {
|
||||
const minutes = (Date.now() - T0) / 60_000;
|
||||
return [
|
||||
train('V1', LAT, -75.4 + minutes * 0.002, {
|
||||
tripId: 'T1',
|
||||
routeId: 'PAO',
|
||||
category: 'local',
|
||||
label: '9512',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
test('the layer has no timetable fallback to switch to', () => {
|
||||
for (const gone of [
|
||||
'LIVE_FAILURES_TO_TIMETABLE',
|
||||
'LIVE_PROBE_MIN_MS',
|
||||
'LIVE_PROBE_MAX_MS',
|
||||
'railLiveFailureKind',
|
||||
'railLiveProbeDelayMs',
|
||||
])
|
||||
assert.equal(policy[gone], undefined, gone);
|
||||
});
|
||||
|
||||
test('a live feed whose operator fails shows unavailable, and no timetable is asked for', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG });
|
||||
septa(app);
|
||||
await app.enable();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const status = feedStatus(app);
|
||||
assert.match(status.error, /unavailable/, `poll ${i + 1}`);
|
||||
assert.equal(status.loading, false);
|
||||
assert.deepEqual(feedKeys(app), [], 'no train placed from the timetable');
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.deepEqual(app.requestedSchedules, [], 'no schedule pack asked for');
|
||||
// Live positions are asked for again every poll, as for any feed.
|
||||
assert.equal(asked(app), 5);
|
||||
assert.match(app.layer.getStats().error, /unavailable/);
|
||||
});
|
||||
|
||||
test('trains a failing feed drew are never moved on by its timetable; they age out', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const app = harness(t, {
|
||||
at: PHILADELPHIA,
|
||||
catalog: CATALOG,
|
||||
scheduleStops: true,
|
||||
});
|
||||
const live = septa(app, liveV1);
|
||||
await app.enable();
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.deepEqual(feedKeys(app), [LIVE_V1]);
|
||||
// A selected live train reads its stops from the schedule pack.
|
||||
app.parts().selection.selectTrain(LIVE_V1);
|
||||
await app.settle();
|
||||
assert.deepEqual(app.requestedSchedules, [FEED]);
|
||||
const lastReport = app.entry(LIVE_V1).record;
|
||||
|
||||
// The operator fails: the train keeps its last report, and only that.
|
||||
live.answer = { status: 502 };
|
||||
const failedAt = Date.now();
|
||||
while (Date.now() - failedAt <= FEED_EVICT_AFTER_MS) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
for (const key of feedKeys(app))
|
||||
assert.equal(key, LIVE_V1, 'no timetable run is drawn');
|
||||
if (app.entry(LIVE_V1))
|
||||
assert.equal(app.entry(LIVE_V1).record, lastReport, 'never moved on');
|
||||
}
|
||||
assert.deepEqual(feedKeys(app), [], 'gone as its report aged');
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.match(feedStatus(app).error, /unavailable/);
|
||||
assert.deepEqual(app.requestedSchedules, [FEED], 'asked for once, for stops');
|
||||
});
|
||||
|
||||
test('a server that answers with timetable positions has no live data: nothing drawn', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// SEPTA's trains route answers 200 with no live positions
|
||||
// (`positions: 'schedule'`, no trains).
|
||||
const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG });
|
||||
septa(app, { positions: 'schedule' });
|
||||
await app.enable();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.deepEqual(feedKeys(app), []);
|
||||
assert.match(feedStatus(app).error, /unavailable/);
|
||||
assert.deepEqual(app.requestedSchedules, []);
|
||||
// It is not taken for a timetable feed: live is asked for every poll.
|
||||
assert.equal(asked(app), 4);
|
||||
});
|
||||
|
||||
test('a train record placed from a timetable is never drawn, whatever the feed says', async (t) => {
|
||||
const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG });
|
||||
septa(app, () => [
|
||||
train('V1', LAT, -75.4, { positionSource: 'schedule' }),
|
||||
train('V2', LAT, -75.3, { positionSource: 'gps' }),
|
||||
train('V3', LAT, -75.35, { positionSource: 'event' }),
|
||||
train('V4', LAT, -75.32, { positionSource: undefined }),
|
||||
]);
|
||||
await app.enable();
|
||||
assert.deepEqual(feedKeys(app).sort(), [
|
||||
railTrainKey(FEED, 'V2'),
|
||||
railTrainKey(FEED, 'V3'),
|
||||
railTrainKey(FEED, 'V4'),
|
||||
]);
|
||||
assert.equal(feedStatus(app).error, null);
|
||||
});
|
||||
|
||||
test('a catalog feed without live positions is never polled', async (t) => {
|
||||
// A catalog entry whose positions are not live is left alone.
|
||||
const app = harness(t, {
|
||||
at: PHILADELPHIA,
|
||||
catalog: CATALOG.map((feed) => ({ ...feed, positions: 'schedule' })),
|
||||
});
|
||||
septa(app, liveV1);
|
||||
await app.enable();
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.ok(!app.state()._activeFeeds.has(FEED));
|
||||
assert.equal(asked(app), 1, 'only the first poll, before the catalog');
|
||||
assert.deepEqual(feedKeys(app), []);
|
||||
assert.deepEqual(app.requestedSchedules, []);
|
||||
});
|
||||
|
||||
test('a feed the catalog leaves out is never polled once it loads', async (t) => {
|
||||
// Here the catalog lists SEPTA's regional rail alone, so the other
|
||||
// bundled feed in range (the trolleys) is dropped as soon as it answers.
|
||||
const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG });
|
||||
septa(app, liveV1);
|
||||
await app.enable();
|
||||
assert.deepEqual([...app.state()._activeFeeds.keys()], [FEED]);
|
||||
const before = app.requested.filter((id) => id !== FEED).length;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.equal(app.requested.filter((id) => id !== FEED).length, before);
|
||||
assert.deepEqual(
|
||||
[...app.state()._feedStatus.keys()],
|
||||
[FEED],
|
||||
'no status, so no error, for a feed the catalog left out',
|
||||
);
|
||||
});
|
||||
|
||||
/** A catalog the test answers (or fails) when it chooses. */
|
||||
function pendingCatalog() {
|
||||
const pending = {};
|
||||
pending.promise = new Promise((resolve, reject) => {
|
||||
pending.resolve = resolve;
|
||||
pending.reject = reject;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
const LONG_DISTANCE = 'renfe-larga-distancia';
|
||||
const CERCANIAS = 'renfe-cercanias';
|
||||
|
||||
/** Madrid's two live feeds, both answering with no trains. */
|
||||
function liveFeedsAnswer(app) {
|
||||
app.feeds.set(LONG_DISTANCE, () => []);
|
||||
app.feeds.set(CERCANIAS, () => []);
|
||||
}
|
||||
|
||||
const needsPack = (id) => railFeedNeedsSchedulePack(getRegisteredRailFeed(id));
|
||||
|
||||
test('Renfe long distance reads its routes from a pack: it is held back until the catalog lists it', () => {
|
||||
assert.equal(needsPack(LONG_DISTANCE), true);
|
||||
assert.equal(needsPack(CERCANIAS), false);
|
||||
});
|
||||
|
||||
test('a pack-routed live feed waits for the catalog, then runs once its pack is built', async (t) => {
|
||||
// A fresh install builds no packs: until the catalog says which are
|
||||
// built, no such feed's pack is asked for (it would answer 404).
|
||||
const catalog = pendingCatalog();
|
||||
const app = harness(t, { at: MADRID, catalog: catalog.promise, paths: {} });
|
||||
liveFeedsAnswer(app);
|
||||
await app.enable();
|
||||
const asks = () => [...app.requestedSchedules, ...app.requestedPaths];
|
||||
assert.ok(app.requestedPaths.includes(CERCANIAS), 'Cercanías starts at once');
|
||||
assert.deepEqual(asks().filter(needsPack), []);
|
||||
assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE));
|
||||
assert.ok(!app.requested.includes(LONG_DISTANCE));
|
||||
|
||||
catalog.resolve(
|
||||
publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }),
|
||||
);
|
||||
await app.settle();
|
||||
assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE));
|
||||
assert.ok(app.requested.includes(LONG_DISTANCE), 'its live positions');
|
||||
assert.ok(app.requestedPaths.includes(LONG_DISTANCE), 'and its line');
|
||||
// Its schedule pack is not needed to place anything.
|
||||
assert.deepEqual(app.requestedSchedules, []);
|
||||
});
|
||||
|
||||
test('a pack-routed live feed whose pack is not built is never polled', async (t) => {
|
||||
const app = harness(t, {
|
||||
at: MADRID,
|
||||
catalog: publicRailCatalog({ schedulePackBuilt: () => false }),
|
||||
paths: {},
|
||||
});
|
||||
liveFeedsAnswer(app);
|
||||
await app.enable();
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.deepEqual([...app.state()._activeFeeds.keys()], [CERCANIAS]);
|
||||
assert.ok(!app.requested.includes(LONG_DISTANCE));
|
||||
assert.deepEqual(
|
||||
[...app.requestedSchedules, ...app.requestedPaths].filter(needsPack),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a catalog that fails keeps pack-routed feeds held back and is asked for again', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// Two failures, then a catalog with Renfe long distance's pack built.
|
||||
const answers = [
|
||||
() => Promise.reject(new Error('Rail feeds HTTP 500')),
|
||||
() => Promise.reject(new Error('Rail feeds HTTP 502')),
|
||||
() =>
|
||||
publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }),
|
||||
];
|
||||
const app = harness(t, {
|
||||
at: MADRID,
|
||||
catalog: () => answers.shift()(),
|
||||
paths: {},
|
||||
});
|
||||
liveFeedsAnswer(app);
|
||||
await app.enable();
|
||||
const asks = () => [...app.requestedSchedules, ...app.requestedPaths];
|
||||
assert.equal(app.catalogRequests.length, 1);
|
||||
assert.ok(app.state()._activeFeeds.has(CERCANIAS), 'live feeds still run');
|
||||
assert.deepEqual(asks().filter(needsPack), [], 'no pack asked for');
|
||||
assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE));
|
||||
|
||||
// Asked for again after 30 s, then after a further minute.
|
||||
t.mock.timers.tick(CATALOG_RETRY_FIRST_MS - 1);
|
||||
await app.settle();
|
||||
assert.equal(app.catalogRequests.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
await app.settle();
|
||||
assert.equal(app.catalogRequests.length, 2);
|
||||
assert.deepEqual(asks().filter(needsPack), []);
|
||||
t.mock.timers.tick(2 * CATALOG_RETRY_FIRST_MS);
|
||||
await app.settle();
|
||||
assert.equal(app.catalogRequests.length, 3);
|
||||
assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE));
|
||||
assert.ok(app.requestedPaths.includes(LONG_DISTANCE));
|
||||
|
||||
// A catalog that loaded is not asked for again.
|
||||
t.mock.timers.tick(CATALOG_RETRY_MAX_MS);
|
||||
await app.settle();
|
||||
assert.equal(app.catalogRequests.length, 3);
|
||||
});
|
||||
|
||||
test('the catalog retry backs off to ten minutes and stops with the layer', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
assert.equal(railCatalogRetryDelayMs(1), CATALOG_RETRY_FIRST_MS);
|
||||
assert.equal(railCatalogRetryDelayMs(2), 2 * CATALOG_RETRY_FIRST_MS);
|
||||
assert.equal(railCatalogRetryDelayMs(30), CATALOG_RETRY_MAX_MS);
|
||||
const app = harness(t, {
|
||||
at: MADRID,
|
||||
catalog: () => Promise.reject(new Error('Rail feeds HTTP 500')),
|
||||
paths: {},
|
||||
});
|
||||
liveFeedsAnswer(app);
|
||||
await app.enable();
|
||||
app.layer.disable(app.viewer);
|
||||
t.mock.timers.tick(CATALOG_RETRY_MAX_MS);
|
||||
await app.settle();
|
||||
assert.equal(app.catalogRequests.length, 1, 'no retry while off');
|
||||
});
|
||||
|
||||
test('a source without a catalog polls only the feeds that need no pack', async (t) => {
|
||||
const app = harness(t, { at: MADRID, catalog: false, paths: {} });
|
||||
liveFeedsAnswer(app);
|
||||
await app.enable();
|
||||
const active = [...app.state()._activeFeeds.keys()];
|
||||
assert.deepEqual(active, [CERCANIAS]);
|
||||
assert.deepEqual(
|
||||
[...app.requestedSchedules, ...app.requestedPaths].filter(needsPack),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { pointAtS, projectOnPath } from '../../data/railPaths.js';
|
||||
|
||||
/**
|
||||
* Binding a train to its railway line and moving it along that line.
|
||||
*
|
||||
* A train is bound to one shape of its feed's path pack: the shape its trip
|
||||
* names, else the route's shape its report lies on. Every later report is
|
||||
* projected onto that shape near the arc the train was last seen at, so a
|
||||
* report can never snap to the far side of a loop or to a parallel branch.
|
||||
* A report that lands well off the line means the binding was wrong (a
|
||||
* diversion, a short-turn shape, a mismatched trip): the train is moved to
|
||||
* another of its route's shapes if one fits, else it is unbound and drawn
|
||||
* with straight-line motion again.
|
||||
*
|
||||
* Between two reports the marker moves by arc length: both reports are
|
||||
* projected onto the shape once per playback segment, and each frame only
|
||||
* interpolates the arc and looks up the point.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/** A report farther than this from the bound line unbinds the train (m). */
|
||||
export const PATH_OFF_TRACK_M = 150;
|
||||
/** Fastest train the search window allows for (m/s, about 250 km/h). */
|
||||
const PATH_MAX_SPEED_MPS = 70;
|
||||
/** Window slack for report jitter and clock error (m). */
|
||||
const PATH_WINDOW_SLACK_M = 300;
|
||||
/** Widest hinted search; beyond it the whole shape is searched (m). */
|
||||
const PATH_WINDOW_MAX_M = 50_000;
|
||||
/**
|
||||
* Among route shapes that fit a report about equally well, the longest wins:
|
||||
* overlapping shapes are usually a full run and its short turns, and a short
|
||||
* turn would unbind a train that runs past its end.
|
||||
*/
|
||||
const PATH_TIE_M = 10;
|
||||
|
||||
/**
|
||||
* Half-width of the arc window a train can have moved in `dtMs`, or NaN when
|
||||
* the gap is unknown or too long for a window to be meaningful.
|
||||
* @param {number} dtMs
|
||||
* @returns {number}
|
||||
*/
|
||||
export function pathWindowM(dtMs) {
|
||||
if (!Number.isFinite(dtMs)) return NaN;
|
||||
const window =
|
||||
PATH_WINDOW_SLACK_M + (PATH_MAX_SPEED_MPS * Math.abs(dtMs)) / 1000;
|
||||
return window > PATH_WINDOW_MAX_M ? NaN : window;
|
||||
}
|
||||
|
||||
function createBinding(shapeIndex, path, via, s, fix) {
|
||||
return {
|
||||
shapeIndex,
|
||||
path,
|
||||
via,
|
||||
/** Arc and time of the newest report. */
|
||||
fixS: s,
|
||||
fixT: fix.t,
|
||||
fixLat: fix.lat,
|
||||
fixLon: fix.lon,
|
||||
/** Arc and time last drawn. */
|
||||
drawS: s,
|
||||
drawT: fix.t,
|
||||
/** Projections of the current playback segment's two reports. */
|
||||
segment: {
|
||||
fromSeq: -1,
|
||||
toSeq: -1,
|
||||
fromLat: NaN,
|
||||
fromLon: NaN,
|
||||
toLat: NaN,
|
||||
toLon: NaN,
|
||||
sFrom: NaN,
|
||||
sTo: NaN,
|
||||
ok: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The route shape a report fits best, preferring the longest near-tie. */
|
||||
function bestRouteShape(pack, routeId, fix, accept) {
|
||||
const candidates = [];
|
||||
for (const index of pack.shapesForRoute(routeId)) {
|
||||
const path = pack.pathAt(index);
|
||||
const hit = projectOnPath(path, fix.lat, fix.lon);
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) continue;
|
||||
if (!accept(index, path)) continue;
|
||||
candidates.push({ index, path, s: hit.s, offsetM: hit.offsetM });
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const nearest = Math.min(...candidates.map((c) => c.offsetM));
|
||||
let best = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.offsetM > nearest + PATH_TIE_M) continue;
|
||||
if (!best || candidate.path.length > best.path.length) best = candidate;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a train to a shape from its feed's pack.
|
||||
*
|
||||
* The trip's own shape when the trip is in the pack and the report lies on it;
|
||||
* otherwise the route shape the report lies nearest (within PATH_OFF_TRACK_M),
|
||||
* which for a single-shape route is that shape.
|
||||
*
|
||||
* @param {object} pack From createRailPathPack.
|
||||
* @param {{tripId?: string, routeId?: string}} ids
|
||||
* @param {{t: number, lat: number, lon: number}} fix The newest report.
|
||||
* @returns {object|null} A binding, or null to keep straight-line motion.
|
||||
*/
|
||||
export function bindTrainPath(pack, { tripId, routeId } = {}, fix) {
|
||||
if (!pack || !fix || !Number.isFinite(fix.lat) || !Number.isFinite(fix.lon))
|
||||
return null;
|
||||
const trip = pack.shapeForTrip(tripId);
|
||||
if (trip !== null) {
|
||||
const path = pack.pathAt(trip);
|
||||
const hit = projectOnPath(path, fix.lat, fix.lon);
|
||||
if (hit && hit.offsetM <= PATH_OFF_TRACK_M)
|
||||
return createBinding(trip, path, 'trip', hit.s, fix);
|
||||
}
|
||||
const best = bestRouteShape(pack, routeId, fix, (index) => index !== trip);
|
||||
return best
|
||||
? createBinding(best.index, best.path, 'route', best.s, fix)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a new report on the bound shape.
|
||||
* @param {object} binding
|
||||
* @param {{t: number, lat: number, lon: number}} fix
|
||||
* @returns {boolean} False when the report lies off the line: the binding is wrong.
|
||||
*/
|
||||
export function followFix(binding, fix) {
|
||||
if (!binding || !fix) return false;
|
||||
const hit = projectOnPath(
|
||||
binding.path,
|
||||
fix.lat,
|
||||
fix.lon,
|
||||
binding.fixS,
|
||||
pathWindowM(fix.t - binding.fixT),
|
||||
);
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
|
||||
binding.fixS = hit.s;
|
||||
binding.fixT = fix.t;
|
||||
binding.fixLat = fix.lat;
|
||||
binding.fixLon = fix.lon;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a report fell off the bound shape: another shape of the route that
|
||||
* holds both the previous report and this one, or null for straight lines.
|
||||
* @param {object} pack
|
||||
* @param {{routeId?: string}} ids
|
||||
* @param {object} binding The binding that no longer fits.
|
||||
* @param {{t: number, lat: number, lon: number}} fix
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function rebindTrainPath(pack, { routeId } = {}, binding, fix) {
|
||||
if (!pack || !binding || !fix) return null;
|
||||
const best = bestRouteShape(pack, routeId, fix, (index, path) => {
|
||||
if (index === binding.shapeIndex) return false;
|
||||
const previous = projectOnPath(path, binding.fixLat, binding.fixLon);
|
||||
return !!previous && previous.offsetM <= PATH_OFF_TRACK_M;
|
||||
});
|
||||
return best
|
||||
? createBinding(best.index, best.path, 'route', best.s, fix)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Project one playback report, near the last drawn arc when that is useful. */
|
||||
function projectReport(binding, fix, hintS, hintT) {
|
||||
const path = binding.path;
|
||||
const window = pathWindowM(fix.t - hintT);
|
||||
let hit = Number.isFinite(hintS)
|
||||
? projectOnPath(path, fix.lat, fix.lon, hintS, window)
|
||||
: null;
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M)
|
||||
hit = projectOnPath(path, fix.lat, fix.lon);
|
||||
return hit && hit.offsetM <= PATH_OFF_TRACK_M ? hit.s : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a bound train is drawn this frame.
|
||||
*
|
||||
* The playback segment's two reports are projected once per segment (the
|
||||
* from-report reuses the previous segment's to-report when they are the same
|
||||
* report); every frame interpolates the arc by the playback fraction and
|
||||
* looks up that point.
|
||||
*
|
||||
* @param {object} binding
|
||||
* @param {{fromSeq: number, toSeq: number, fraction: number}} sample Playback sample.
|
||||
* @param {{from: {seq:number, t:number, lat:number, lon:number},
|
||||
* to: {seq:number, t:number, lat:number, lon:number}}} segment The sample's two reports.
|
||||
* @param {{lat?: number, lon?: number, bearingDeg?: number}} out
|
||||
* @returns {boolean} False when the train should stay on its straight-line point.
|
||||
*/
|
||||
export function placeOnPath(binding, sample, segment, out) {
|
||||
if (!binding || !sample || !segment || !(sample.fromSeq >= 0)) return false;
|
||||
const { from, to } = segment;
|
||||
if (from.seq !== sample.fromSeq || to.seq !== sample.toSeq) return false;
|
||||
const cache = binding.segment;
|
||||
if (
|
||||
cache.fromSeq !== sample.fromSeq ||
|
||||
cache.toSeq !== sample.toSeq ||
|
||||
cache.fromLat !== from.lat ||
|
||||
cache.fromLon !== from.lon ||
|
||||
cache.toLat !== to.lat ||
|
||||
cache.toLon !== to.lon
|
||||
) {
|
||||
const chained =
|
||||
cache.ok &&
|
||||
cache.toSeq === sample.fromSeq &&
|
||||
cache.toLat === from.lat &&
|
||||
cache.toLon === from.lon;
|
||||
const sFrom = chained
|
||||
? cache.sTo
|
||||
: projectReport(binding, from, binding.drawS, binding.drawT);
|
||||
const sTo =
|
||||
sample.toSeq === sample.fromSeq
|
||||
? sFrom
|
||||
: Number.isFinite(sFrom)
|
||||
? projectReport(binding, to, sFrom, from.t)
|
||||
: NaN;
|
||||
cache.fromSeq = sample.fromSeq;
|
||||
cache.toSeq = sample.toSeq;
|
||||
cache.fromLat = from.lat;
|
||||
cache.fromLon = from.lon;
|
||||
cache.toLat = to.lat;
|
||||
cache.toLon = to.lon;
|
||||
cache.sFrom = sFrom;
|
||||
cache.sTo = sTo;
|
||||
cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo);
|
||||
}
|
||||
if (!cache.ok) return false;
|
||||
const f = Number.isFinite(sample.fraction)
|
||||
? Math.max(0, Math.min(1, sample.fraction))
|
||||
: 1;
|
||||
const s = cache.sFrom + (cache.sTo - cache.sFrom) * f;
|
||||
if (!pointAtS(binding.path, s, out)) return false;
|
||||
binding.drawS = s;
|
||||
binding.drawT = from.t + (to.t - from.t) * f;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRailPathPack } from '../../data/railPaths.js';
|
||||
import {
|
||||
PATH_OFF_TRACK_M,
|
||||
bindTrainPath,
|
||||
followFix,
|
||||
pathWindowM,
|
||||
placeOnPath,
|
||||
rebindTrainPath,
|
||||
} from './pathBinding.js';
|
||||
|
||||
const D = 1 / 111_320;
|
||||
|
||||
/**
|
||||
* Three shapes on the equator:
|
||||
* 0: an L, 2 km east then 2 km north (the full run);
|
||||
* 1: the first 1 km of the same line (a short turn);
|
||||
* 2: 2 km east then 2 km south (a branch at the same junction).
|
||||
*/
|
||||
function pack() {
|
||||
return createRailPathPack({
|
||||
version: 1,
|
||||
feedId: 'test-feed',
|
||||
shapes: [
|
||||
[0, 0, 0, 2000 * D, 2000 * D, 2000 * D],
|
||||
[0, 0, 0, 1000 * D],
|
||||
[0, 0, 0, 2000 * D, -2000 * D, 2000 * D],
|
||||
],
|
||||
dist: [
|
||||
[0, 2000, 4000],
|
||||
[0, 1000],
|
||||
[0, 2000, 4000],
|
||||
],
|
||||
trips: { full: 0, branch: 2 },
|
||||
routes: { main: [0, 1, 2], short: [1] },
|
||||
});
|
||||
}
|
||||
|
||||
const fix = (t, eastM, northM) => ({ t, lat: northM * D, lon: eastM * D });
|
||||
|
||||
test('a trip binds to its own shape, else to the best route shape', () => {
|
||||
const p = pack();
|
||||
const byTrip = bindTrainPath(
|
||||
p,
|
||||
{ tripId: 'branch', routeId: 'main' },
|
||||
fix(0, 500, 10),
|
||||
);
|
||||
assert.equal(byTrip.shapeIndex, 2);
|
||||
assert.equal(byTrip.via, 'trip');
|
||||
assert.ok(Math.abs(byTrip.fixS - 500) < 0.5);
|
||||
const single = bindTrainPath(
|
||||
p,
|
||||
{ tripId: 'unknown', routeId: 'short' },
|
||||
fix(0, 500, 0),
|
||||
);
|
||||
assert.equal(single.shapeIndex, 1);
|
||||
assert.equal(single.via, 'route');
|
||||
// Near the shared start every main shape fits; the longest wins the tie.
|
||||
const shared = bindTrainPath(p, { routeId: 'main' }, fix(0, 500, 0));
|
||||
assert.ok([0, 2].includes(shared.shapeIndex), String(shared.shapeIndex));
|
||||
// Up the north leg only the full run fits.
|
||||
const north = bindTrainPath(p, { routeId: 'main' }, fix(0, 2010, 1500));
|
||||
assert.equal(north.shapeIndex, 0);
|
||||
assert.ok(Math.abs(north.fixS - 3500) < 1);
|
||||
});
|
||||
|
||||
test('a trip whose report is off its shape falls back to the route', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(
|
||||
p,
|
||||
{ tripId: 'branch', routeId: 'main' },
|
||||
fix(0, 2000, 1000),
|
||||
);
|
||||
assert.equal(binding.shapeIndex, 0);
|
||||
});
|
||||
|
||||
test('nothing binds when no shape lies within the off-track distance', () => {
|
||||
const p = pack();
|
||||
assert.equal(
|
||||
bindTrainPath(p, { routeId: 'main' }, fix(0, 1000, PATH_OFF_TRACK_M + 50)),
|
||||
null,
|
||||
);
|
||||
assert.equal(bindTrainPath(p, { routeId: 'none' }, fix(0, 0, 0)), null);
|
||||
assert.equal(bindTrainPath(null, { routeId: 'main' }, fix(0, 0, 0)), null);
|
||||
assert.equal(
|
||||
bindTrainPath(p, { routeId: 'main' }, { t: 0, lat: Number.NaN, lon: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('reports are followed along the line and an off-line report unbinds', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0));
|
||||
assert.ok(followFix(binding, fix(30_000, 1200, 5)));
|
||||
assert.ok(Math.abs(binding.fixS - 1200) < 0.5);
|
||||
assert.equal(followFix(binding, fix(60_000, 1500, 400)), false);
|
||||
assert.ok(
|
||||
Math.abs(binding.fixS - 1200) < 0.5,
|
||||
'a rejected report changes nothing',
|
||||
);
|
||||
});
|
||||
|
||||
test('an off-line report moves the train to a route shape that holds both reports', () => {
|
||||
const p = pack();
|
||||
// Bound to the short turn, the train runs past its end and on up the L.
|
||||
const short = bindTrainPath(p, { routeId: 'short' }, fix(0, 800, 0));
|
||||
assert.ok(followFix(short, fix(20_000, 950, 0)));
|
||||
const next = fix(60_000, 2000, 300);
|
||||
assert.equal(followFix(short, next), false);
|
||||
const moved = rebindTrainPath(p, { routeId: 'main' }, short, next);
|
||||
assert.equal(moved.shapeIndex, 0);
|
||||
assert.ok(Math.abs(moved.fixS - 2300) < 1);
|
||||
assert.equal(rebindTrainPath(p, { routeId: 'short' }, short, next), null);
|
||||
});
|
||||
|
||||
test('the search window grows with the gap and gives up when it is too long', () => {
|
||||
assert.equal(pathWindowM(0), 300);
|
||||
assert.equal(pathWindowM(-10_000), 1000);
|
||||
assert.ok(Number.isNaN(pathWindowM(Number.NaN)));
|
||||
assert.ok(Number.isNaN(pathWindowM(3_600_000)));
|
||||
});
|
||||
|
||||
function playback(fromSeq, toSeq, from, to, fraction) {
|
||||
return {
|
||||
sample: { fromSeq, toSeq, fraction },
|
||||
segment: {
|
||||
from: { seq: fromSeq, ...from },
|
||||
to: { seq: toSeq, ...to },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('between two reports a bound train moves along the line, not the chord', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 1500, 0));
|
||||
// A corner cut: 1.5 km east, then 500 m up the north leg.
|
||||
const a = fix(0, 1500, 0);
|
||||
const b = fix(60_000, 2000, 500);
|
||||
const out = {};
|
||||
const mid = playback(4, 5, a, b, 0.5);
|
||||
assert.ok(placeOnPath(binding, mid.sample, mid.segment, out));
|
||||
// Halfway along the 1 km of track is the corner itself.
|
||||
assert.ok(Math.abs(out.lat) < 1e-9, String(out.lat));
|
||||
assert.ok(Math.abs(out.lon - 2000 * D) < 1e-6, String(out.lon));
|
||||
assert.ok(Math.abs(binding.drawS - 2000) < 1);
|
||||
const later = playback(4, 5, a, b, 0.75);
|
||||
assert.ok(placeOnPath(binding, later.sample, later.segment, out));
|
||||
assert.ok(Math.abs(out.lat - 250 * D) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(out.bearingDeg) < 1e-6 || Math.abs(out.bearingDeg - 360) < 1e-6,
|
||||
);
|
||||
});
|
||||
|
||||
test('the two projections are computed once per segment and chained', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0));
|
||||
const out = {};
|
||||
const first = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.2);
|
||||
assert.ok(placeOnPath(binding, first.sample, first.segment, out));
|
||||
const cache = binding.segment;
|
||||
assert.equal(cache.fromSeq, 1);
|
||||
assert.ok(Math.abs(cache.sTo - 600) < 0.5);
|
||||
// Same segment, later frame: cached arcs, only the fraction moves.
|
||||
cache.sFrom = 100.25; // a sentinel a recompute would overwrite
|
||||
const frame = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.4);
|
||||
assert.ok(placeOnPath(binding, frame.sample, frame.segment, out));
|
||||
assert.equal(cache.sFrom, 100.25);
|
||||
// Next segment starts at the old to-report: its arc is reused.
|
||||
cache.sTo = 600.5;
|
||||
const next = playback(2, 3, fix(30_000, 600, 0), fix(60_000, 1100, 0), 0);
|
||||
assert.ok(placeOnPath(binding, next.sample, next.segment, out));
|
||||
assert.equal(cache.sFrom, 600.5);
|
||||
assert.ok(Math.abs(cache.sTo - 1100) < 0.5);
|
||||
});
|
||||
|
||||
test('a parked train sits on the line at its report', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 700, 20));
|
||||
const out = {};
|
||||
const parked = playback(3, 3, fix(0, 700, 20), fix(0, 700, 20), 1);
|
||||
assert.ok(placeOnPath(binding, parked.sample, parked.segment, out));
|
||||
assert.equal(out.lat, 0, 'pulled onto the track');
|
||||
assert.ok(Math.abs(out.lon - 700 * D) < 1e-6);
|
||||
});
|
||||
|
||||
test('a segment whose reports do not fit the line keeps the straight line', () => {
|
||||
const p = pack();
|
||||
const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0));
|
||||
const out = {};
|
||||
const off = playback(1, 2, fix(0, 100, 0), fix(30_000, 800, 600), 0.5);
|
||||
assert.equal(placeOnPath(binding, off.sample, off.segment, out), false);
|
||||
assert.equal(binding.segment.ok, false);
|
||||
// No playback position yet, or stale report samples: nothing to place.
|
||||
assert.equal(
|
||||
placeOnPath(binding, { fromSeq: -1, toSeq: -1 }, off.segment, out),
|
||||
false,
|
||||
);
|
||||
const stale = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.5);
|
||||
stale.segment.to.seq = 9;
|
||||
assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false);
|
||||
assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false);
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* @module layers/rail/policy
|
||||
* @description Constants and pure decisions for the Rail layer. No Cesium
|
||||
* scene state, no network: everything here is exercised by node:test.
|
||||
*
|
||||
* Motion between reports is the Transit layer's delayed playback
|
||||
* (src/layers/transit/movement.js over src/data/contactPlayback.js), so the
|
||||
* report-age rules here are Transit's too.
|
||||
*/
|
||||
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import {
|
||||
RAIL_CATEGORIES,
|
||||
RAIL_POSITION_SOURCES,
|
||||
} from '../../data/railFeeds.js';
|
||||
import { displayMotion } from '../transit/movement.js';
|
||||
import { FIX_FLAGS, readFix } from '../../data/contactPlayback.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
import {
|
||||
stopClock,
|
||||
stopLateMinutes,
|
||||
stopName,
|
||||
stopRoleIndices,
|
||||
} from './stopLabels.js';
|
||||
|
||||
export const RAIL_SELECTED_OVERLAY_SOURCE_ID = 'rail-selected';
|
||||
export const RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 0,
|
||||
moving: true,
|
||||
});
|
||||
|
||||
/** Poll interval (ms), matching the proxy's 15 s cache window. */
|
||||
export const RAIL_POLL_MS = 15_000;
|
||||
/**
|
||||
* Feeds activate below this camera altitude, with hysteresis. Rail networks
|
||||
* are regional, so the gate is Transit's: low enough that a whole-continent
|
||||
* view does not poll every operator at once.
|
||||
*/
|
||||
export const ACTIVATION_ALTITUDE_M = 3_000_000;
|
||||
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000;
|
||||
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000;
|
||||
export const CAMERA_DEBOUNCE_MS = 340;
|
||||
export const CAMERA_PERCENTAGE_CHANGED = 0.05;
|
||||
/** A feed already active stays active this far past its coverage edge. */
|
||||
export const RANGE_SLACK_KM = 40;
|
||||
export const MAX_TRAINS_TOTAL = 5_000;
|
||||
/** A train a feed stops listing for this many polls in a row is removed. */
|
||||
export const MISSED_POLLS_TO_DROP = 2;
|
||||
/** A report older than this is not drawn (a frozen feed empties, it does not lie). */
|
||||
export const TRAIN_MAX_FIX_AGE_MS = 10 * 60_000;
|
||||
/** A feed whose operator has not answered for this long reads as stale. */
|
||||
export const FEED_STALE_AFTER_MS = 90_000;
|
||||
/** ...and for this long, its trains are removed. */
|
||||
export const FEED_EVICT_AFTER_MS = 5 * 60_000;
|
||||
/** A stamp this far ahead of the fetch is a clock fault, bounded by the fetch. */
|
||||
export const FUTURE_STAMP_TOLERANCE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Where a drawn train's position may come from: the operator's GPS, or the
|
||||
* station or timing point its signalling last reported. No train is placed
|
||||
* or moved from a timetable; between reports it only glides along its line
|
||||
* behind real time, as Transit's vehicles do. Every source the registry
|
||||
* knows (RAIL_POSITION_SOURCES) is a live one.
|
||||
*/
|
||||
export const RAIL_LIVE_POSITION_SOURCES = RAIL_POSITION_SOURCES;
|
||||
|
||||
/**
|
||||
* Whether a feed (a registry or catalog entry) or a train record reports
|
||||
* live positions. Without a stated source, GPS is meant.
|
||||
* @param {string|null|undefined} source `feed.positions` or
|
||||
* `record.positionSource`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLiveRailPositionSource(source) {
|
||||
return RAIL_LIVE_POSITION_SOURCES.includes(source ?? 'gps');
|
||||
}
|
||||
/**
|
||||
* A feed catalog that fails is asked for again after CATALOG_RETRY_FIRST_MS,
|
||||
* then twice as long after each failure in a row, up to CATALOG_RETRY_MAX_MS.
|
||||
*/
|
||||
export const CATALOG_RETRY_FIRST_MS = 30_000;
|
||||
export const CATALOG_RETRY_MAX_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* How long the catalog waits after its nth failure in a row (ms).
|
||||
* @param {number} failures 1 after the first.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function railCatalogRetryDelayMs(failures) {
|
||||
return Math.min(
|
||||
CATALOG_RETRY_MAX_MS,
|
||||
CATALOG_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor cells a moving train reaches cold are warmed in batches: at most
|
||||
* COLD_CELLS_PER_FLUSH per flush, COLD_CELL_FLUSH_MS after the first one is
|
||||
* queued, and a cell asked for is not asked again for COLD_CELL_RETRY_MS. A
|
||||
* long-distance train crosses a 0.001° cell (about 111 m) every few seconds.
|
||||
*/
|
||||
export const COLD_CELLS_PER_FLUSH = 64;
|
||||
export const COLD_CELL_FLUSH_MS = 1_000;
|
||||
/**
|
||||
* Floor warming shares `/api/terrain/heights` with every other layer: 90
|
||||
* requests a minute per client, 64 points each. Rail's budget refills at
|
||||
* FLOOR_CELLS_PER_MINUTE cells a minute and holds at most FLOOR_CELLS_BURST,
|
||||
* so no minute spends more than the two together (1,520 cells, about 24
|
||||
* requests). It is spent only on trains within FLOOR_WARM_MAX_DIST_KM of the
|
||||
* camera and on the selected trip's stops, the selected train first, then
|
||||
* the nearest, so hundreds of trains in view (Paris, the Ruhr) cannot use up
|
||||
* the limit and leave every layer without floors.
|
||||
*/
|
||||
export const FLOOR_CELLS_PER_MINUTE = 1_200;
|
||||
export const FLOOR_CELLS_BURST = COLD_CELLS_PER_FLUSH * 5;
|
||||
export const FLOOR_WARM_MAX_DIST_KM = 30;
|
||||
export const COLD_CELL_RETRY_MS = 5_000;
|
||||
/**
|
||||
* Track heights on photorealistic tiles: the rendered mesh is sampled at
|
||||
* points on the train's own line (where the draped line is the track bed
|
||||
* or the deck), in buckets of 1/TRACK_SAMPLE_GRID degree (about 55 m),
|
||||
* at most TRACK_SAMPLES_PER_FLUSH per flush, for trains within
|
||||
* TRACK_SAMPLE_MAX_DIST_KM of a camera at most TRACK_SAMPLE_MAX_CAMERA_M
|
||||
* above the floor there, and only while the tileset reports its tiles
|
||||
* loaded: a higher camera, or tiles still streaming, reads coarse tiles,
|
||||
* whose height under a viaduct is the valley's (the failure
|
||||
* src/services/meshFloorSampler.js records). A sample is kept only within
|
||||
* TRACK_PRIOR_BELOW_M / TRACK_PRIOR_ABOVE_M of the floor under it, and is
|
||||
* read again after TRACK_SAMPLE_REFRESH_MS: the new reading replaces it
|
||||
* when the camera is no higher than it was, or once the old one is
|
||||
* TRACK_SAMPLE_MAX_AGE_MS old.
|
||||
*/
|
||||
export const TRACK_SAMPLE_GRID = 2000;
|
||||
export const TRACK_SAMPLES_PER_FLUSH = 8;
|
||||
export const TRACK_SAMPLE_RETRY_MS = 4_000;
|
||||
export const TRACK_SAMPLE_MAX_CAMERA_M = 5_000;
|
||||
export const TRACK_SAMPLE_MAX_DIST_KM = 8;
|
||||
export const TRACK_PRIOR_BELOW_M = 25;
|
||||
export const TRACK_PRIOR_ABOVE_M = 80;
|
||||
export const TRACK_SAMPLE_REFRESH_MS = 30_000;
|
||||
export const TRACK_SAMPLE_MAX_AGE_MS = 5 * 60_000;
|
||||
/**
|
||||
* A train's drawn height follows its target height no faster than a
|
||||
* railway climbs: TRACK_MAX_GRADE of the distance it moved (lines rarely
|
||||
* exceed 4%), plus TRACK_SETTLE_MPS while it stands, so a roof or overpass
|
||||
* sampled under it is a bump, not a jump. It snaps to a better source (a
|
||||
* real floor replacing a fallback, or any floor replacing the ellipsoid)
|
||||
* and after a jump of more than TRACK_SNAP_M.
|
||||
*/
|
||||
export const TRACK_MAX_GRADE = 0.05;
|
||||
export const TRACK_SETTLE_MPS = 0.5;
|
||||
export const TRACK_SNAP_M = 2_000;
|
||||
export const MARKER_PIXEL_SIZE = 18;
|
||||
export const SELECTED_PIXEL_SIZE = 28;
|
||||
export const SELECTED_CARD_REFRESH_MS = 250;
|
||||
/** While a train is selected, every other train is drawn at this opacity. */
|
||||
export const UNSELECTED_TRAIN_ALPHA = 0.35;
|
||||
/**
|
||||
* The faint route network is drawn in full below the first altitude, fades
|
||||
* out above it and is not drawn at all above the second (m). Above that the
|
||||
* lines are clutter at the scale of a whole region, and cost frames.
|
||||
*/
|
||||
export const NETWORK_FULL_BELOW_M = 150_000;
|
||||
export const NETWORK_HIDDEN_ABOVE_M = 200_000;
|
||||
/** The network's opacity, and its share of that while a train is selected. */
|
||||
export const NETWORK_ALPHA = 0.4;
|
||||
export const NETWORK_SELECTED_DIM = 0.4;
|
||||
export const NETWORK_WIDTH_PX = 2;
|
||||
/**
|
||||
* A feed out of use this long gives back its network, path pack and parsed
|
||||
* schedule pack; it loads them again when next used (ms).
|
||||
*/
|
||||
export const FEED_RELEASE_AFTER_MS = 10 * 60_000;
|
||||
/** The selected train's line: the part ahead, and the part travelled. */
|
||||
export const ROUTE_AHEAD_WIDTH_PX = 5;
|
||||
export const ROUTE_TRAVELLED_ALPHA = 0.3;
|
||||
/** How often the selected line's travelled/ahead split follows the train. */
|
||||
export const ROUTE_SPLIT_INTERVAL_MS = 250;
|
||||
/** How often the selected train's stops, labels and card are refreshed. */
|
||||
export const STOP_REFRESH_MS = 1_000;
|
||||
/**
|
||||
* How long the follow camera glides to another feed's copy of the selected
|
||||
* train when the selection moves to it (ms), so it neither jumps nor lets go.
|
||||
*/
|
||||
export const FOLLOW_HANDOVER_MS = 1_200;
|
||||
|
||||
/**
|
||||
* Category palette. Warm to cool with distance served, and none of the colours
|
||||
* the Transit modes, flights (white/cyan), military (amber) or vessels use, so
|
||||
* a train never reads as a bus drawn by the other layer.
|
||||
*/
|
||||
export const RAIL_CATEGORY_COLORS = Object.freeze({
|
||||
transit: '#FF7A59',
|
||||
local: '#F2D04B',
|
||||
regional: '#7BE495',
|
||||
national: '#5AB8FF',
|
||||
international: '#C58BFF',
|
||||
});
|
||||
|
||||
/** Panel and card words for each category. */
|
||||
export const RAIL_CATEGORY_LABELS = Object.freeze({
|
||||
transit: msg('Urban rail'),
|
||||
local: msg('Local rail'),
|
||||
regional: msg('Regional rail'),
|
||||
national: msg('National rail'),
|
||||
international: msg('International rail'),
|
||||
});
|
||||
|
||||
/** Card words for what runs on the track, shared with the Transit layer's. */
|
||||
const RAIL_MODE_WORDS = Object.freeze({
|
||||
rail: msg('Train'),
|
||||
metro: msg('Subway'),
|
||||
tram: msg('Tram'),
|
||||
});
|
||||
|
||||
/** Default visibility: every category on. */
|
||||
export const DEFAULT_RAIL_CATEGORY_VISIBILITY = Object.freeze(
|
||||
Object.fromEntries(RAIL_CATEGORIES.map((category) => [category, true])),
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalise requested category toggles against the current ones. Unknown keys
|
||||
* are ignored and non-boolean values keep the current setting, so a malformed
|
||||
* share link can hide nothing it did not name.
|
||||
* @param {Record<string, unknown>} params
|
||||
* @param {Record<string, boolean>} [current]
|
||||
* @returns {Record<string, boolean>}
|
||||
*/
|
||||
export function normalizeRailCategories(
|
||||
params,
|
||||
current = DEFAULT_RAIL_CATEGORY_VISIBILITY,
|
||||
) {
|
||||
const next = {};
|
||||
for (const category of RAIL_CATEGORIES) {
|
||||
next[category] =
|
||||
typeof params?.[category] === 'boolean'
|
||||
? params[category]
|
||||
: current[category] !== false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable key for a train across polls. Prefixed so it can never equal a
|
||||
* Transit vehicle key for the same physical train in a shared pick registry.
|
||||
* @param {string} feedId
|
||||
* @param {string} trainId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function railTrainKey(feedId, trainId) {
|
||||
return `rail:${feedId}:${trainId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a report was made, in ms: the train's own stamp, else the fetch time.
|
||||
* @param {object} record
|
||||
* @param {number|null} [fetchedAtMs]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trainReportTimeMs(record, fetchedAtMs = null) {
|
||||
const seconds = Number(record?.timestamp);
|
||||
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
||||
if (Number.isFinite(fetchedAtMs)) return fetchedAtMs;
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Age of a report in ms (never negative). An unstamped record is aged from
|
||||
* the snapshot's fetch time, never from now.
|
||||
* @param {object} record
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trainFixAgeMs(record, nowMs, fetchedAtMs = null) {
|
||||
const seconds = Number(record?.timestamp);
|
||||
if (Number.isFinite(seconds) && seconds > 0)
|
||||
return Math.max(0, nowMs - seconds * 1000);
|
||||
if (Number.isFinite(fetchedAtMs)) return Math.max(0, nowMs - fetchedAtMs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Whether a report is too old to draw. */
|
||||
export function isStaleTrainFix(record, nowMs, fetchedAtMs = null) {
|
||||
return trainFixAgeMs(record, nowMs, fetchedAtMs) > TRAIN_MAX_FIX_AGE_MS;
|
||||
}
|
||||
|
||||
/** The movement module's speed ceilings are keyed by Transit mode names. */
|
||||
export function playbackModeFor(railMode) {
|
||||
return railMode === 'metro'
|
||||
? 'subway'
|
||||
: railMode === 'tram'
|
||||
? 'tram'
|
||||
: 'rail';
|
||||
}
|
||||
|
||||
/** Two reports closer than this are one place: a parked train's jitter (m). */
|
||||
export const FOLLOW_MIN_MOVE_M = 15;
|
||||
|
||||
const lastFix = {};
|
||||
const priorFix = {};
|
||||
|
||||
/** How far apart a track's last two reports are (m), 0 with fewer. */
|
||||
function lastReportsApartM(track) {
|
||||
if (!track || track.count < 2) return 0;
|
||||
const to = readFix(track, track.count - 1, lastFix);
|
||||
const from = readFix(track, track.count - 2, priorFix);
|
||||
if (from.epoch !== to.epoch || to.flags & FIX_FLAGS.BREAK) return 0;
|
||||
const kx = 111_320 * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180);
|
||||
const dLon = ((((to.lon - from.lon + 180) % 360) + 360) % 360) - 180;
|
||||
return Math.hypot(dLon * kx, (to.lat - from.lat) * 111_320);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera follows a train once it is selected, as it follows a
|
||||
* tracked aircraft: its reports are recent, and it is moving on its playback
|
||||
* now, waits at a stop with its next move already reported, or is between
|
||||
* reports (playback waiting for the next one) after its last two reports
|
||||
* moved it. Any other train (parked, known from a single report, or not
|
||||
* heard from lately) gets one fly-to.
|
||||
* @param {{sample?: object, track?: object}|null} entry Runtime entry with
|
||||
* playback state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function railTrainFollowable(entry) {
|
||||
const sample = entry?.sample;
|
||||
if (!sample || displayMotion(entry, 0).word === 'NO FIX') return false;
|
||||
if (sample.phase === 'playing' && sample.segmentSpeedMps > 0) return true;
|
||||
if (
|
||||
sample.phase === 'playing' &&
|
||||
sample.motion === 'stopped' &&
|
||||
Number.isFinite(sample.nextMotionT) &&
|
||||
sample.nextMotionT > sample.displayT
|
||||
)
|
||||
return true;
|
||||
return lastReportsApartM(entry.track) >= FOLLOW_MIN_MOVE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The follow camera's offset from the train in east-north-up metres: the
|
||||
* shared world-focus framing for a train (its range and pitch) seen along the
|
||||
* camera's current heading, so taking the train does not swing the view round.
|
||||
* @param {{rangeM: number, pitchDeg: number}} framing
|
||||
* @param {number} headingRad
|
||||
* @returns {{x: number, y: number, z: number}}
|
||||
*/
|
||||
export function railFollowOffset(framing, headingRad) {
|
||||
const heading = Number.isFinite(headingRad) ? headingRad : 0;
|
||||
const pitch = (framing.pitchDeg * Math.PI) / 180;
|
||||
const across = framing.rangeM * Math.cos(pitch);
|
||||
return {
|
||||
x: -Math.sin(heading) * across,
|
||||
y: -Math.cos(heading) * across,
|
||||
z: -Math.sin(pitch) * framing.rangeM,
|
||||
};
|
||||
}
|
||||
|
||||
// Tools and the server may import this module, so no catalogue here: the
|
||||
// layer passes the UI's t().
|
||||
function englishText(text, values) {
|
||||
return String(text).replace(/\{(\w+)\}/g, (match, key) =>
|
||||
values && key in values ? String(values[key]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
/** Card words for where a train's position comes from. */
|
||||
const RAIL_POSITION_SOURCE_WORDS = Object.freeze({
|
||||
gps: msg('Live GPS'),
|
||||
event: msg('Estimated from station reports'),
|
||||
});
|
||||
|
||||
/** The card never grows past this many detail lines. */
|
||||
export const RAIL_CARD_MAX_LINES = 6;
|
||||
|
||||
/**
|
||||
* The card's title: the route when the feed names one, else the trip's
|
||||
* headsign, else the vehicle's own label, else its id.
|
||||
* @param {object} record
|
||||
* @param {string|null} headsign
|
||||
* @param {(text: string, values?: object) => string} t
|
||||
* @returns {string}
|
||||
*/
|
||||
function railCardTitle(record, headsign, t) {
|
||||
const to = headsign ? t('To {headsign}', { headsign }) : '';
|
||||
if (record.routeId) {
|
||||
const route = t('Route {route}', { route: record.routeId });
|
||||
return `🚆 ${to ? `${route} · ${to}` : route}`;
|
||||
}
|
||||
if (to) return `🚆 ${to}`;
|
||||
const label =
|
||||
typeof record.label === 'string' && record.label.trim()
|
||||
? record.label.trim()
|
||||
: '';
|
||||
if (label && label !== record.id) return `🚆 ${label}`;
|
||||
return `🚆 ${t('Vehicle {vehicle}', { vehicle: label || record.id })}`;
|
||||
}
|
||||
|
||||
/** "Next Yonkers 14:32 T−4 (+3 min late)" / "Prev Riverdale 14:24 T+3". */
|
||||
function stopLine(stop, which, t) {
|
||||
const values = {
|
||||
stop: stopName(stop),
|
||||
time: stopClock(stop),
|
||||
relative: stop.relative || '',
|
||||
};
|
||||
const line = (
|
||||
which === 'next'
|
||||
? t('Next {stop} {time} {relative}', values)
|
||||
: t('Prev {stop} {time} {relative}', values)
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const late = which === 'next' ? stopLateMinutes(stop) : 0;
|
||||
return late > 0
|
||||
? `${line} ${t('(+{minutes} min late)', { minutes: late })}`
|
||||
: line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card copy for the selected train. The status words, report-age wording and
|
||||
* "shown behind" note are the Transit card's, so they read the same.
|
||||
*
|
||||
* With the trip's stops (see stopLabels.js for the stop shape) the card names
|
||||
* the next and previous stops; the card stays within RAIL_CARD_MAX_LINES by
|
||||
* dropping its least useful lines first.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {object} record Train record from the snapshot.
|
||||
* @param {number} nowMs
|
||||
* @param {number|null} [fetchedAtMs]
|
||||
* @param {object|null} [entry] Runtime entry with playback state.
|
||||
* @param {(text: string, values?: object) => string} [t]
|
||||
* @param {{stops?: object[]|null, headsign?: string|null}|null} [trip] The
|
||||
* trip's stops and headsign from the feed's schedule pack, when known.
|
||||
* @returns {{title: string, details: string[]}}
|
||||
*/
|
||||
export function buildRailSelectionCopy(
|
||||
feed,
|
||||
record,
|
||||
nowMs,
|
||||
fetchedAtMs = null,
|
||||
entry = null,
|
||||
t = englishText,
|
||||
trip = null,
|
||||
) {
|
||||
const headsign =
|
||||
(typeof trip?.headsign === 'string' && trip.headsign.trim()) ||
|
||||
(typeof record.headsign === 'string' && record.headsign.trim()) ||
|
||||
null;
|
||||
const title = railCardTitle(record, headsign, t);
|
||||
const category = t(RAIL_CATEGORY_LABELS[record.category] || 'Train');
|
||||
const mode = t(RAIL_MODE_WORDS[record.mode] || RAIL_MODE_WORDS.rail);
|
||||
/** Lines with a rank: when the card is too long, the highest ranks go. */
|
||||
const lines = [];
|
||||
const add = (rank, text) => lines.push({ rank, text });
|
||||
add(0, `${category} · ${mode} · ${feed.name} · ${feed.region}`);
|
||||
|
||||
const shown = entry ? displayMotion(entry, nowMs) : null;
|
||||
const motion =
|
||||
shown?.word === 'STOPPED'
|
||||
? t('Stopped', { context: 'vehicle motion' })
|
||||
: shown?.word === 'NO FIX'
|
||||
? t('No recent fix')
|
||||
: shown?.word === 'WAITING'
|
||||
? t('Waiting for update')
|
||||
: shown
|
||||
? t('Moving')
|
||||
: '';
|
||||
const sourceWord = RAIL_POSITION_SOURCE_WORDS[record.positionSource];
|
||||
const source = sourceWord ? t(sourceWord) : '';
|
||||
if (motion || source)
|
||||
add(1, motion && source ? `${motion} · ${source}` : motion || source);
|
||||
|
||||
const stops = Array.isArray(trip?.stops) ? trip.stops : [];
|
||||
const roles = stopRoleIndices(stops);
|
||||
if (roles.next >= 0) add(2, stopLine(stops[roles.next], 'next', t));
|
||||
if (roles.previous >= 0)
|
||||
add(3, stopLine(stops[roles.previous], 'previous', t));
|
||||
const haveStops = roles.next >= 0 || roles.previous >= 0;
|
||||
|
||||
const ageS = Math.round(
|
||||
(Number.isFinite(entry?.sample?.latestReportAgeMs)
|
||||
? entry.sample.latestReportAgeMs
|
||||
: trainFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
|
||||
);
|
||||
const reported =
|
||||
ageS < 90
|
||||
? t('Reported {seconds} s ago', { seconds: ageS })
|
||||
: t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) });
|
||||
const ownTime =
|
||||
Number.isFinite(record.timestamp) &&
|
||||
record.timestamp > 0 &&
|
||||
(!record.timestampSource || record.timestampSource === 'vehicle');
|
||||
const lagS = Number.isFinite(entry?.sample?.actualDelayMs)
|
||||
? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000))
|
||||
: null;
|
||||
add(
|
||||
4,
|
||||
(ownTime ? reported : t('{reported} (feed time)', { reported })) +
|
||||
(lagS === null
|
||||
? ''
|
||||
: ` · ${t('shown {seconds} s behind', { seconds: lagS })}`),
|
||||
);
|
||||
|
||||
const reportedMotion = [];
|
||||
if (Number.isFinite(record.speedMps))
|
||||
reportedMotion.push(
|
||||
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
|
||||
);
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', {
|
||||
heading: roundHeadingDegrees(record.bearing),
|
||||
}),
|
||||
);
|
||||
if (reportedMotion.length) add(7, reportedMotion.join(' · '));
|
||||
// The feed's own stop id says less than the schedule pack's stop names.
|
||||
if (!haveStops) {
|
||||
if (record.status === 'STOPPED_AT' && record.stopId)
|
||||
add(8, t('Last report: stopped at stop {stop}', { stop: record.stopId }));
|
||||
else if (record.status === 'INCOMING_AT' && record.stopId)
|
||||
add(
|
||||
8,
|
||||
t('Last report: arriving at stop {stop}', { stop: record.stopId }),
|
||||
);
|
||||
else if (record.status === 'IN_TRANSIT_TO' && record.stopId)
|
||||
add(8, t('Last report: next stop {stop}', { stop: record.stopId }));
|
||||
}
|
||||
if (record.mode === 'metro')
|
||||
add(5, t('Shown at street level · depth not in the feed'));
|
||||
add(
|
||||
6,
|
||||
`${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
|
||||
);
|
||||
|
||||
// Past the limit, the highest ranks go; the rest keep their order.
|
||||
const kept = lines
|
||||
.map((line, order) => ({ ...line, order }))
|
||||
.sort((a, b) => a.rank - b.rank || a.order - b.order)
|
||||
.slice(0, RAIL_CARD_MAX_LINES)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { title, details: kept.map((line) => line.text) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared-host card for the selected train.
|
||||
* @param {string} key
|
||||
* @param {object|(() => object|null)} position Cartesian, or a getter the host
|
||||
* calls on every paint so the card follows the marker.
|
||||
* @param {{title: string, details: string[]}} copy
|
||||
* @param {string} category
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function createRailSelectedOverlayEntry(key, position, copy, category) {
|
||||
if (!key || !position) return null;
|
||||
return {
|
||||
id: String(key),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent: RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local,
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 11,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// Pure Rail layer decisions: category toggles from share links, train keys,
|
||||
// report aging, the playback mode a train borrows from Transit, and the
|
||||
// selected-train card. The copy builder stays free of i18n (tools import
|
||||
// policy.js); the browser hands it t(), so a fake t() shows which words go
|
||||
// through translation.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_RAIL_CATEGORY_VISIBILITY,
|
||||
RAIL_CARD_MAX_LINES,
|
||||
RAIL_CATEGORY_COLORS,
|
||||
TRAIN_MAX_FIX_AGE_MS,
|
||||
RAIL_LIVE_POSITION_SOURCES,
|
||||
buildRailSelectionCopy,
|
||||
createRailSelectedOverlayEntry,
|
||||
isLiveRailPositionSource,
|
||||
isStaleTrainFix,
|
||||
normalizeRailCategories,
|
||||
playbackModeFor,
|
||||
railTrainKey,
|
||||
trainFixAgeMs,
|
||||
trainReportTimeMs,
|
||||
} from './policy.js';
|
||||
import { RAIL_CATEGORIES } from '../../data/railFeeds.js';
|
||||
|
||||
const NOW = 1_794_000_000_000;
|
||||
const feed = { name: 'MBTA', region: 'Boston, MA' };
|
||||
|
||||
/** A t() that marks every string it translates and fills placeholders. */
|
||||
function fakeT() {
|
||||
const calls = [];
|
||||
const t = (text, values) => {
|
||||
calls.push({ text, values });
|
||||
const filled = String(text).replace(/\{(\w+)\}/g, (match, key) =>
|
||||
values && key in values ? String(values[key]) : match,
|
||||
);
|
||||
return `T(${filled})`;
|
||||
};
|
||||
return { t, calls };
|
||||
}
|
||||
|
||||
const stampedTrain = Object.freeze({
|
||||
id: '1712',
|
||||
label: 'Train 1712',
|
||||
routeId: 'CR-Worcester',
|
||||
category: 'local',
|
||||
mode: 'rail',
|
||||
lat: 42.35,
|
||||
lon: -71.2,
|
||||
bearing: 90,
|
||||
speedMps: 20,
|
||||
timestamp: (NOW - 30_000) / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
status: 'IN_TRANSIT_TO',
|
||||
stopId: 'place-sstat',
|
||||
});
|
||||
|
||||
const unstampedMetro = Object.freeze({
|
||||
id: '77',
|
||||
label: '77',
|
||||
routeId: null,
|
||||
category: 'transit',
|
||||
mode: 'metro',
|
||||
lat: 42.36,
|
||||
lon: -71.06,
|
||||
});
|
||||
|
||||
test('every category is on by default', () => {
|
||||
assert.deepEqual(Object.keys(DEFAULT_RAIL_CATEGORY_VISIBILITY), [
|
||||
...RAIL_CATEGORIES,
|
||||
]);
|
||||
assert.ok(
|
||||
Object.values(DEFAULT_RAIL_CATEGORY_VISIBILITY).every((on) => on === true),
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeRailCategories({}),
|
||||
DEFAULT_RAIL_CATEGORY_VISIBILITY,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeRailCategories(null),
|
||||
DEFAULT_RAIL_CATEGORY_VISIBILITY,
|
||||
);
|
||||
});
|
||||
|
||||
test('category toggles apply booleans and ignore anything else', () => {
|
||||
const current = {
|
||||
transit: true,
|
||||
local: false,
|
||||
regional: true,
|
||||
national: true,
|
||||
international: true,
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeRailCategories(
|
||||
{
|
||||
transit: false,
|
||||
local: 'true',
|
||||
regional: 0,
|
||||
national: null,
|
||||
international: true,
|
||||
bus: false,
|
||||
},
|
||||
current,
|
||||
),
|
||||
{
|
||||
transit: false,
|
||||
local: false,
|
||||
regional: true,
|
||||
national: true,
|
||||
international: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a share link can switch a hidden category back on', () => {
|
||||
const allOff = Object.fromEntries(RAIL_CATEGORIES.map((key) => [key, false]));
|
||||
assert.deepEqual(normalizeRailCategories({ regional: true }, allOff), {
|
||||
...allOff,
|
||||
regional: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('train keys are namespaced so they never collide with Transit keys', () => {
|
||||
assert.equal(railTrainKey('mbta-rail', '1712'), 'rail:mbta-rail:1712');
|
||||
assert.notEqual(
|
||||
railTrainKey('mbta-rail', '1712'),
|
||||
railTrainKey('mbta-rail', '1713'),
|
||||
);
|
||||
assert.notEqual(
|
||||
railTrainKey('mta-lirr', '7'),
|
||||
railTrainKey('mta-metro-north', '7'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a stamped report is aged from its own time', () => {
|
||||
assert.equal(trainFixAgeMs(stampedTrain, NOW), 30_000);
|
||||
// The fetch time does not override the train's own stamp.
|
||||
assert.equal(trainFixAgeMs(stampedTrain, NOW, NOW - 1_000), 30_000);
|
||||
assert.equal(trainReportTimeMs(stampedTrain, NOW - 1_000), NOW - 30_000);
|
||||
});
|
||||
|
||||
test('a stamp ahead of now ages to zero, never below', () => {
|
||||
assert.equal(trainFixAgeMs({ timestamp: (NOW + 5_000) / 1000 }, NOW), 0);
|
||||
});
|
||||
|
||||
test('an unstamped report is aged from the snapshot fetch time', () => {
|
||||
assert.equal(trainFixAgeMs(unstampedMetro, NOW, NOW - 45_000), 45_000);
|
||||
assert.equal(trainReportTimeMs(unstampedMetro, NOW - 45_000), NOW - 45_000);
|
||||
// Zero and negative stamps count as no stamp.
|
||||
assert.equal(trainFixAgeMs({ timestamp: 0 }, NOW, NOW - 2_000), 2_000);
|
||||
assert.equal(trainFixAgeMs({ timestamp: -1 }, NOW, NOW - 2_000), 2_000);
|
||||
// With neither a stamp nor a fetch time there is nothing to age from.
|
||||
assert.equal(trainFixAgeMs(unstampedMetro, NOW), 0);
|
||||
});
|
||||
|
||||
test('a report is stale only past the ten-minute limit', () => {
|
||||
assert.equal(TRAIN_MAX_FIX_AGE_MS, 10 * 60_000);
|
||||
const at = (ageMs) => ({ timestamp: (NOW - ageMs) / 1000 });
|
||||
assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS), NOW), false);
|
||||
assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS + 1_000), NOW), true);
|
||||
});
|
||||
|
||||
test('an unstamped report from an old snapshot is stale, not live', () => {
|
||||
const oldFetch = NOW - TRAIN_MAX_FIX_AGE_MS - 1;
|
||||
assert.equal(isStaleTrainFix(unstampedMetro, NOW, oldFetch), true);
|
||||
assert.equal(isStaleTrainFix(unstampedMetro, NOW, NOW - 60_000), false);
|
||||
});
|
||||
|
||||
test('rail modes map onto the Transit playback modes', () => {
|
||||
assert.equal(playbackModeFor('metro'), 'subway');
|
||||
assert.equal(playbackModeFor('tram'), 'tram');
|
||||
assert.equal(playbackModeFor('rail'), 'rail');
|
||||
assert.equal(playbackModeFor('monorail'), 'rail');
|
||||
assert.equal(playbackModeFor(undefined), 'rail');
|
||||
});
|
||||
|
||||
test('the card for a train with its own timestamp', () => {
|
||||
const { t, calls } = fakeT();
|
||||
const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t);
|
||||
assert.equal(copy.title, '🚆 T(Route CR-Worcester)');
|
||||
assert.deepEqual(copy.details, [
|
||||
'T(Local rail) · T(Train) · MBTA · Boston, MA',
|
||||
'T(Reported 30 s ago)',
|
||||
'T(reported 72 km/h) · T(reported hdg 90°)',
|
||||
'T(Last report: next stop place-sstat)',
|
||||
'T(Vehicle 1712) · Train 1712',
|
||||
]);
|
||||
// Operator names and regions are data, never sent through t().
|
||||
assert.ok(calls.every(({ text }) => !text.includes('MBTA')));
|
||||
});
|
||||
|
||||
test('the card for an unstamped metro train says the time is the feed’s', () => {
|
||||
const { t } = fakeT();
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
unstampedMetro,
|
||||
NOW,
|
||||
NOW - 120_000,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
assert.equal(copy.title, '🚆 T(Vehicle 77)');
|
||||
assert.deepEqual(copy.details, [
|
||||
'T(Urban rail) · T(Subway) · MBTA · Boston, MA',
|
||||
'T(T(Reported 2 min ago) (feed time))',
|
||||
'T(Shown at street level · depth not in the feed)',
|
||||
'T(Vehicle 77)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a header-stamped report is feed time even with a timestamp', () => {
|
||||
const { t } = fakeT();
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...stampedTrain, timestampSource: 'header' },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
assert.equal(copy.details[1], 'T(T(Reported 30 s ago) (feed time))');
|
||||
});
|
||||
|
||||
test('only metro trains carry the street-level note', () => {
|
||||
const { t } = fakeT();
|
||||
const note = 'T(Shown at street level · depth not in the feed)';
|
||||
for (const mode of ['rail', 'tram']) {
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...unstampedMetro, mode },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
assert.equal(copy.details.includes(note), false, mode);
|
||||
}
|
||||
const metro = buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...stampedTrain, mode: 'metro', category: 'transit' },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
assert.equal(metro.details.includes(note), true);
|
||||
});
|
||||
|
||||
test('playback state adds the motion word and the display delay', () => {
|
||||
const { t, calls } = fakeT();
|
||||
const entry = {
|
||||
sample: {
|
||||
latestReportAgeMs: 5_000,
|
||||
actualDelayMs: 42_400,
|
||||
phase: 'playing',
|
||||
motion: 'stopped',
|
||||
},
|
||||
};
|
||||
const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, entry, t);
|
||||
assert.equal(copy.details[1], 'T(Stopped)');
|
||||
assert.deepEqual(calls.find(({ text }) => text === 'Stopped').values, {
|
||||
context: 'vehicle motion',
|
||||
});
|
||||
assert.equal(copy.details[2], 'T(Reported 5 s ago) · T(shown 42 s behind)');
|
||||
|
||||
const moving = buildRailSelectionCopy(
|
||||
feed,
|
||||
stampedTrain,
|
||||
NOW,
|
||||
NOW,
|
||||
{ sample: { ...entry.sample, motion: 'moving' } },
|
||||
t,
|
||||
);
|
||||
assert.equal(moving.details[1], 'T(Moving)');
|
||||
});
|
||||
|
||||
test('without a t() the card reads in plain English', () => {
|
||||
const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW);
|
||||
assert.equal(copy.title, '🚆 Route CR-Worcester');
|
||||
assert.equal(copy.details[0], 'Local rail · Train · MBTA · Boston, MA');
|
||||
assert.equal(copy.details[1], 'Reported 30 s ago');
|
||||
});
|
||||
|
||||
test('the selected card is accented in the category colour', () => {
|
||||
const copy = { title: 'Route 550', details: ['Local rail'] };
|
||||
const position = () => ({ x: 1, y: 2, z: 3 });
|
||||
for (const category of RAIL_CATEGORIES) {
|
||||
const card = createRailSelectedOverlayEntry(
|
||||
'rail:capmetro-rail:101',
|
||||
position,
|
||||
copy,
|
||||
category,
|
||||
);
|
||||
assert.equal(card.accent, RAIL_CATEGORY_COLORS[category], category);
|
||||
}
|
||||
const card = createRailSelectedOverlayEntry(
|
||||
'rail:capmetro-rail:101',
|
||||
position,
|
||||
copy,
|
||||
'bus',
|
||||
);
|
||||
assert.equal(card.accent, RAIL_CATEGORY_COLORS.local);
|
||||
assert.equal(card.id, 'rail:capmetro-rail:101');
|
||||
assert.equal(card.position, position);
|
||||
assert.equal(card.title, 'Route 550');
|
||||
assert.deepEqual(card.details, ['Local rail']);
|
||||
assert.equal(card.selected, true);
|
||||
});
|
||||
|
||||
test('no card without a key or a position', () => {
|
||||
const copy = { title: '', details: [] };
|
||||
assert.equal(
|
||||
createRailSelectedOverlayEntry('', () => null, copy),
|
||||
null,
|
||||
);
|
||||
assert.equal(createRailSelectedOverlayEntry('rail:a:1', null, copy), null);
|
||||
});
|
||||
|
||||
/** Two stops as the schedule pack's tripStops() lists them. */
|
||||
const tripStopsNearYonkers = Object.freeze([
|
||||
Object.freeze({
|
||||
stopId: 'riv',
|
||||
name: 'Riverdale',
|
||||
lat: 40.9,
|
||||
lon: -73.91,
|
||||
state: 'passed',
|
||||
expectedArrival: '14:23',
|
||||
expectedDeparture: '14:24',
|
||||
arrivalDelaySec: 180,
|
||||
departureDelaySec: 180,
|
||||
relative: 'T+3',
|
||||
}),
|
||||
Object.freeze({
|
||||
stopId: 'yon',
|
||||
name: 'Yonkers',
|
||||
lat: 40.93,
|
||||
lon: -73.9,
|
||||
state: 'next',
|
||||
atStop: false,
|
||||
expectedArrival: '14:32',
|
||||
expectedDeparture: '14:33',
|
||||
arrivalDelaySec: 200,
|
||||
departureDelaySec: 200,
|
||||
relative: 'T−4',
|
||||
}),
|
||||
]);
|
||||
|
||||
test('with the trip’s stops the card names the next and previous stops', () => {
|
||||
const { t } = fakeT();
|
||||
const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t, {
|
||||
stops: tripStopsNearYonkers,
|
||||
headsign: 'Poughkeepsie',
|
||||
});
|
||||
assert.equal(copy.title, '🚆 T(Route CR-Worcester) · T(To Poughkeepsie)');
|
||||
assert.deepEqual(copy.details, [
|
||||
'T(Local rail) · T(Train) · MBTA · Boston, MA',
|
||||
'T(Next Yonkers 14:32 T−4) T((+3 min late))',
|
||||
'T(Prev Riverdale 14:24 T+3)',
|
||||
'T(Reported 30 s ago)',
|
||||
'T(reported 72 km/h) · T(reported hdg 90°)',
|
||||
'T(Vehicle 1712) · Train 1712',
|
||||
]);
|
||||
// The feed's bare stop id is not repeated beside the stop names.
|
||||
assert.ok(copy.details.every((line) => !line.includes('place-sstat')));
|
||||
});
|
||||
|
||||
test('an on-time next stop has no lateness, and English reads plainly', () => {
|
||||
const onTime = tripStopsNearYonkers.map((stop) => ({
|
||||
...stop,
|
||||
arrivalDelaySec: 20,
|
||||
}));
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
stampedTrain,
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
undefined,
|
||||
{
|
||||
stops: onTime,
|
||||
},
|
||||
);
|
||||
assert.equal(copy.details[1], 'Next Yonkers 14:32 T−4');
|
||||
assert.equal(copy.details[2], 'Prev Riverdale 14:24 T+3');
|
||||
});
|
||||
|
||||
test('the card never grows past six lines', () => {
|
||||
const { t } = fakeT();
|
||||
const entry = {
|
||||
sample: {
|
||||
latestReportAgeMs: 5_000,
|
||||
actualDelayMs: 30_000,
|
||||
phase: 'playing',
|
||||
motion: 'moving',
|
||||
},
|
||||
};
|
||||
const busy = { ...stampedTrain, mode: 'metro', positionSource: 'gps' };
|
||||
const copy = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t, {
|
||||
stops: tripStopsNearYonkers,
|
||||
});
|
||||
assert.equal(copy.details.length, RAIL_CARD_MAX_LINES);
|
||||
assert.equal(RAIL_CARD_MAX_LINES, 6);
|
||||
// What stays: who, motion, next, previous, report age and the depth note;
|
||||
// the reported speed and the vehicle line give way first.
|
||||
assert.deepEqual(copy.details, [
|
||||
'T(Local rail) · T(Subway) · MBTA · Boston, MA',
|
||||
'T(Moving) · T(Live GPS)',
|
||||
'T(Next Yonkers 14:32 T−4) T((+3 min late))',
|
||||
'T(Prev Riverdale 14:24 T+3)',
|
||||
'T(Reported 5 s ago) · T(shown 30 s behind)',
|
||||
'T(Shown at street level · depth not in the feed)',
|
||||
]);
|
||||
// Without stops a full card also stays within the limit.
|
||||
const noStops = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t);
|
||||
assert.ok(noStops.details.length <= RAIL_CARD_MAX_LINES);
|
||||
});
|
||||
|
||||
test('the card says where the position comes from', () => {
|
||||
const { t } = fakeT();
|
||||
const words = {
|
||||
gps: 'T(Live GPS)',
|
||||
event: 'T(Estimated from station reports)',
|
||||
};
|
||||
for (const [positionSource, word] of Object.entries(words)) {
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...stampedTrain, positionSource },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
// No playback state: the source stands on its own line.
|
||||
assert.equal(copy.details[1], word, positionSource);
|
||||
}
|
||||
// A source the layer does not draw, a timetable's included, is never
|
||||
// named as one.
|
||||
for (const positionSource of ['psychic', 'schedule']) {
|
||||
const unknown = buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...stampedTrain, positionSource },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
);
|
||||
assert.equal(unknown.details[1], 'T(Reported 30 s ago)', positionSource);
|
||||
}
|
||||
});
|
||||
|
||||
test('only live positions are drawn: GPS and station reports', () => {
|
||||
assert.deepEqual([...RAIL_LIVE_POSITION_SOURCES], ['gps', 'event']);
|
||||
for (const source of ['gps', 'event', undefined, null])
|
||||
assert.equal(isLiveRailPositionSource(source), true, String(source));
|
||||
for (const source of ['schedule', 'psychic', ''])
|
||||
assert.equal(isLiveRailPositionSource(source), false, source);
|
||||
});
|
||||
|
||||
test('without a route the title prefers the headsign, then the label', () => {
|
||||
const { t } = fakeT();
|
||||
const lirr = { ...stampedTrain, routeId: null, id: '7259', label: '7259' };
|
||||
assert.equal(
|
||||
buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t).title,
|
||||
'🚆 T(Vehicle 7259)',
|
||||
);
|
||||
assert.equal(
|
||||
buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...lirr, label: 'Train 2715' },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
).title,
|
||||
'🚆 Train 2715',
|
||||
);
|
||||
assert.equal(
|
||||
buildRailSelectionCopy(
|
||||
feed,
|
||||
{ ...lirr, headsign: 'Ronkonkoma' },
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
t,
|
||||
).title,
|
||||
'🚆 T(To Ronkonkoma)',
|
||||
);
|
||||
assert.equal(
|
||||
buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t, {
|
||||
headsign: 'Babylon',
|
||||
stops: [],
|
||||
}).title,
|
||||
'🚆 T(To Babylon)',
|
||||
);
|
||||
assert.equal(
|
||||
buildRailSelectionCopy(feed, { ...lirr, label: null }, NOW, NOW, null, t)
|
||||
.title,
|
||||
'🚆 T(Vehicle 7259)',
|
||||
);
|
||||
});
|
||||
|
||||
test('the card names a platform stop by its station', () => {
|
||||
const stops = tripStopsNearYonkers.map((stop) => ({
|
||||
...stop,
|
||||
name: `${stop.name} Platform 2`,
|
||||
parentName: `${stop.name} Station`,
|
||||
}));
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
stampedTrain,
|
||||
NOW,
|
||||
NOW,
|
||||
null,
|
||||
undefined,
|
||||
{
|
||||
stops,
|
||||
},
|
||||
);
|
||||
assert.equal(copy.details[1], 'Next Yonkers Station 14:32 T−4 (+3 min late)');
|
||||
assert.equal(copy.details[2], 'Prev Riverdale Station 14:24 T+3');
|
||||
});
|
||||
@@ -0,0 +1,724 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
||||
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
||||
import {
|
||||
COLD_CELL_FLUSH_MS,
|
||||
COLD_CELL_RETRY_MS,
|
||||
TRACK_MAX_GRADE,
|
||||
TRACK_PRIOR_ABOVE_M,
|
||||
TRACK_PRIOR_BELOW_M,
|
||||
TRACK_SAMPLE_GRID,
|
||||
TRACK_SAMPLE_MAX_CAMERA_M,
|
||||
TRACK_SAMPLE_MAX_AGE_MS,
|
||||
TRACK_SAMPLE_MAX_DIST_KM,
|
||||
TRACK_SAMPLE_REFRESH_MS,
|
||||
TRACK_SAMPLE_RETRY_MS,
|
||||
TRACK_SAMPLES_PER_FLUSH,
|
||||
TRACK_SETTLE_MPS,
|
||||
TRACK_SNAP_M,
|
||||
COLD_CELLS_PER_FLUSH,
|
||||
FLOOR_CELLS_BURST,
|
||||
FLOOR_CELLS_PER_MINUTE,
|
||||
FLOOR_WARM_MAX_DIST_KM,
|
||||
MARKER_PIXEL_SIZE,
|
||||
RAIL_CATEGORY_COLORS,
|
||||
SELECTED_PIXEL_SIZE,
|
||||
UNSELECTED_TRAIN_ALPHA,
|
||||
} from './policy.js';
|
||||
import { placeOnPath } from './pathBinding.js';
|
||||
|
||||
const SPRITE_PX = 64;
|
||||
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
||||
const FULL_TINT = Cesium.Color.WHITE;
|
||||
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
||||
|
||||
/**
|
||||
* Draw one category/mode sprite: a filled disc in the category colour with a
|
||||
* dark rim and a white glyph for what runs on the track (a train front, a
|
||||
* tram with its pantograph, or the metro "M"). Drawn once per pair and reused.
|
||||
* @param {string} category
|
||||
* @param {string} mode
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
function drawSprite(category, mode) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = SPRITE_PX;
|
||||
canvas.height = SPRITE_PX;
|
||||
const g = canvas.getContext('2d');
|
||||
const c = SPRITE_PX / 2;
|
||||
g.beginPath();
|
||||
g.arc(c, c, c - 3, 0, Math.PI * 2);
|
||||
g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local;
|
||||
g.fill();
|
||||
g.lineWidth = 4;
|
||||
g.strokeStyle = 'rgba(10, 14, 20, 0.85)';
|
||||
g.stroke();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.strokeStyle = '#ffffff';
|
||||
if (mode === 'metro') {
|
||||
g.font = 'bold 34px sans-serif';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.fillText('M', c, c + 2);
|
||||
return canvas;
|
||||
}
|
||||
// Train front: body, windscreen cut out, two lamps.
|
||||
g.beginPath();
|
||||
g.roundRect(c - 13, c - 14, 26, 28, 6);
|
||||
g.fill();
|
||||
g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local;
|
||||
g.fillRect(c - 9, c - 9, 18, 9);
|
||||
g.beginPath();
|
||||
g.arc(c - 7, c + 7, 2.5, 0, Math.PI * 2);
|
||||
g.arc(c + 7, c + 7, 2.5, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
if (mode === 'tram') {
|
||||
g.lineWidth = 3;
|
||||
g.beginPath();
|
||||
g.moveTo(c - 6, c - 14);
|
||||
g.lineTo(c, c - 22);
|
||||
g.lineTo(c + 6, c - 14);
|
||||
g.stroke();
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers, ground height and the per-frame pass for one Rail layer.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRendering({ state, services, parts }) {
|
||||
const {
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} = services.render;
|
||||
const {
|
||||
cachedGroundFloor,
|
||||
warmGroundFloor,
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
neighborFloorM = null,
|
||||
coarseFloorCoord = null,
|
||||
} = services.ground;
|
||||
const sprites = new Map();
|
||||
const terrainPoint = new Cesium.Cartographic();
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
/** Scratch for the on-line position; place() runs every frame. */
|
||||
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
||||
|
||||
function spriteFor(category, mode) {
|
||||
const id = `${category}:${mode}`;
|
||||
if (!sprites.has(id)) sprites.set(id, drawSprite(category, mode));
|
||||
return sprites.get(id);
|
||||
}
|
||||
|
||||
/** Whether ground matters at this camera (Transit's gate, shared). */
|
||||
function nearGround() {
|
||||
const height = state._viewer?.camera?.positionCartographic?.height;
|
||||
return !Number.isFinite(height) || height <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
||||
}
|
||||
|
||||
/** Kilometres from the camera's subpoint, or Infinity without a camera. */
|
||||
function cameraKm(lat, lon) {
|
||||
const carto = state._viewer?.camera?.positionCartographic;
|
||||
if (!carto) return Infinity;
|
||||
const camLat = Cesium.Math.toDegrees(carto.latitude);
|
||||
const camLon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const kLon = Math.cos((camLat * Math.PI) / 180);
|
||||
return Math.hypot(lat - camLat, (lon - camLon) * kLon) * 111.32;
|
||||
}
|
||||
|
||||
/**
|
||||
* A number naming the cell of 1/`perDegree` degree that holds a point. A
|
||||
* string key would be built for every moving train on every frame.
|
||||
*/
|
||||
function cellKey(lat, lon, perDegree) {
|
||||
return (
|
||||
Math.round(lat * perDegree) * (362 * perDegree) +
|
||||
Math.round(lon * perDegree)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells rail may still ask the floor service for: refilled at
|
||||
* FLOOR_CELLS_PER_MINUTE, never holding more than FLOOR_CELLS_BURST.
|
||||
*/
|
||||
const floorBudget = { cells: FLOOR_CELLS_BURST, at: Date.now() };
|
||||
function takeFloorBudget(wanted) {
|
||||
const now = Date.now();
|
||||
floorBudget.cells = Math.min(
|
||||
FLOOR_CELLS_BURST,
|
||||
floorBudget.cells +
|
||||
((now - floorBudget.at) / 60_000) * FLOOR_CELLS_PER_MINUTE,
|
||||
);
|
||||
floorBudget.at = now;
|
||||
const granted = Math.max(
|
||||
0,
|
||||
Math.min(wanted, Math.floor(floorBudget.cells)),
|
||||
);
|
||||
floorBudget.cells -= granted;
|
||||
return granted;
|
||||
}
|
||||
/**
|
||||
* Floor cells a train (or a stop of the selected trip) reached while they
|
||||
* were cold, warmed in small batches off the frame path. `asked`
|
||||
* remembers when a cell was last requested, so a frame loop over a cold
|
||||
* cell does not re-request it.
|
||||
*/
|
||||
const coldCells = new Map();
|
||||
const asked = new Map();
|
||||
let coldTimer = null;
|
||||
// Track heights on photorealistic tiles, sampled from the rendered mesh at
|
||||
// points on the train's own line: the mesh under a draped line is the
|
||||
// track bed or the deck. A floor cell's height is taken at the cell's
|
||||
// centre, which can be a roof, a cutting's rim or a hillside well above
|
||||
// the rails, so it is only the fallback. Keyed by bucket of
|
||||
// 1/TRACK_SAMPLE_GRID degree; each sample keeps the camera height it was
|
||||
// read from (`camM`, above the floor), when (`at`), and when it was last
|
||||
// read again (`readAt`).
|
||||
const trackSamples = new Map();
|
||||
const trackQueue = new Map();
|
||||
const trackAsked = new Map();
|
||||
const trackProbe = new Cesium.Cartographic();
|
||||
function cameraCartographic() {
|
||||
return state._viewer?.camera?.positionCartographic ?? null;
|
||||
}
|
||||
/**
|
||||
* Whether the photorealistic tiles have finished streaming for this view:
|
||||
* there is a shown tileset, and every shown one reports its tiles loaded.
|
||||
*/
|
||||
function tilesLoaded(scene) {
|
||||
const primitives = scene.primitives;
|
||||
let found = false;
|
||||
for (let i = 0; i < (primitives?.length || 0); i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean')
|
||||
continue;
|
||||
if (!primitive.tilesLoaded) return false;
|
||||
found = true;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function queueTrackSample(lat, lon, key, priority) {
|
||||
const now = Date.now();
|
||||
if (now - (trackAsked.get(key) ?? -Infinity) < TRACK_SAMPLE_RETRY_MS)
|
||||
return;
|
||||
// The floor under it is the gross check against coarse tiles; with no
|
||||
// floor yet, wait (the cold-cell queue is warming it). From higher up,
|
||||
// the tiles near the camera are coarse.
|
||||
const prior = cachedGroundFloor(lat, lon);
|
||||
const height = cameraCartographic()?.height;
|
||||
if (
|
||||
!Number.isFinite(prior) ||
|
||||
!Number.isFinite(height) ||
|
||||
height - prior > TRACK_SAMPLE_MAX_CAMERA_M
|
||||
)
|
||||
return;
|
||||
if (trackAsked.size > 8192) trackAsked.clear();
|
||||
trackAsked.set(key, now);
|
||||
trackQueue.set(key, { lat, lon, priority });
|
||||
coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS);
|
||||
}
|
||||
function flushTrackSamples() {
|
||||
const scene = state._viewer?.scene;
|
||||
const carto = cameraCartographic();
|
||||
if (
|
||||
!trackQueue.size ||
|
||||
typeof scene?.sampleHeight !== 'function' ||
|
||||
!Number.isFinite(carto?.height) ||
|
||||
!tilesLoaded(scene)
|
||||
) {
|
||||
trackQueue.clear();
|
||||
return;
|
||||
}
|
||||
const camLat = Cesium.Math.toDegrees(carto.latitude);
|
||||
const camLon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const kLon = Math.cos((camLat * Math.PI) / 180);
|
||||
const excludeObjects = state._markers ? [state._markers] : [];
|
||||
const now = performance.now();
|
||||
// The selected train first, then the rest in the order they asked.
|
||||
const items = [...trackQueue].sort(
|
||||
(a, b) => Number(b[1].priority) - Number(a[1].priority),
|
||||
);
|
||||
trackQueue.clear();
|
||||
let sampled = 0;
|
||||
for (const [key, point] of items) {
|
||||
if (sampled >= TRACK_SAMPLES_PER_FLUSH) break;
|
||||
const dKm =
|
||||
Math.hypot(point.lat - camLat, (point.lon - camLon) * kLon) * 111.32;
|
||||
if (dKm > TRACK_SAMPLE_MAX_DIST_KM) continue;
|
||||
const prior = cachedGroundFloor(point.lat, point.lon);
|
||||
if (!Number.isFinite(prior)) continue;
|
||||
const camM = carto.height - prior;
|
||||
if (camM > TRACK_SAMPLE_MAX_CAMERA_M) continue;
|
||||
let height;
|
||||
try {
|
||||
sampled += 1;
|
||||
Cesium.Cartographic.fromDegrees(point.lon, point.lat, 0, trackProbe);
|
||||
height = scene.sampleHeight(trackProbe, excludeObjects);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const kept = trackSamples.get(key);
|
||||
if (kept) kept.readAt = now;
|
||||
if (
|
||||
!Number.isFinite(height) ||
|
||||
height < prior - TRACK_PRIOR_BELOW_M ||
|
||||
height > prior + TRACK_PRIOR_ABOVE_M
|
||||
)
|
||||
continue;
|
||||
// A reading from higher up saw coarser tiles: it replaces one read
|
||||
// from lower down only once that one is old.
|
||||
if (kept) {
|
||||
if (camM > kept.camM && now - kept.at < TRACK_SAMPLE_MAX_AGE_MS)
|
||||
continue;
|
||||
kept.h = height;
|
||||
kept.camM = camM;
|
||||
kept.at = now;
|
||||
} else trackSamples.set(key, { h: height, camM, at: now, readAt: now });
|
||||
}
|
||||
if (trackSamples.size > 50_000) trackSamples.clear();
|
||||
if (sampled) governorRequestRender('rail-track-height');
|
||||
}
|
||||
function flushColdCells() {
|
||||
coldTimer = null;
|
||||
const queued = [...coldCells.values()].sort(
|
||||
(a, b) => Number(b.priority) - Number(a.priority) || a.km - b.km,
|
||||
);
|
||||
coldCells.clear();
|
||||
const granted = takeFloorBudget(
|
||||
Math.min(queued.length, COLD_CELLS_PER_FLUSH),
|
||||
);
|
||||
// Cells left over are asked for again after COLD_CELL_RETRY_MS.
|
||||
if (granted) warmGroundFloor(queued.slice(0, granted));
|
||||
flushTrackSamples();
|
||||
}
|
||||
/**
|
||||
* Queue a cold floor cell for warming. Only cells within
|
||||
* FLOOR_WARM_MAX_DIST_KM of the camera are, unless `anyDistance` (the
|
||||
* selected trip's stops, which are drawn wherever the camera is).
|
||||
*/
|
||||
function queueColdCell(lat, lon, priority = false, anyDistance = false) {
|
||||
if (!nearGround()) return;
|
||||
const km = cameraKm(lat, lon);
|
||||
if (km > FLOOR_WARM_MAX_DIST_KM && !anyDistance) return;
|
||||
const key = cellKey(lat, lon, 1000);
|
||||
const now = Date.now();
|
||||
if (now - (asked.get(key) ?? -Infinity) < COLD_CELL_RETRY_MS) return;
|
||||
if (asked.size > 4096) asked.clear();
|
||||
asked.set(key, now);
|
||||
coldCells.set(key, { lat, lon, km, priority });
|
||||
coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Layer disabled: drop the queued work and the samples taken so far. */
|
||||
function reset() {
|
||||
clearTimeout(coldTimer);
|
||||
coldTimer = null;
|
||||
coldCells.clear();
|
||||
asked.clear();
|
||||
trackQueue.clear();
|
||||
trackAsked.clear();
|
||||
trackSamples.clear();
|
||||
}
|
||||
|
||||
/** floorHeight's answer, reused by every call: read it before the next. */
|
||||
const floorAnswer = { h: 0, q: 0 };
|
||||
function answer(h, q) {
|
||||
floorAnswer.h = h;
|
||||
floorAnswer.q = q;
|
||||
return floorAnswer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height to draw a train at: the rendered terrain under it on a terrain
|
||||
* globe, else the shared ground floor (which tracks the photorealistic mesh
|
||||
* where one is loaded). Where that cell is not warm yet, a neighbouring
|
||||
* cell's floor, else the floor this train last stood on, while the cell is
|
||||
* warmed: a train running onto unsampled ground keeps its height instead of
|
||||
* dropping to the ellipsoid, which lies tens to hundreds of metres under
|
||||
* the photorealistic mesh (about 150 m around Chicago). Only a train with
|
||||
* no floor yet, or a stop, stands on the ellipsoid, until its cell is warm.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {{floorM?: number, key?: string}|null} [entry] the train, which
|
||||
* keeps its floor
|
||||
* @param {boolean} [stop] a stop of the selected trip: its cell is warmed
|
||||
* first, however far from the camera
|
||||
* @returns {{h: number, q: number}} The height, and how good it is: 3 the
|
||||
* rendered terrain, 2 the cell's floor, 1 a neighbouring cell's or the
|
||||
* train's last floor, 0 the ellipsoid. The same object every call.
|
||||
*/
|
||||
/** The last coarse cell asked for a stop (stops have no entry). */
|
||||
const stopCell = { kLat: NaN, kLon: NaN, cell: null };
|
||||
|
||||
/**
|
||||
* The point's coarse floor cell, kept on the train (and once for stops)
|
||||
* while the point stays in the same 0.001° cell: a train gliding across a
|
||||
* cold cell asks the floor service for it once, not every frame.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {object|null} entry
|
||||
* @returns {{lat: number, lon: number}}
|
||||
*/
|
||||
function coarseCellOf(lat, lon, entry) {
|
||||
const memo = entry
|
||||
? (entry.coarseCell ??= { kLat: NaN, kLon: NaN, cell: null })
|
||||
: stopCell;
|
||||
const kLat = Math.round(lat * 1000);
|
||||
const kLon = Math.round(lon * 1000);
|
||||
if (memo.kLat !== kLat || memo.kLon !== kLon) {
|
||||
memo.kLat = kLat;
|
||||
memo.kLon = kLon;
|
||||
memo.cell = coarseFloorCoord(lat, lon);
|
||||
}
|
||||
return memo.cell;
|
||||
}
|
||||
|
||||
function floorHeight(lat, lon, entry = null, stop = false) {
|
||||
const globe = state._viewer?.scene?.globe;
|
||||
if (globe?.show === true) {
|
||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
||||
const rendered = globe.getHeight?.(terrainPoint);
|
||||
if (Number.isFinite(rendered))
|
||||
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
||||
}
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
if (Number.isFinite(floor)) {
|
||||
if (entry) entry.floorM = floor;
|
||||
return answer(floor + GROUND_FLOOR_LIFT_M, 2);
|
||||
}
|
||||
queueColdCell(
|
||||
lat,
|
||||
lon,
|
||||
stop || (entry !== null && entry.key === state._selectedKey),
|
||||
stop,
|
||||
);
|
||||
const near =
|
||||
neighborFloorM && coarseFloorCoord
|
||||
? neighborFloorM(coarseCellOf(lat, lon, entry))
|
||||
: null;
|
||||
if (Number.isFinite(near)) return answer(near + GROUND_FLOOR_LIFT_M, 1);
|
||||
return Number.isFinite(entry?.floorM)
|
||||
? answer(entry.floorM + GROUND_FLOOR_LIFT_M, 1)
|
||||
: answer(0, 0);
|
||||
}
|
||||
function heightFor(lat, lon, entry = null) {
|
||||
return floorHeight(lat, lon, entry).h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a stop of the selected trip stands, and how good that height is
|
||||
* (floorHeight's quality): a stop read before its cell was warm is read
|
||||
* again until it stands on a real floor (routeDisplay.js).
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {{h: number, q: number}}
|
||||
*/
|
||||
function stopHeight(lat, lon) {
|
||||
const { h, q } = floorHeight(lat, lon, null, true);
|
||||
return { h, q };
|
||||
}
|
||||
|
||||
/**
|
||||
* The height a train is drawn at. Its target is the track sample under it
|
||||
* on photorealistic tiles (else the floor chain of heightFor), and the
|
||||
* drawn height moves toward it no faster than a railway climbs:
|
||||
* TRACK_MAX_GRADE of the distance moved, plus TRACK_SETTLE_MPS over time.
|
||||
* A single high sample (a roof, an overpass) is a bump, cell steps are
|
||||
* ramps, and a followed camera rides smoothly. The drawn height snaps when
|
||||
* a better source replaces a fallback (quality 0-1 to 2-3, or any floor
|
||||
* replacing the ellipsoid, 0 to 1), and after a jump of more than
|
||||
* TRACK_SNAP_M.
|
||||
* @param {object} entry The train.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number}
|
||||
*/
|
||||
function displayHeightFor(entry, lat, lon) {
|
||||
const globe = state._viewer?.scene?.globe;
|
||||
const now = performance.now();
|
||||
let targetH = NaN;
|
||||
let targetQ = 0;
|
||||
if (globe?.show !== true && nearGround()) {
|
||||
const key = cellKey(lat, lon, TRACK_SAMPLE_GRID);
|
||||
const sampled = trackSamples.get(key);
|
||||
if (sampled) {
|
||||
targetH = sampled.h + GROUND_FLOOR_LIFT_M;
|
||||
targetQ = 3;
|
||||
}
|
||||
// A sample is read again now and then: from a lower camera, it
|
||||
// replaces one read from coarser tiles.
|
||||
if (!sampled || now - sampled.readAt >= TRACK_SAMPLE_REFRESH_MS)
|
||||
queueTrackSample(lat, lon, key, entry.key === state._selectedKey);
|
||||
}
|
||||
if (!Number.isFinite(targetH)) {
|
||||
const floor = floorHeight(lat, lon, entry);
|
||||
targetH = floor.h;
|
||||
targetQ = floor.q;
|
||||
}
|
||||
const prev = entry.drawnHeight;
|
||||
let h = targetH;
|
||||
if (prev && Number.isFinite(prev.h)) {
|
||||
const kLon = Math.cos((lat * Math.PI) / 180);
|
||||
const moved =
|
||||
Math.hypot(lat - prev.lat, (lon - prev.lon) * kLon) * 111_320;
|
||||
const better =
|
||||
(prev.q === 0 && targetQ > 0) || (prev.q <= 1 && targetQ >= 2);
|
||||
if (moved <= TRACK_SNAP_M && !better) {
|
||||
const dt = Math.max(0, (now - prev.at) / 1000);
|
||||
const step = TRACK_MAX_GRADE * moved + TRACK_SETTLE_MPS * dt;
|
||||
h = prev.h + Math.max(-step, Math.min(step, targetH - prev.h));
|
||||
}
|
||||
}
|
||||
// Written in place: this runs for every moving train on every frame.
|
||||
if (prev) {
|
||||
prev.h = h;
|
||||
prev.q = targetQ;
|
||||
prev.lat = lat;
|
||||
prev.lon = lon;
|
||||
prev.at = now;
|
||||
} else entry.drawnHeight = { h, q: targetQ, lat, lon, at: now };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Ask the floor service for the cells under one feed's trains; off the frame path. */
|
||||
function warmFloors(feedId) {
|
||||
for (const entry of state._trains.values()) {
|
||||
if (entry.feedId !== feedId) continue;
|
||||
// A parked train's height was read when it was placed; floors warmed
|
||||
// since (or finer terrain tiles) land on it here, once per poll. Moving
|
||||
// trains re-read their height every frame already.
|
||||
if (entry.marker && !state._moving.has(entry)) place(entry);
|
||||
const { lat, lon } = entry.record;
|
||||
if (!Number.isFinite(cachedGroundFloor(lat, lon)))
|
||||
queueColdCell(lat, lon, entry.key === state._selectedKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a train is drawn: its category is on, and it is not a copy of a
|
||||
* train another feed draws better (dedupe.js).
|
||||
*/
|
||||
function categoryShown(entry) {
|
||||
return (
|
||||
state._categories[entry.category] !== false && entry.duplicate !== true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A train bound to its railway line is drawn on the line, at the arc its
|
||||
* playback has reached; any other train (or a bound train whose reports do
|
||||
* not fit the line) is drawn on the time-interpolated straight-line point.
|
||||
*/
|
||||
function place(entry) {
|
||||
const sample = entry.sample;
|
||||
const onPath =
|
||||
entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint);
|
||||
const lat = onPath
|
||||
? pathPoint.lat
|
||||
: Number.isFinite(sample?.lat)
|
||||
? sample.lat
|
||||
: entry.record.lat;
|
||||
const lon = onPath
|
||||
? pathPoint.lon
|
||||
: Number.isFinite(sample?.lon)
|
||||
? sample.lon
|
||||
: entry.record.lon;
|
||||
// Write into scratch, never into `marker.position`: the getter returns the
|
||||
// billboard's own vector, so filling it in place makes the setter see an
|
||||
// unchanged position and skip the update that actually moves the sprite.
|
||||
entry.marker.position = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
displayHeightFor(entry, lat, lon),
|
||||
undefined,
|
||||
scratchPosition,
|
||||
);
|
||||
entry.marker.show = categoryShown(entry);
|
||||
}
|
||||
|
||||
/** While a train is selected, every other train is drawn see-through. */
|
||||
function tintFor(entry) {
|
||||
return state._selectedKey !== null && entry.key !== state._selectedKey
|
||||
? DIM_TINT
|
||||
: FULL_TINT;
|
||||
}
|
||||
|
||||
function paint(entry) {
|
||||
if (!entry.marker) return;
|
||||
const selected = entry.key === state._selectedKey;
|
||||
const size = selected ? SELECTED_PIXEL_SIZE : MARKER_PIXEL_SIZE;
|
||||
entry.marker.image = spriteFor(entry.category, entry.railMode);
|
||||
entry.marker.width = size;
|
||||
entry.marker.height = size;
|
||||
entry.marker.color = tintFor(entry);
|
||||
entry.marker.show = categoryShown(entry);
|
||||
}
|
||||
|
||||
/** Re-tint every train after the selection appeared, moved or cleared. */
|
||||
function paintAll() {
|
||||
for (const entry of state._trains.values()) paint(entry);
|
||||
}
|
||||
|
||||
function addMarker(entry) {
|
||||
const marker = state._markers.add({
|
||||
id: entry.key,
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
image: spriteFor(entry.category, entry.railMode),
|
||||
width: MARKER_PIXEL_SIZE,
|
||||
height: MARKER_PIXEL_SIZE,
|
||||
color: tintFor(entry),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
entry.marker = marker;
|
||||
place(entry);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function cancelWake(entry) {
|
||||
if (entry?.wakeTimer) clearTimeout(entry.wakeTimer);
|
||||
if (entry) entry.wakeTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track whether a train is mid-glide, so the frame pass walks only those. A
|
||||
* train parked at a fix whose next segment starts later is woken by a timer
|
||||
* at that boundary, not by polling every frame.
|
||||
*/
|
||||
function schedulePlayback(entry) {
|
||||
cancelWake(entry);
|
||||
if (!entry.marker) return;
|
||||
place(entry);
|
||||
const sample = entry.sample;
|
||||
const moving =
|
||||
categoryShown(entry) &&
|
||||
sample?.phase === 'playing' &&
|
||||
sample.segmentSpeedMps > 0;
|
||||
if (moving) {
|
||||
state._moving.add(entry);
|
||||
return;
|
||||
}
|
||||
state._moving.delete(entry);
|
||||
if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) {
|
||||
entry.wakeTimer = setTimeout(
|
||||
() => {
|
||||
entry.wakeTimer = null;
|
||||
if (!state._enabled || !state._trains.has(entry.key)) return;
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
schedulePlayback(entry);
|
||||
syncRenderHold();
|
||||
governorRequestRender('rail-motion-boundary');
|
||||
},
|
||||
Math.max(1, sample.nextWakeMonoMs - performance.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncRenderHold() {
|
||||
const working = state._enabled && state._moving.size > 0;
|
||||
if (working && !state._renderHeld) {
|
||||
holdContinuousRender('rail');
|
||||
state._renderHeld = true;
|
||||
} else if (!working && state._renderHeld) {
|
||||
releaseContinuousRender('rail');
|
||||
state._renderHeld = false;
|
||||
governorRequestRender('rail-settled');
|
||||
}
|
||||
}
|
||||
|
||||
const horizon = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
const surfaceScratch = new Cesium.Cartesian3();
|
||||
|
||||
/** Whether a point on the Earth is on the camera's side of it. */
|
||||
function facesCamera(position) {
|
||||
const viewer = state._viewer;
|
||||
const cameraPosition = viewer?.camera?.positionWC;
|
||||
if (
|
||||
!position ||
|
||||
!cameraPosition ||
|
||||
viewer.scene?.mode !== Cesium.SceneMode.SCENE3D
|
||||
)
|
||||
return true;
|
||||
const surface = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
|
||||
position,
|
||||
surfaceScratch,
|
||||
);
|
||||
if (!surface) return true;
|
||||
horizon.cameraPosition = cameraPosition;
|
||||
return horizon.isPointVisible(surface);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers are drawn over the terrain, never behind it, and the selected
|
||||
* train is drawn at any distance (viewport.js): from far out, the globe
|
||||
* would not hide it on its far side, so this pass does, with its stops.
|
||||
*/
|
||||
function cullSelected() {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
if (!entry?.marker) return;
|
||||
const shown = categoryShown(entry) && facesCamera(entry.marker.position);
|
||||
if (entry.marker.show !== shown) entry.marker.show = shown;
|
||||
parts.routes.cullStops(facesCamera);
|
||||
}
|
||||
|
||||
function onPreRender() {
|
||||
advanceMoving();
|
||||
if (state._enabled && state._selectedKey !== null) cullSelected();
|
||||
}
|
||||
|
||||
function advanceMoving() {
|
||||
if (!state._enabled || state._moving.size === 0) {
|
||||
syncRenderHold();
|
||||
return;
|
||||
}
|
||||
if (globalThis.document?.visibilityState === 'hidden') return;
|
||||
const now = Date.now();
|
||||
const monoNow = performance.now();
|
||||
for (const entry of state._moving) {
|
||||
if (!entry.marker) {
|
||||
state._moving.delete(entry);
|
||||
continue;
|
||||
}
|
||||
updatePlayback(entry, now, monoNow);
|
||||
applyDisplayCourse(entry, monoNow);
|
||||
place(entry);
|
||||
if (
|
||||
entry.sample.phase !== 'playing' ||
|
||||
entry.sample.segmentSpeedMps === 0
|
||||
)
|
||||
schedulePlayback(entry);
|
||||
}
|
||||
syncRenderHold();
|
||||
}
|
||||
|
||||
/** Re-apply category visibility to every marker after a toggle. */
|
||||
function applyCategoryVisibility() {
|
||||
for (const entry of state._trains.values()) {
|
||||
if (!entry.marker) continue;
|
||||
entry.marker.show = categoryShown(entry);
|
||||
schedulePlayback(entry);
|
||||
}
|
||||
syncRenderHold();
|
||||
parts.routes.applyNetwork();
|
||||
governorRequestRender('rail-categories');
|
||||
}
|
||||
|
||||
return {
|
||||
addMarker,
|
||||
paint,
|
||||
paintAll,
|
||||
heightFor,
|
||||
stopHeight,
|
||||
displayHeightFor,
|
||||
cancelWake,
|
||||
schedulePlayback,
|
||||
syncRenderHold,
|
||||
onPreRender,
|
||||
warmFloors,
|
||||
applyCategoryVisibility,
|
||||
categoryShown,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { classifyTrain, getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import {
|
||||
FEED_RELEASE_AFTER_MS,
|
||||
NETWORK_ALPHA,
|
||||
NETWORK_FULL_BELOW_M,
|
||||
NETWORK_HIDDEN_ABOVE_M,
|
||||
NETWORK_SELECTED_DIM,
|
||||
NETWORK_WIDTH_PX,
|
||||
RAIL_CATEGORY_COLORS,
|
||||
ROUTE_AHEAD_WIDTH_PX,
|
||||
ROUTE_SPLIT_INTERVAL_MS,
|
||||
ROUTE_TRAVELLED_ALPHA,
|
||||
STOP_REFRESH_MS,
|
||||
} from './policy.js';
|
||||
import {
|
||||
networkAltitudeFade,
|
||||
railNetworkLineSteps,
|
||||
splitFraction,
|
||||
} from './routeLines.js';
|
||||
import {
|
||||
RAIL_STOP_OVERLAY_SOURCE_ID,
|
||||
RAIL_STOP_OVERLAY_SOURCE_OPTIONS,
|
||||
createStopOverlayEntries,
|
||||
selectStopLabels,
|
||||
} from './stopLabels.js';
|
||||
|
||||
/** Longest slice of network building per task, so a frame is never held (ms). */
|
||||
const NETWORK_SLICE_MS = 6;
|
||||
/** The fade is applied in steps, so a slow zoom does not rewrite it per event. */
|
||||
const FADE_STEP = 0.05;
|
||||
/** A split that moved less than this share of the line is not redrawn. */
|
||||
const SPLIT_EPSILON = 1e-5;
|
||||
/** Arc a route-bound train must cover before its direction is trusted (m). */
|
||||
const DIRECTION_EVIDENCE_M = 60;
|
||||
const STOP_DOT_PX = 5;
|
||||
const STOP_LABELLED_DOT_PX = 8;
|
||||
const STOP_DOT_OUTLINE = Cesium.Color.fromCssColorString(
|
||||
'rgba(10, 14, 20, 0.9)',
|
||||
);
|
||||
|
||||
/**
|
||||
* The selected line's material: solid ahead of the train, dimmer behind.
|
||||
*/
|
||||
function createSplitMaterial() {
|
||||
return new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'RailRouteSplit',
|
||||
uniforms: {
|
||||
aheadColor: new Cesium.Color(1, 1, 1, 1),
|
||||
travelledColor: new Cesium.Color(1, 1, 1, ROUTE_TRAVELLED_ALPHA),
|
||||
splitAt: -1,
|
||||
direction: 1,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
float s = materialInput.st.s;
|
||||
bool travelled = direction > 0.0 ? s < splitAt : s > splitAt;
|
||||
vec4 c = travelled ? travelledColor : aheadColor;
|
||||
// A dark rim keeps the line legible over bright imagery.
|
||||
bool rim = abs(materialInput.st.t - 0.5) > 0.36;
|
||||
m.diffuse = czm_gammaCorrect(rim ? c.rgb * 0.22 : c.rgb);
|
||||
m.alpha = c.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route lines for one Rail layer: the faint network of every active feed
|
||||
* (built once per path pack), the selected train's line split where the train
|
||||
* is, and the selected trip's stops as dots and labels.
|
||||
*
|
||||
* Nothing here runs per frame. The network's opacity follows the camera on
|
||||
* camera events; the split and the stops follow the train on a timer that
|
||||
* only runs while a train is selected.
|
||||
*
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRouteDisplay({
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
stops,
|
||||
timetable,
|
||||
}) {
|
||||
const { governorRequestRender } = services.render;
|
||||
let groundSupported = null;
|
||||
/** feedId → when the feed was last seen in use (ms). */
|
||||
const feedSeenAt = new Map();
|
||||
/** Releases idle feeds while the layer is off. */
|
||||
let releaseTimer = null;
|
||||
let stopPoints = null;
|
||||
let timer = null;
|
||||
let ticks = 0;
|
||||
|
||||
function scene() {
|
||||
return state._viewer?.scene || null;
|
||||
}
|
||||
|
||||
function useGround() {
|
||||
const s = scene();
|
||||
if (!s) return false;
|
||||
if (groundSupported === null)
|
||||
groundSupported =
|
||||
!!s.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(s);
|
||||
return groundSupported;
|
||||
}
|
||||
|
||||
/** Positions for a flat `[lat, lon, ...]` run, with consecutive repeats dropped. */
|
||||
function positionsOf(coords) {
|
||||
const out = [];
|
||||
for (let i = 0; i + 1 < coords.length; i += 2) {
|
||||
const point = Cesium.Cartesian3.fromDegrees(coords[i + 1], coords[i], 0);
|
||||
if (
|
||||
out.length &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
out[out.length - 1],
|
||||
point,
|
||||
Cesium.Math.EPSILON7,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
out.push(point);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One batched primitive of polylines, ground-clamped where the GPU allows. */
|
||||
function linePrimitive(runs, width, material) {
|
||||
const ground = useGround();
|
||||
const instances = [];
|
||||
for (const positions of runs) {
|
||||
if (positions.length < 2) continue;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!instances.length) return null;
|
||||
const appearance = new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
});
|
||||
const primitive = ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
// Off the terrain, a line under a hill still shows through it.
|
||||
depthFailAppearance: new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
});
|
||||
return {
|
||||
primitive,
|
||||
collection: ground ? scene().groundPrimitives : scene().primitives,
|
||||
ground,
|
||||
};
|
||||
}
|
||||
|
||||
function removePrimitive(slot) {
|
||||
if (!slot?.primitive) return;
|
||||
slot.collection?.remove(slot.primitive);
|
||||
slot.primitive = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network
|
||||
|
||||
/**
|
||||
* Start building a feed's network from its decoded pack, in slices between
|
||||
* frames. Called once per pack by ingestion.
|
||||
* @param {string} feedId
|
||||
* @param {object} raw The pack's JSON body.
|
||||
*/
|
||||
function buildNetwork(feedId, raw) {
|
||||
const previous = state._networks.get(feedId);
|
||||
if (previous) dropNetwork(feedId);
|
||||
const feed = getRegisteredRailFeed(feedId);
|
||||
const categoryOf = (routeId) =>
|
||||
feed ? classifyTrain(feed, routeId)?.category || null : null;
|
||||
const network = {
|
||||
feedId,
|
||||
status: 'building',
|
||||
steps: railNetworkLineSteps(raw, categoryOf),
|
||||
timer: null,
|
||||
layers: new Map(),
|
||||
stats: null,
|
||||
};
|
||||
state._networks.set(feedId, network);
|
||||
const slice = () => {
|
||||
network.timer = null;
|
||||
if (state._networks.get(feedId) !== network) return;
|
||||
const started = performance.now();
|
||||
for (;;) {
|
||||
const next = network.steps.next();
|
||||
if (next.done) {
|
||||
network.steps = null;
|
||||
finishNetwork(network, next.value);
|
||||
return;
|
||||
}
|
||||
if (performance.now() - started > NETWORK_SLICE_MS) break;
|
||||
}
|
||||
network.timer = setTimeout(slice, 0);
|
||||
};
|
||||
network.timer = setTimeout(slice, 0);
|
||||
}
|
||||
|
||||
function finishNetwork(network, result) {
|
||||
network.status = 'ready';
|
||||
network.stats = {
|
||||
shapes: result.shapesIn,
|
||||
pointsIn: result.pointsIn,
|
||||
lines: result.lines.length,
|
||||
points: result.pointsOut,
|
||||
};
|
||||
if (!scene()) return;
|
||||
const byCategory = new Map();
|
||||
for (const line of result.lines) {
|
||||
if (!byCategory.has(line.category)) byCategory.set(line.category, []);
|
||||
byCategory.get(line.category).push(positionsOf(line.coords));
|
||||
}
|
||||
for (const [category, runs] of byCategory) {
|
||||
const color = Cesium.Color.fromCssColorString(
|
||||
RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local,
|
||||
);
|
||||
const material = Cesium.Material.fromType('Color', {
|
||||
color: color.withAlpha(0),
|
||||
});
|
||||
const slot = linePrimitive(runs, NETWORK_WIDTH_PX, material);
|
||||
if (!slot) continue;
|
||||
slot.primitive.show = false;
|
||||
slot.collection.add(slot.primitive);
|
||||
network.layers.set(category, {
|
||||
...slot,
|
||||
material,
|
||||
base: color,
|
||||
alpha: -1,
|
||||
});
|
||||
}
|
||||
applyNetwork();
|
||||
}
|
||||
|
||||
function dropNetwork(feedId) {
|
||||
const network = state._networks.get(feedId);
|
||||
if (!network) return;
|
||||
clearTimeout(network.timer);
|
||||
network.timer = null;
|
||||
network.steps = null;
|
||||
for (const layer of network.layers.values()) removePrimitive(layer);
|
||||
network.layers.clear();
|
||||
state._networks.delete(feedId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show each feed's network while the feed is active, the category is on and
|
||||
* the camera is low enough, at an opacity set by altitude and selection.
|
||||
*/
|
||||
function applyNetwork() {
|
||||
const fade = state._networkFade;
|
||||
const selected = state._selectedKey !== null;
|
||||
let changed = false;
|
||||
for (const network of state._networks.values()) {
|
||||
// A feed held only for the selected train shows that train's line alone.
|
||||
const active =
|
||||
state._enabled &&
|
||||
state._activeFeeds.has(network.feedId) &&
|
||||
network.feedId !== state._heldFeedId;
|
||||
for (const [category, layer] of network.layers) {
|
||||
const alpha =
|
||||
active && state._categories[category] !== false
|
||||
? NETWORK_ALPHA * fade * (selected ? NETWORK_SELECTED_DIM : 1)
|
||||
: 0;
|
||||
const show = alpha > 0.001;
|
||||
if (layer.primitive.show !== show) {
|
||||
layer.primitive.show = show;
|
||||
changed = true;
|
||||
}
|
||||
if (show && Math.abs(layer.alpha - alpha) > 0.001) {
|
||||
layer.material.uniforms.color = layer.base.withAlpha(alpha);
|
||||
layer.alpha = alpha;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) governorRequestRender('rail-network');
|
||||
}
|
||||
|
||||
/** Camera moved: recompute the altitude fade, applying it only on a step. */
|
||||
function onCamera() {
|
||||
const altitude = parts.viewport.cameraAltitude();
|
||||
const raw = networkAltitudeFade(
|
||||
altitude,
|
||||
NETWORK_FULL_BELOW_M,
|
||||
NETWORK_HIDDEN_ABOVE_M,
|
||||
);
|
||||
const fade = Math.round(raw / FADE_STEP) * FADE_STEP;
|
||||
if (fade === state._networkFade) return;
|
||||
state._networkFade = fade;
|
||||
applyNetwork();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected route
|
||||
|
||||
/**
|
||||
* The shape the selected train runs on: its binding, else its route's
|
||||
* longest.
|
||||
*/
|
||||
function routeShapeFor(entry) {
|
||||
const pack = state._pathPacks.get(entry.feedId)?.pack;
|
||||
if (entry.path?.path)
|
||||
return {
|
||||
shapeIndex: entry.path.shapeIndex,
|
||||
path: entry.path.path,
|
||||
binding: entry.path,
|
||||
};
|
||||
const routeId = entry.record?.routeId;
|
||||
if (!pack || !routeId) return null;
|
||||
let best = null;
|
||||
for (const index of pack.shapesForRoute(routeId)) {
|
||||
const path = pack.pathAt(index);
|
||||
if (path && (!best || path.length > best.path.length))
|
||||
best = { shapeIndex: index, path, binding: null };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function dropRoute() {
|
||||
const route = state._route;
|
||||
if (!route) return;
|
||||
removePrimitive(route);
|
||||
state._route = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw (or keep) the selected train's line. Rebuilt only when the train
|
||||
* moves to another shape; the split is a material uniform.
|
||||
*/
|
||||
function syncRoute() {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
const shape = entry && state._enabled ? routeShapeFor(entry) : null;
|
||||
if (!shape || !scene()) {
|
||||
if (state._route) {
|
||||
dropRoute();
|
||||
governorRequestRender('rail-route');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const id = `${entry.key}\n${entry.feedId}\n${shape.shapeIndex}`;
|
||||
if (state._route?.id !== id) {
|
||||
dropRoute();
|
||||
const { path } = shape;
|
||||
const positions = [];
|
||||
// Cumulative drawn length per path vertex. A ground line's texture
|
||||
// coordinate runs by length along these chords; a plain polyline's runs
|
||||
// by vertex, so there it counts the vertices Cesium keeps.
|
||||
const ground = useGround();
|
||||
const chordAt = new Float64Array(path.n);
|
||||
for (let i = 0; i < path.n; i++) {
|
||||
const point = Cesium.Cartesian3.fromDegrees(
|
||||
path.lon[i],
|
||||
path.lat[i],
|
||||
0,
|
||||
);
|
||||
const last = positions[positions.length - 1];
|
||||
const repeat =
|
||||
last &&
|
||||
Cesium.Cartesian3.equalsEpsilon(last, point, Cesium.Math.EPSILON7);
|
||||
if (i > 0)
|
||||
chordAt[i] =
|
||||
chordAt[i - 1] +
|
||||
(repeat ? 0 : ground ? Cesium.Cartesian3.distance(last, point) : 1);
|
||||
if (!repeat) positions.push(point);
|
||||
}
|
||||
const material = createSplitMaterial();
|
||||
const color = Cesium.Color.fromCssColorString(
|
||||
RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local,
|
||||
);
|
||||
material.uniforms.aheadColor = color.withAlpha(1);
|
||||
material.uniforms.travelledColor = color.withAlpha(ROUTE_TRAVELLED_ALPHA);
|
||||
const slot = linePrimitive([positions], ROUTE_AHEAD_WIDTH_PX, material);
|
||||
if (!slot) return;
|
||||
slot.collection.add(slot.primitive);
|
||||
state._route = {
|
||||
...slot,
|
||||
id,
|
||||
material,
|
||||
path,
|
||||
chordAt,
|
||||
binding: shape.binding,
|
||||
// A trip's own shape runs the way the trip does; a route's shape may
|
||||
// be drawn either way, so its direction is learnt from the train.
|
||||
direction: shape.binding?.via === 'trip' ? 1 : 0,
|
||||
firstS: shape.binding ? shape.binding.drawS : NaN,
|
||||
fraction: NaN,
|
||||
};
|
||||
governorRequestRender('rail-route');
|
||||
} else if (state._route.binding !== shape.binding) {
|
||||
// Rebound to the same shape (a new trip on it): follow the new binding.
|
||||
state._route.binding = shape.binding;
|
||||
state._route.firstS = shape.binding ? shape.binding.drawS : NaN;
|
||||
if (shape.binding?.via !== 'trip') state._route.direction = 0;
|
||||
}
|
||||
updateSplit();
|
||||
}
|
||||
|
||||
/** Move the travelled/ahead split to where the train is drawn now. */
|
||||
function updateSplit() {
|
||||
const route = state._route;
|
||||
if (!route?.primitive) return;
|
||||
const binding = route.binding;
|
||||
const s = binding ? binding.drawS : NaN;
|
||||
if (route.direction === 0 && Number.isFinite(s)) {
|
||||
const moved = s - route.firstS;
|
||||
if (Math.abs(moved) >= DIRECTION_EVIDENCE_M)
|
||||
route.direction = Math.sign(moved);
|
||||
}
|
||||
const fraction =
|
||||
route.direction !== 0 && Number.isFinite(s)
|
||||
? splitFraction(route.path, route.chordAt, s)
|
||||
: NaN;
|
||||
const uniforms = route.material.uniforms;
|
||||
if (!Number.isFinite(fraction)) {
|
||||
// No position on the line yet: draw it all as ahead.
|
||||
if (uniforms.splitAt !== -1 || uniforms.direction !== 1) {
|
||||
uniforms.splitAt = -1;
|
||||
uniforms.direction = 1;
|
||||
governorRequestRender('rail-route-split');
|
||||
}
|
||||
route.fraction = NaN;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Number.isFinite(route.fraction) &&
|
||||
Math.abs(fraction - route.fraction) < SPLIT_EPSILON &&
|
||||
uniforms.direction === route.direction
|
||||
)
|
||||
return;
|
||||
route.fraction = fraction;
|
||||
uniforms.splitAt = fraction;
|
||||
uniforms.direction = route.direction;
|
||||
governorRequestRender('rail-route-split');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stops
|
||||
|
||||
function stopPointsCollection() {
|
||||
if (!stopPoints && scene()) {
|
||||
stopPoints = new Cesium.PointPrimitiveCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
scene().primitives.add(stopPoints);
|
||||
}
|
||||
return stopPoints;
|
||||
}
|
||||
|
||||
function clearStops() {
|
||||
stopPoints?.removeAll();
|
||||
state._trip = null;
|
||||
state._stopsSignature = '';
|
||||
state._stopLabelsSignature = '';
|
||||
state._stopAnchors.clear();
|
||||
state._overlayHost.setEntries(
|
||||
RAIL_STOP_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
RAIL_STOP_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
function anchorFor(stop) {
|
||||
const key = `${stop.stopId ?? ''}\n${stop.lat}\n${stop.lon}`;
|
||||
let anchor = state._stopAnchors.get(key);
|
||||
if (!anchor) {
|
||||
const { h, q } = parts.rendering.stopHeight(stop.lat, stop.lon);
|
||||
anchor = {
|
||||
lat: stop.lat,
|
||||
lon: stop.lon,
|
||||
h,
|
||||
q,
|
||||
position: Cesium.Cartesian3.fromDegrees(stop.lon, stop.lat, h),
|
||||
};
|
||||
state._stopAnchors.set(key, anchor);
|
||||
}
|
||||
return anchor.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops placed before their floor cell was warm stand on a fallback (the
|
||||
* ellipsoid at worst, which is drawn over the terrain far off its station
|
||||
* from a low camera): each refresh reads those again, and once one stands
|
||||
* better its dot and label are drawn again there.
|
||||
*/
|
||||
function settleAnchors() {
|
||||
let moved = false;
|
||||
for (const anchor of state._stopAnchors.values()) {
|
||||
if (anchor.q >= 2) continue;
|
||||
const { h, q } = parts.rendering.stopHeight(anchor.lat, anchor.lon);
|
||||
if (q < anchor.q || (q === anchor.q && Math.abs(h - anchor.h) < 1))
|
||||
continue;
|
||||
anchor.h = h;
|
||||
anchor.q = q;
|
||||
anchor.position = Cesium.Cartesian3.fromDegrees(
|
||||
anchor.lon,
|
||||
anchor.lat,
|
||||
h,
|
||||
);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) {
|
||||
state._stopsSignature = '';
|
||||
state._stopLabelsSignature = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the stop dots on the far side of the Earth. They are drawn over the
|
||||
* terrain, and the selected trip is drawn at any distance (viewport.js), so
|
||||
* the rendering pass hands over its horizon test every frame.
|
||||
* @param {(position: object) => boolean} facesCamera
|
||||
*/
|
||||
function cullStops(facesCamera) {
|
||||
if (!stopPoints) return;
|
||||
for (let i = 0; i < stopPoints.length; i++) {
|
||||
const point = stopPoints.get(i);
|
||||
const shown = facesCamera(point.position);
|
||||
if (point.show !== shown) point.show = shown;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the provider for the selected trip's stops and redraw what changed. */
|
||||
function refreshStops() {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
if (!entry || !state._enabled || typeof stops?.stopsFor !== 'function') {
|
||||
if (state._trip || state._stopsSignature) clearStops();
|
||||
return;
|
||||
}
|
||||
let trip = null;
|
||||
try {
|
||||
trip = stops.stopsFor(
|
||||
{
|
||||
feedId: entry.feedId,
|
||||
tripId: entry.record?.tripId || null,
|
||||
routeId: entry.record?.routeId || null,
|
||||
record: entry.record,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[Data:Rail] stops unavailable:', error?.message || error);
|
||||
}
|
||||
const list = Array.isArray(trip?.stops) ? trip.stops : [];
|
||||
state._trip = list.length || trip?.headsign ? trip : null;
|
||||
settleAnchors();
|
||||
const { labels, dots } = selectStopLabels(list);
|
||||
const color = Cesium.Color.fromCssColorString(
|
||||
RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local,
|
||||
);
|
||||
|
||||
const signature = `${entry.key}|${labels.map((l) => l.index).join(',')}|${dots
|
||||
.map((d) => `${d.index}${d.passed ? 'p' : ''}`)
|
||||
.join(',')}`;
|
||||
if (signature !== state._stopsSignature) {
|
||||
state._stopsSignature = signature;
|
||||
const points = stopPointsCollection();
|
||||
if (points) {
|
||||
points.removeAll();
|
||||
const add = (stop, passed, size) =>
|
||||
points.add({
|
||||
position: anchorFor(stop),
|
||||
pixelSize: size,
|
||||
color: passed ? color.withAlpha(0.45) : color,
|
||||
outlineColor: STOP_DOT_OUTLINE,
|
||||
outlineWidth: 1.5,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
for (const dot of dots) add(dot.stop, dot.passed, STOP_DOT_PX);
|
||||
for (const label of labels)
|
||||
add(label.stop, label.stop.state === 'passed', STOP_LABELLED_DOT_PX);
|
||||
}
|
||||
governorRequestRender('rail-stops');
|
||||
}
|
||||
|
||||
const entries = createStopOverlayEntries(entry.key, labels, anchorFor);
|
||||
const text = JSON.stringify(
|
||||
entries.map((item) => [item.id, item.title, item.details, item.accent]),
|
||||
);
|
||||
if (text !== state._stopLabelsSignature) {
|
||||
state._stopLabelsSignature = text;
|
||||
state._overlayHost.setEntries(
|
||||
RAIL_STOP_OVERLAY_SOURCE_ID,
|
||||
entries,
|
||||
RAIL_STOP_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection ticker
|
||||
|
||||
function tick() {
|
||||
if (globalThis.document?.visibilityState === 'hidden') return;
|
||||
if (!state._enabled || !state._selectedKey) {
|
||||
stopTicker();
|
||||
return;
|
||||
}
|
||||
ticks += 1;
|
||||
syncRoute();
|
||||
if ((ticks * ROUTE_SPLIT_INTERVAL_MS) % STOP_REFRESH_MS === 0) {
|
||||
refreshStops();
|
||||
parts.selection.refreshSelectedCard(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startTicker() {
|
||||
if (timer) return;
|
||||
ticks = 0;
|
||||
timer = setInterval(tick, ROUTE_SPLIT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopTicker() {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
/** A train was selected, or the selection was cleared. */
|
||||
function onSelectionChanged() {
|
||||
state._stopAnchors.clear();
|
||||
state._stopsSignature = '';
|
||||
state._stopLabelsSignature = '';
|
||||
const entry = state._enabled ? state._trains.get(state._selectedKey) : null;
|
||||
// The provider loads the feed's timetable and polls its delays while a
|
||||
// train on it is selected.
|
||||
stops?.onSelect?.(
|
||||
entry
|
||||
? {
|
||||
feedId: entry.feedId,
|
||||
tripId: entry.record?.tripId || null,
|
||||
routeId: entry.record?.routeId || null,
|
||||
record: entry.record,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
if (state._selectedKey && state._enabled) {
|
||||
syncRoute();
|
||||
refreshStops();
|
||||
startTicker();
|
||||
} else {
|
||||
stopTicker();
|
||||
dropRoute();
|
||||
clearStops();
|
||||
}
|
||||
applyNetwork();
|
||||
governorRequestRender('rail-route');
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back what a feed out of use for FEED_RELEASE_AFTER_MS holds: its
|
||||
* faint network, its path pack and its parsed timetable. A feed in use
|
||||
* (polled, or held for the selected train) keeps everything; a pack still
|
||||
* loading is left to finish. Called on each poll tick, and once more while
|
||||
* the layer is off.
|
||||
* @param {number} nowMs
|
||||
*/
|
||||
function releaseIdleFeeds(nowMs) {
|
||||
for (const feedId of state._activeFeeds.keys())
|
||||
feedSeenAt.set(feedId, nowMs);
|
||||
const held = new Set([
|
||||
...state._networks.keys(),
|
||||
...state._pathPacks.keys(),
|
||||
...feedSeenAt.keys(),
|
||||
]);
|
||||
for (const feedId of held) {
|
||||
if (state._activeFeeds.has(feedId)) continue;
|
||||
const seen = feedSeenAt.get(feedId);
|
||||
if (seen === undefined) {
|
||||
feedSeenAt.set(feedId, nowMs);
|
||||
continue;
|
||||
}
|
||||
if (nowMs - seen < FEED_RELEASE_AFTER_MS) continue;
|
||||
if (state._pathPacks.get(feedId)?.status === 'loading') continue;
|
||||
dropNetwork(feedId);
|
||||
state._pathPacks.delete(feedId);
|
||||
timetable?.forget?.(feedId);
|
||||
feedSeenAt.delete(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer disabled: hide everything. Built networks stay for a quick return,
|
||||
* and are released once they have been out of use FEED_RELEASE_AFTER_MS.
|
||||
*/
|
||||
function hideAll() {
|
||||
stops?.onSelect?.(null);
|
||||
stopTicker();
|
||||
dropRoute();
|
||||
clearStops();
|
||||
applyNetwork();
|
||||
releaseIdleFeeds(Date.now());
|
||||
clearTimeout(releaseTimer);
|
||||
releaseTimer = setTimeout(() => {
|
||||
releaseTimer = null;
|
||||
releaseIdleFeeds(Date.now());
|
||||
}, FEED_RELEASE_AFTER_MS);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
hideAll();
|
||||
clearTimeout(releaseTimer);
|
||||
releaseTimer = null;
|
||||
feedSeenAt.clear();
|
||||
for (const feedId of [...state._networks.keys()]) dropNetwork(feedId);
|
||||
if (stopPoints) {
|
||||
scene()?.primitives?.remove(stopPoints);
|
||||
stopPoints = null;
|
||||
}
|
||||
state._overlayHost.clearSource(RAIL_STOP_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
/** Counts for QA and the testing surface. */
|
||||
function stats() {
|
||||
const feeds = {};
|
||||
for (const [feedId, network] of state._networks) {
|
||||
feeds[feedId] = {
|
||||
status: network.status,
|
||||
primitives: network.layers.size,
|
||||
shown: [...network.layers.values()].filter((l) => l.primitive.show)
|
||||
.length,
|
||||
...(network.stats || {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
fade: state._networkFade,
|
||||
feeds,
|
||||
route: state._route
|
||||
? {
|
||||
id: state._route.id,
|
||||
vertices: state._route.path.n,
|
||||
fraction: state._route.fraction,
|
||||
direction: state._route.direction,
|
||||
ground: state._route.ground,
|
||||
}
|
||||
: null,
|
||||
stops: {
|
||||
points: stopPoints?.length || 0,
|
||||
trip: state._trip ? state._trip.stops?.length || 0 : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buildNetwork,
|
||||
dropNetwork,
|
||||
applyNetwork,
|
||||
onCamera,
|
||||
syncRoute,
|
||||
refreshStops,
|
||||
cullStops,
|
||||
onSelectionChanged,
|
||||
hideAll,
|
||||
releaseIdleFeeds,
|
||||
destroy,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @module layers/rail/routeLines
|
||||
* @description Pure geometry for the Rail layer's route lines: the faint
|
||||
* network a feed's path pack draws when zoomed in, and the split of a
|
||||
* selected train's line into the part travelled and the part ahead.
|
||||
*
|
||||
* A pack lists every distinct GTFS shape, and most of them overlap: a
|
||||
* commuter network's short turns, express patterns and branches share the
|
||||
* trunk. Drawing each shape would stack hundreds of translucent lines on the
|
||||
* same track, so the network is a union: shapes are walked longest first, and
|
||||
* only the stretches no earlier shape already covered are kept. Each kept
|
||||
* stretch is then thinned to the vertices its curve needs.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/** Metres per degree of latitude (and of longitude at the equator). */
|
||||
const M_PER_DEG = 111_320;
|
||||
/** Coverage grid cell (m). A track within about one cell is already drawn. */
|
||||
export const NETWORK_CELL_M = 40;
|
||||
/** Sample spacing when walking a shape for coverage (m). */
|
||||
const NETWORK_STEP_M = 40;
|
||||
/** Uncovered stretches shorter than this are jitter beside a drawn track (m). */
|
||||
export const NETWORK_MIN_RUN_M = 200;
|
||||
/** Douglas-Peucker tolerance for the drawn stretches (m). */
|
||||
export const NETWORK_SIMPLIFY_M = 10;
|
||||
/** Grid key offset, so negative cell indices stay distinct. */
|
||||
const KEY_OFFSET = 2 ** 21;
|
||||
|
||||
/** Longitude difference wrapped into (-180, 180]. */
|
||||
function wrapLon(delta) {
|
||||
return ((((delta + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
/** Arc length of a flat `[lat, lon, ...]` shape in a local frame (m). */
|
||||
function shapeLength(coords, kx) {
|
||||
let length = 0;
|
||||
for (let i = 2; i + 1 < coords.length; i += 2)
|
||||
length += Math.hypot(
|
||||
wrapLon(coords[i + 1] - coords[i - 1]) * kx,
|
||||
(coords[i] - coords[i - 2]) * M_PER_DEG,
|
||||
);
|
||||
return length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas-Peucker on a run of local-frame points, returning kept indices.
|
||||
* Iterative, so a long run cannot overflow the stack.
|
||||
*/
|
||||
function simplifyIndices(xs, ys, tolerance) {
|
||||
const n = xs.length;
|
||||
if (n <= 2) return Array.from({ length: n }, (_, i) => i);
|
||||
const keep = new Uint8Array(n);
|
||||
keep[0] = 1;
|
||||
keep[n - 1] = 1;
|
||||
const tol2 = tolerance * tolerance;
|
||||
const stack = [0, n - 1];
|
||||
while (stack.length) {
|
||||
const last = stack.pop();
|
||||
const first = stack.pop();
|
||||
const ax = xs[first];
|
||||
const ay = ys[first];
|
||||
const dx = xs[last] - ax;
|
||||
const dy = ys[last] - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let worst = -1;
|
||||
let worstD2 = tol2;
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
let t = len2 > 0 ? ((xs[i] - ax) * dx + (ys[i] - ay) * dy) / len2 : 0;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
const ex = ax + dx * t - xs[i];
|
||||
const ey = ay + dy * t - ys[i];
|
||||
const d2 = ex * ex + ey * ey;
|
||||
if (d2 > worstD2) {
|
||||
worstD2 = d2;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst < 0) continue;
|
||||
keep[worst] = 1;
|
||||
stack.push(first, worst, worst, last);
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) if (keep[i]) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category each shape is drawn in: that of the first route listing it
|
||||
* whose category is known. Shapes no classified route lists are not drawn
|
||||
* (a pack can carry shapes of routes the layer filters out).
|
||||
* @param {Record<string, number[]>} routes routeId → shape indices.
|
||||
* @param {number} shapeCount
|
||||
* @param {(routeId: string) => string|null} categoryOf
|
||||
* @returns {(string|null)[]}
|
||||
*/
|
||||
export function shapeCategories(routes, shapeCount, categoryOf) {
|
||||
const out = new Array(shapeCount).fill(null);
|
||||
for (const [routeId, list] of Object.entries(routes || {})) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
let category;
|
||||
for (const index of list) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= shapeCount)
|
||||
continue;
|
||||
if (out[index] !== null) continue;
|
||||
if (category === undefined) category = categoryOf(routeId) || null;
|
||||
if (category) out[index] = category;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The union of a pack's shapes as drawable stretches, each tagged with the
|
||||
* category of the route it came from.
|
||||
*
|
||||
* @param {{shapes: ArrayLike<number>[], routes?: Record<string, number[]>}} raw
|
||||
* A decoded path pack (the JSON body, not the indexed wrapper).
|
||||
* @param {(routeId: string) => string|null} categoryOf Route → category, or
|
||||
* null for a route the layer does not draw.
|
||||
* @param {{cellM?: number, minRunM?: number, simplifyM?: number}} [options]
|
||||
* @returns {{lines: {category: string, coords: Float64Array}[],
|
||||
* shapesIn: number, pointsIn: number, pointsOut: number}}
|
||||
*/
|
||||
export function buildRailNetworkLines(raw, categoryOf, options = {}) {
|
||||
const steps = railNetworkLineSteps(raw, categoryOf, options);
|
||||
for (;;) {
|
||||
const next = steps.next();
|
||||
if (next.done) return next.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same union as buildRailNetworkLines(), one shape per step, so a large
|
||||
* pack can be built in slices between frames instead of in one long task.
|
||||
* @param {object} raw
|
||||
* @param {(routeId: string) => string|null} categoryOf
|
||||
* @param {object} [options]
|
||||
* @returns {Generator<void, ReturnType<typeof buildRailNetworkLines>>}
|
||||
*/
|
||||
export function* railNetworkLineSteps(raw, categoryOf, options = {}) {
|
||||
const cellM = options.cellM ?? NETWORK_CELL_M;
|
||||
const minRunM = options.minRunM ?? NETWORK_MIN_RUN_M;
|
||||
const simplifyM = options.simplifyM ?? NETWORK_SIMPLIFY_M;
|
||||
const shapes = Array.isArray(raw?.shapes) ? raw.shapes : [];
|
||||
const categories = shapeCategories(raw?.routes, shapes.length, categoryOf);
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let pointsIn = 0;
|
||||
const order = [];
|
||||
for (let index = 0; index < shapes.length; index++) {
|
||||
const coords = shapes[index];
|
||||
if (!categories[index] || !(coords?.length >= 4)) continue;
|
||||
for (let i = 0; i + 1 < coords.length; i += 2) {
|
||||
if (coords[i] < minLat) minLat = coords[i];
|
||||
if (coords[i] > maxLat) maxLat = coords[i];
|
||||
}
|
||||
pointsIn += coords.length / 2;
|
||||
order.push(index);
|
||||
}
|
||||
if (!order.length) return { lines: [], shapesIn: 0, pointsIn, pointsOut: 0 };
|
||||
const lat0 = (minLat + maxLat) / 2;
|
||||
const lon0 = shapes[order[0]][1];
|
||||
const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180);
|
||||
const lengths = new Map(
|
||||
order.map((index) => [index, shapeLength(shapes[index], kx)]),
|
||||
);
|
||||
order.sort((a, b) => lengths.get(b) - lengths.get(a));
|
||||
|
||||
const covered = new Set();
|
||||
const lines = [];
|
||||
let pointsOut = 0;
|
||||
// Reused per shape: the densified walk in the local frame.
|
||||
let xs = [];
|
||||
let ys = [];
|
||||
let cells = [];
|
||||
const rowWidth = KEY_OFFSET * 2;
|
||||
const cellKey = (x, y) =>
|
||||
(Math.floor(x / cellM) + KEY_OFFSET) * rowWidth +
|
||||
(Math.floor(y / cellM) + KEY_OFFSET);
|
||||
// Own cell first: on an already drawn track that is almost always the hit,
|
||||
// so a covered sample costs one lookup and only new track pays for nine.
|
||||
const around = [
|
||||
-rowWidth,
|
||||
rowWidth,
|
||||
-1,
|
||||
1,
|
||||
-rowWidth - 1,
|
||||
-rowWidth + 1,
|
||||
rowWidth - 1,
|
||||
rowWidth + 1,
|
||||
];
|
||||
const isCovered = (key) => {
|
||||
if (covered.has(key)) return true;
|
||||
for (let i = 0; i < around.length; i++)
|
||||
if (covered.has(key + around[i])) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
function emit(category, from, to) {
|
||||
const runXs = xs.slice(from, to + 1);
|
||||
const runYs = ys.slice(from, to + 1);
|
||||
let length = 0;
|
||||
for (let i = 1; i < runXs.length; i++)
|
||||
length += Math.hypot(runXs[i] - runXs[i - 1], runYs[i] - runYs[i - 1]);
|
||||
if (length < minRunM) return;
|
||||
const kept = simplifyIndices(runXs, runYs, simplifyM);
|
||||
const coords = new Float64Array(kept.length * 2);
|
||||
for (let k = 0; k < kept.length; k++) {
|
||||
const i = kept[k];
|
||||
coords[2 * k] = runYs[i] / M_PER_DEG + lat0;
|
||||
let lon = runXs[i] / kx + lon0;
|
||||
if (lon > 180) lon -= 360;
|
||||
else if (lon < -180) lon += 360;
|
||||
coords[2 * k + 1] = lon;
|
||||
}
|
||||
pointsOut += kept.length;
|
||||
lines.push({ category, coords });
|
||||
}
|
||||
|
||||
for (const index of order) {
|
||||
const coords = shapes[index];
|
||||
xs = [];
|
||||
ys = [];
|
||||
for (let i = 0; i + 1 < coords.length; i += 2) {
|
||||
const x = wrapLon(coords[i + 1] - lon0) * kx;
|
||||
const y = (coords[i] - lat0) * M_PER_DEG;
|
||||
if (xs.length) {
|
||||
const px = xs[xs.length - 1];
|
||||
const py = ys[ys.length - 1];
|
||||
const steps = Math.ceil(Math.hypot(x - px, y - py) / NETWORK_STEP_M);
|
||||
for (let k = 1; k < steps; k++) {
|
||||
xs.push(px + ((x - px) * k) / steps);
|
||||
ys.push(py + ((y - py) * k) / steps);
|
||||
}
|
||||
}
|
||||
xs.push(x);
|
||||
ys.push(y);
|
||||
}
|
||||
cells = new Array(xs.length);
|
||||
let runStart = -1;
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
cells[i] = cellKey(xs[i], ys[i]);
|
||||
const free = !isCovered(cells[i]);
|
||||
if (free && runStart < 0) runStart = Math.max(0, i - 1);
|
||||
else if (!free && runStart >= 0) {
|
||||
emit(categories[index], runStart, i);
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
if (runStart >= 0) emit(categories[index], runStart, xs.length - 1);
|
||||
// Marked after the walk, not during it: consecutive samples of one shape
|
||||
// sit in neighbouring cells, so marking as it goes would cover the shape
|
||||
// against itself.
|
||||
for (const key of cells) covered.add(key);
|
||||
yield;
|
||||
}
|
||||
return { lines, shapesIn: order.length, pointsIn, pointsOut };
|
||||
}
|
||||
|
||||
/**
|
||||
* The fraction of a polyline, by cumulative chord length, at which arc `s`
|
||||
* of a rail path falls. The renderer draws the path's own vertices, so the
|
||||
* fraction is measured on those vertices: segment `i` of the path holds the
|
||||
* arc range `[path.s[i], path.s[i+1]]` and the same chord in the drawn line.
|
||||
*
|
||||
* @param {{n: number, s: ArrayLike<number>}} path From createRailPath.
|
||||
* @param {ArrayLike<number>} chordAt Cumulative drawn length per vertex
|
||||
* (same length as the path), e.g. from cumulativeChords().
|
||||
* @param {number} s Arc length (m) on the path.
|
||||
* @returns {number} 0..1, or NaN when either input is unusable.
|
||||
*/
|
||||
export function splitFraction(path, chordAt, s) {
|
||||
if (!path || !chordAt || chordAt.length !== path.n || !Number.isFinite(s))
|
||||
return NaN;
|
||||
const total = chordAt[path.n - 1];
|
||||
if (!(total > 0)) return NaN;
|
||||
const arc = path.s;
|
||||
if (s <= arc[0]) return 0;
|
||||
if (s >= arc[path.n - 1]) return 1;
|
||||
let lo = 0;
|
||||
let hi = path.n - 2;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (arc[mid] <= s) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
const span = arc[lo + 1] - arc[lo];
|
||||
const t = span > 0 ? (s - arc[lo]) / span : 0;
|
||||
const chord = chordAt[lo] + (chordAt[lo + 1] - chordAt[lo]) * t;
|
||||
return Math.max(0, Math.min(1, chord / total));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative length per vertex of a path, measured on the path's own local
|
||||
* frame (x, y). This is what splitFraction() needs when the drawn line's
|
||||
* texture coordinate runs by length, as a ground polyline's does.
|
||||
* @param {{n: number, x: ArrayLike<number>, y: ArrayLike<number>}} path
|
||||
* @returns {Float64Array}
|
||||
*/
|
||||
export function cumulativeChords(path) {
|
||||
const out = new Float64Array(path?.n || 0);
|
||||
for (let i = 1; i < out.length; i++)
|
||||
out[i] =
|
||||
out[i - 1] +
|
||||
Math.hypot(path.x[i] - path.x[i - 1], path.y[i] - path.y[i - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex-index positions: what splitFraction() needs when the drawn line's
|
||||
* texture coordinate runs by vertex index, as a plain PolylineGeometry's does.
|
||||
* @param {{n: number}} path
|
||||
* @returns {Float64Array}
|
||||
*/
|
||||
export function vertexIndexChords(path) {
|
||||
const out = new Float64Array(path?.n || 0);
|
||||
for (let i = 0; i < out.length; i++) out[i] = i;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The faint network's opacity at a camera altitude: full below `fullBelowM`,
|
||||
* none above `hiddenAboveM`, linear between.
|
||||
* @param {number} altitudeM
|
||||
* @param {number} fullBelowM
|
||||
* @param {number} hiddenAboveM
|
||||
* @returns {number} 0..1
|
||||
*/
|
||||
export function networkAltitudeFade(altitudeM, fullBelowM, hiddenAboveM) {
|
||||
if (!Number.isFinite(altitudeM)) return 0;
|
||||
if (altitudeM <= fullBelowM) return 1;
|
||||
if (altitudeM >= hiddenAboveM) return 0;
|
||||
return (hiddenAboveM - altitudeM) / (hiddenAboveM - fullBelowM);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// The Rail layer's route-line geometry: the network union a path pack draws
|
||||
// as faint lines, and where a selected train's line splits into the part
|
||||
// travelled and the part ahead; and, on a whole layer, how the selected
|
||||
// train's line is drawn and released.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEED_RELEASE_AFTER_MS, railTrainKey } from './policy.js';
|
||||
import { SCHEDULE_RETRY_FIRST_MS } from './schedules.js';
|
||||
import {
|
||||
BOSTON,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
import {
|
||||
NETWORK_MIN_RUN_M,
|
||||
buildRailNetworkLines,
|
||||
cumulativeChords,
|
||||
networkAltitudeFade,
|
||||
railNetworkLineSteps,
|
||||
shapeCategories,
|
||||
splitFraction,
|
||||
vertexIndexChords,
|
||||
} from './routeLines.js';
|
||||
import {
|
||||
createRailPath,
|
||||
pointAtS,
|
||||
projectOnPath,
|
||||
} from '../../data/railPaths.js';
|
||||
|
||||
const M_PER_DEG = 111_320;
|
||||
const LAT = 42.36;
|
||||
const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180);
|
||||
|
||||
/** A flat [lat, lon, ...] line due east from `lon0`, one vertex per `stepM`. */
|
||||
function eastLine(lon0, lengthM, stepM = 100, lat = LAT) {
|
||||
const out = [];
|
||||
for (let d = 0; d <= lengthM + 1e-6; d += stepM) out.push(lat, lon0 + d / KX);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Total length of a flat line (m), measured like the module does. */
|
||||
function lengthOf(coords) {
|
||||
let length = 0;
|
||||
for (let i = 2; i < coords.length; i += 2)
|
||||
length += Math.hypot(
|
||||
(coords[i + 1] - coords[i - 1]) * KX,
|
||||
(coords[i] - coords[i - 2]) * M_PER_DEG,
|
||||
);
|
||||
return length;
|
||||
}
|
||||
|
||||
const everyRoute = () => 'regional';
|
||||
|
||||
test('shape categories come from the first classified route listing them', () => {
|
||||
const categories = shapeCategories(
|
||||
{ bus: [0], 'CR-A': [1, 2], 'CR-B': [2, 3], bad: [99, -1, 'x'] },
|
||||
4,
|
||||
(route) =>
|
||||
route === 'bus' ? null : route === 'CR-A' ? 'local' : 'national',
|
||||
);
|
||||
assert.deepEqual(categories, [null, 'local', 'local', 'national']);
|
||||
});
|
||||
|
||||
test('overlapping shapes are drawn once: a short turn adds nothing', () => {
|
||||
const full = eastLine(-71.2, 20_000);
|
||||
const shortTurn = eastLine(-71.2, 8_000, 250); // other vertex spacing too
|
||||
const result = buildRailNetworkLines(
|
||||
{ shapes: [shortTurn, full], routes: { r: [0, 1] } },
|
||||
everyRoute,
|
||||
);
|
||||
assert.equal(result.shapesIn, 2);
|
||||
assert.equal(result.lines.length, 1);
|
||||
assert.equal(result.lines[0].category, 'regional');
|
||||
// The longest shape is walked first, so the one line spans all of it.
|
||||
assert.ok(Math.abs(lengthOf(result.lines[0].coords) - 20_000) < 60);
|
||||
// A straight track keeps only its ends.
|
||||
assert.equal(result.lines[0].coords.length, 4);
|
||||
assert.ok(result.pointsOut < result.pointsIn);
|
||||
});
|
||||
|
||||
test('a branch adds only the stretch the trunk does not already cover', () => {
|
||||
const trunk = eastLine(-71.2, 20_000);
|
||||
// Shares the trunk's first 10 km, then turns north for 5 km.
|
||||
const branch = eastLine(-71.2, 10_000);
|
||||
const turnLon = branch[branch.length - 1];
|
||||
for (let d = 100; d <= 5_000; d += 100)
|
||||
branch.push(LAT + d / M_PER_DEG, turnLon);
|
||||
const result = buildRailNetworkLines(
|
||||
{ shapes: [trunk, branch], routes: { a: [0], b: [1] } },
|
||||
(route) => (route === 'a' ? 'local' : 'regional'),
|
||||
);
|
||||
assert.equal(result.lines.length, 2);
|
||||
const [first, second] = result.lines;
|
||||
assert.equal(first.category, 'local');
|
||||
assert.equal(second.category, 'regional');
|
||||
const branchLength = lengthOf(second.coords);
|
||||
// The new stretch is the 5 km spur, joined to the trunk by a sample or so.
|
||||
assert.ok(branchLength > 4_900 && branchLength < 5_200, String(branchLength));
|
||||
});
|
||||
|
||||
test('a parallel track beside a drawn one is jitter, not a new line', () => {
|
||||
const a = eastLine(-71.2, 10_000);
|
||||
const b = eastLine(-71.2, 9_000, 100, LAT + 15 / M_PER_DEG); // 15 m north
|
||||
const result = buildRailNetworkLines(
|
||||
{ shapes: [a, b], routes: { r: [0, 1] } },
|
||||
everyRoute,
|
||||
);
|
||||
assert.equal(result.lines.length, 1);
|
||||
});
|
||||
|
||||
test('uncovered stretches shorter than the minimum run are dropped', () => {
|
||||
const trunk = eastLine(-71.2, 10_000);
|
||||
// Leaves the trunk for a stub shorter than the minimum, then ends.
|
||||
const stub = eastLine(-71.2, 5_000);
|
||||
const endLon = stub[stub.length - 1];
|
||||
const stubM = NETWORK_MIN_RUN_M / 2;
|
||||
for (let d = 20; d <= stubM; d += 20) stub.push(LAT - d / M_PER_DEG, endLon);
|
||||
const result = buildRailNetworkLines(
|
||||
{ shapes: [trunk, stub], routes: { r: [0, 1] } },
|
||||
everyRoute,
|
||||
);
|
||||
assert.equal(result.lines.length, 1);
|
||||
});
|
||||
|
||||
test('shapes of routes the layer does not draw are left out', () => {
|
||||
const result = buildRailNetworkLines(
|
||||
{ shapes: [eastLine(-71.2, 5_000)], routes: { bus: [0] } },
|
||||
() => null,
|
||||
);
|
||||
assert.deepEqual(result.lines, []);
|
||||
assert.equal(result.shapesIn, 0);
|
||||
assert.deepEqual(buildRailNetworkLines(null, everyRoute).lines, []);
|
||||
});
|
||||
|
||||
test('the step builder yields once per shape and ends with the same union', () => {
|
||||
const raw = {
|
||||
shapes: [
|
||||
eastLine(-71.2, 20_000),
|
||||
eastLine(-71.2, 8_000),
|
||||
eastLine(-71.3, 3_000),
|
||||
],
|
||||
routes: { r: [0, 1, 2] },
|
||||
};
|
||||
const steps = railNetworkLineSteps(raw, everyRoute);
|
||||
let yields = 0;
|
||||
let next = steps.next();
|
||||
while (!next.done) {
|
||||
yields += 1;
|
||||
next = steps.next();
|
||||
}
|
||||
assert.equal(yields, 3);
|
||||
assert.deepEqual(
|
||||
next.value.lines.map((line) => Array.from(line.coords)),
|
||||
buildRailNetworkLines(raw, everyRoute).lines.map((line) =>
|
||||
Array.from(line.coords),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the split falls where the train is on its line, by drawn length', () => {
|
||||
// An L-shaped line: 3 km east, then 1 km north, with uneven vertices.
|
||||
const coords = eastLine(-71.2, 3_000, 500);
|
||||
const cornerLon = coords[coords.length - 1];
|
||||
for (let d = 100; d <= 1_000; d += 100)
|
||||
coords.push(LAT + d / M_PER_DEG, cornerLon);
|
||||
const path = createRailPath(coords);
|
||||
const chords = cumulativeChords(path);
|
||||
assert.equal(chords.length, path.n);
|
||||
assert.ok(Math.abs(chords[path.n - 1] - path.length) < 1e-6);
|
||||
|
||||
// A train reported 1.5 km along, a little off the track.
|
||||
const hit = projectOnPath(path, LAT + 8 / M_PER_DEG, -71.2 + 1_500 / KX);
|
||||
assert.ok(Math.abs(hit.s - 1_500) < 1);
|
||||
const fraction = splitFraction(path, chords, hit.s);
|
||||
assert.ok(Math.abs(fraction - 1_500 / 4_000) < 1e-3);
|
||||
|
||||
// The drawn point at the split is the train's point on the line.
|
||||
const at = pointAtS(path, fraction * path.length);
|
||||
assert.ok(Math.abs(at.lon - (-71.2 + 1_500 / KX)) < 1e-6);
|
||||
|
||||
// Ends and out-of-range arcs clamp; bad input is NaN, not a guess.
|
||||
assert.equal(splitFraction(path, chords, -50), 0);
|
||||
assert.equal(splitFraction(path, chords, path.length + 50), 1);
|
||||
assert.ok(Number.isNaN(splitFraction(path, chords, NaN)));
|
||||
assert.ok(Number.isNaN(splitFraction(path, new Float64Array(2), 10)));
|
||||
assert.ok(Number.isNaN(splitFraction(null, chords, 10)));
|
||||
});
|
||||
|
||||
test('by vertex index, the split follows a line drawn per vertex', () => {
|
||||
// Vertices at 0, 500, 2500 m: by index the middle vertex is halfway.
|
||||
const coords = [LAT, -71.2, LAT, -71.2 + 500 / KX, LAT, -71.2 + 2_500 / KX];
|
||||
const path = createRailPath(coords);
|
||||
const byIndex = vertexIndexChords(path);
|
||||
assert.deepEqual(Array.from(byIndex), [0, 1, 2]);
|
||||
assert.ok(Math.abs(splitFraction(path, byIndex, 500) - 0.5) < 1e-9);
|
||||
// 1500 m is halfway along the second segment: index 1.5 of 2.
|
||||
assert.ok(Math.abs(splitFraction(path, byIndex, 1_500) - 0.75) < 1e-9);
|
||||
// By length the same arc is 0.6 of the line.
|
||||
assert.ok(
|
||||
Math.abs(splitFraction(path, cumulativeChords(path), 1_500) - 0.6) < 1e-6,
|
||||
);
|
||||
});
|
||||
|
||||
test('the network fades out between its two altitudes', () => {
|
||||
assert.equal(networkAltitudeFade(15_000, 150_000, 200_000), 1);
|
||||
assert.equal(networkAltitudeFade(150_000, 150_000, 200_000), 1);
|
||||
assert.equal(networkAltitudeFade(175_000, 150_000, 200_000), 0.5);
|
||||
assert.equal(networkAltitudeFade(200_000, 150_000, 200_000), 0);
|
||||
assert.equal(networkAltitudeFade(3_000_000, 150_000, 200_000), 0);
|
||||
assert.equal(networkAltitudeFade(Infinity, 150_000, 200_000), 0);
|
||||
assert.equal(networkAltitudeFade(NaN, 150_000, 200_000), 0);
|
||||
});
|
||||
|
||||
test('the selected train’s line is its trip’s shape, else its route’s longest', async (t) => {
|
||||
const pack = {
|
||||
version: 1,
|
||||
feedId: 'mbta-rail',
|
||||
shapes: [eastLine(-71.08, 3000), eastLine(-71.08, 3000, 3000, LAT + 0.01)],
|
||||
dist: [[], []],
|
||||
shapeSources: ['operator', 'operator'],
|
||||
trips: { 'trip-T1': 0 },
|
||||
routes: { R1: [0], R2: [1] },
|
||||
};
|
||||
const app = harness(t, { paths: { 'mbta-rail': pack } });
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', LAT, -71.07, { routeId: 'R1' }),
|
||||
train('T2', LAT + 0.01, -71.07, { routeId: 'R2' }),
|
||||
]);
|
||||
await app.enable();
|
||||
for (const [id, shapeIndex, vertices] of [
|
||||
['T1', 0, 31],
|
||||
['T2', 1, 2],
|
||||
]) {
|
||||
const key = railTrainKey('mbta-rail', id);
|
||||
app.parts().selection.selectTrain(key);
|
||||
const { route } = app.layer.getRouteDisplayStats();
|
||||
assert.equal(
|
||||
route?.id,
|
||||
`${key}
|
||||
mbta-rail
|
||||
${shapeIndex}`,
|
||||
id,
|
||||
);
|
||||
assert.equal(route.vertices, vertices, id);
|
||||
}
|
||||
});
|
||||
|
||||
/** A one-shape Boston pack for mbta-rail. */
|
||||
const BOSTON_PACK = Object.freeze({
|
||||
version: 1,
|
||||
feedId: 'mbta-rail',
|
||||
shapes: [eastLine(-71.08, 3000)],
|
||||
dist: [[]],
|
||||
trips: {},
|
||||
routes: { R1: [0] },
|
||||
});
|
||||
const OCEAN = { lat: 30, lon: -40 };
|
||||
const mbtaPaths = (app) =>
|
||||
app.requestedPaths.filter((id) => id === 'mbta-rail').length;
|
||||
|
||||
test('a path pack the server cannot read now is asked for again on a stepped delay', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const paths = { 'mbta-rail': 503 };
|
||||
const app = harness(t, { paths });
|
||||
app.feeds.set('mbta-rail', () => []);
|
||||
await app.enable();
|
||||
assert.equal(mbtaPaths(app), 1);
|
||||
await app.poll();
|
||||
assert.equal(mbtaPaths(app), 1, 'not on every poll');
|
||||
t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS);
|
||||
await app.poll();
|
||||
assert.equal(mbtaPaths(app), 2, 'again after 30 s');
|
||||
// The second failure waits twice as long.
|
||||
t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS);
|
||||
await app.poll();
|
||||
assert.equal(mbtaPaths(app), 2);
|
||||
paths['mbta-rail'] = BOSTON_PACK;
|
||||
t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS);
|
||||
await app.poll();
|
||||
assert.equal(mbtaPaths(app), 3);
|
||||
assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready');
|
||||
});
|
||||
|
||||
test('a feed out of use for ten minutes gives back its network and packs', async (t) => {
|
||||
const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } });
|
||||
app.feeds.set('mbta-rail', () => []);
|
||||
await app.enable();
|
||||
t.mock.timers.tick(0);
|
||||
assert.ok(app.state()._networks.has('mbta-rail'));
|
||||
assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready');
|
||||
// Away from Boston: kept for a quick return...
|
||||
await app.moveCamera(OCEAN);
|
||||
assert.ok(!app.state()._activeFeeds.has('mbta-rail'));
|
||||
t.mock.timers.tick(FEED_RELEASE_AFTER_MS - 60_000);
|
||||
await app.poll();
|
||||
assert.ok(app.state()._networks.has('mbta-rail'));
|
||||
assert.ok(app.state()._pathPacks.has('mbta-rail'));
|
||||
// ...and released after ten minutes out of use.
|
||||
t.mock.timers.tick(60_000);
|
||||
await app.poll();
|
||||
assert.ok(!app.state()._networks.has('mbta-rail'));
|
||||
assert.ok(!app.state()._pathPacks.has('mbta-rail'));
|
||||
// Back in Boston, the pack loads again.
|
||||
await app.moveCamera(BOSTON);
|
||||
assert.equal(mbtaPaths(app), 2);
|
||||
});
|
||||
|
||||
test('a feed in use keeps its network however long it runs', async (t) => {
|
||||
const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } });
|
||||
app.feeds.set('mbta-rail', () => []);
|
||||
await app.enable();
|
||||
t.mock.timers.tick(0);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
t.mock.timers.tick(FEED_RELEASE_AFTER_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.ok(app.state()._networks.has('mbta-rail'));
|
||||
assert.equal(mbtaPaths(app), 1);
|
||||
});
|
||||
|
||||
test('with the layer off, its feeds are released after ten minutes', async (t) => {
|
||||
const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } });
|
||||
app.feeds.set('mbta-rail', () => []);
|
||||
await app.enable();
|
||||
t.mock.timers.tick(0);
|
||||
app.layer.disable(app.viewer);
|
||||
assert.ok(app.state()._networks.has('mbta-rail'), 'kept for a quick return');
|
||||
t.mock.timers.tick(FEED_RELEASE_AFTER_MS);
|
||||
assert.ok(!app.state()._networks.has('mbta-rail'));
|
||||
assert.ok(!app.state()._pathPacks.has('mbta-rail'));
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* @module layers/rail/schedules
|
||||
* @description The Rail layer's schedule tier: each feed's optional schedule
|
||||
* pack (src/data/railSchedule.js) and live trip-update delays, read for the
|
||||
* selected live train's stops, headsign and delays (the stops provider,
|
||||
* `stopsFor`). It never places or moves a train: every train drawn is where
|
||||
* its operator last reported it.
|
||||
*
|
||||
* A schedule pack is fetched the first time a train on that feed is
|
||||
* selected, and kept until the feed has been out of use for a while
|
||||
* (`forget`). Trip updates are polled every RAIL_UPDATES_POLL_MS while a
|
||||
* train on the feed is selected. A feed that publishes none (Irish Rail, SJ
|
||||
* Nord) may state each train's delay in its report (`delaySec`): that delay
|
||||
* then moves the train's timetable, for its stops and for finding its run.
|
||||
*
|
||||
* No Cesium, no DOM. Network access goes through the injected source and
|
||||
* timers through injected functions, so node:test drives it.
|
||||
*/
|
||||
|
||||
import {
|
||||
activeTrips,
|
||||
createSchedule,
|
||||
liveDelaySec,
|
||||
scheduledPosition,
|
||||
tripStops,
|
||||
} from '../../data/railSchedule.js';
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
|
||||
/** Trip updates are re-read this often while needed (ms). */
|
||||
export const RAIL_UPDATES_POLL_MS = 30_000;
|
||||
/** Delays older than this are not applied: a stopped poll must not lie (ms). */
|
||||
export const RAIL_UPDATES_MAX_AGE_MS = 2 * RAIL_UPDATES_POLL_MS + 15_000;
|
||||
/**
|
||||
* A schedule pack that failed (other than a 404) is tried again after
|
||||
* SCHEDULE_RETRY_FIRST_MS, then twice as long after each failure in a row,
|
||||
* up to SCHEDULE_RETRY_MS (ms): one bad answer from the server does not
|
||||
* leave a selected train without its stops for ten minutes.
|
||||
*/
|
||||
export const SCHEDULE_RETRY_FIRST_MS = 30_000;
|
||||
export const SCHEDULE_RETRY_MS = 10 * 60_000;
|
||||
/**
|
||||
* A live train without a known trip id may be matched to a run from this
|
||||
* long before its departure (waiting at the origin)...
|
||||
*/
|
||||
export const MATCH_BEFORE_MS = 5 * 60_000;
|
||||
/** ...to this long after its arrival, at the terminus. */
|
||||
export const MATCH_AFTER_MS = 2 * 60_000;
|
||||
/** A live train without a known trip id matches a run this close (m). */
|
||||
export const MATCH_NEAREST_MAX_M = 3_000;
|
||||
/** How long a nearest-run match is kept before it is looked for again (ms). */
|
||||
export const MATCH_KEEP_MS = 60_000;
|
||||
|
||||
/**
|
||||
* How long a schedule pack waits after its nth failure in a row (ms).
|
||||
* @param {number} failures 1 after the first.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function scheduleRetryDelayMs(failures) {
|
||||
return Math.min(
|
||||
SCHEDULE_RETRY_MS,
|
||||
SCHEDULE_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1),
|
||||
);
|
||||
}
|
||||
|
||||
const M_PER_DEG = 111_320;
|
||||
|
||||
/** Ground distance in metres, equirectangular: fine at a few kilometres. */
|
||||
function distanceM(lat1, lon1, lat2, lon2) {
|
||||
const kx = M_PER_DEG * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
const dLon = ((((lon2 - lon1 + 180) % 360) + 360) % 360) - 180;
|
||||
return Math.hypot(dLon * kx, (lat2 - lat1) * M_PER_DEG);
|
||||
}
|
||||
|
||||
/**
|
||||
* GTFS-RT trip records (the `/api/rail/updates` body) as the `delays` map
|
||||
* railSchedule reads, keyed by trip id.
|
||||
* @param {{trips?: object[]}|null} body
|
||||
* @returns {Record<string, object>}
|
||||
*/
|
||||
export function delaysFromUpdates(body) {
|
||||
const delays = {};
|
||||
for (const trip of Array.isArray(body?.trips) ? body.trips : []) {
|
||||
if (typeof trip?.tripId !== 'string' || !trip.tripId) continue;
|
||||
delays[trip.tripId] = {
|
||||
delaySec: Number.isFinite(trip.delaySec) ? trip.delaySec : null,
|
||||
startDate: trip.startDate ?? null,
|
||||
relationship: trip.relationship ?? null,
|
||||
stops: Array.isArray(trip.stops) ? trip.stops : [],
|
||||
};
|
||||
}
|
||||
return delays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a timetable's shape indices point into this path pack: the two
|
||||
* come from one build. A path pack rebuilt on its own (or a schedule left
|
||||
* from an older build) has other shapes at those indices, so its runs are
|
||||
* compared between their stops instead of along some other line. Packs that
|
||||
* predate the stamps are taken as a pair.
|
||||
* @param {{pathsBuiltAt?: string|null}} schedule
|
||||
* @param {{builtAt?: string|null}|null} pack
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pathsMatch(schedule, pack) {
|
||||
if (!pack) return false;
|
||||
if (!schedule?.pathsBuiltAt || !pack.builtAt) return true;
|
||||
return schedule.pathsBuiltAt === pack.builtAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the timetable has one run now: on its shape when the path pack has
|
||||
* it, else between its stops. Only compared with a live train's report, to
|
||||
* find the run it is on (stopsFor); never drawn.
|
||||
* @param {object} schedule From createSchedule.
|
||||
* @param {object|string} run A run from activeTrips, or a trip id.
|
||||
* @param {number} nowMs
|
||||
* @param {object|null} delays
|
||||
* @param {object|null} pack A path pack (createRailPathPack), when loaded.
|
||||
* @returns {object|null} scheduledPosition's result plus `lat`, `lon`,
|
||||
* `bearingDeg` (NaN when unknown) and `onShape`.
|
||||
*/
|
||||
export function scheduledRunPoint(schedule, run, nowMs, delays, pack) {
|
||||
const position = scheduledPosition(schedule, run, nowMs, delays);
|
||||
if (!position) return null;
|
||||
const path =
|
||||
position.shape !== null && pathsMatch(schedule, pack)
|
||||
? pack.pathAt(position.shape)
|
||||
: null;
|
||||
const point = path ? pointAtS(path, position.s) : null;
|
||||
if (point && Number.isFinite(point.lat) && Number.isFinite(point.lon))
|
||||
return { ...position, ...point, onShape: true };
|
||||
if (Number.isFinite(position.lat) && Number.isFinite(position.lon))
|
||||
return { ...position, bearingDeg: NaN, onShape: false };
|
||||
// A shaped pattern whose path pack is not here (yet): between the stops.
|
||||
const trip = schedule.trips.get(position.tripId);
|
||||
if (!trip) return null;
|
||||
const stopAt = (k) => (k >= 0 ? schedule.stops[trip.pattern.stops[k]] : null);
|
||||
const a = stopAt(
|
||||
position.prevStop >= 0 ? position.prevStop : position.nextStop,
|
||||
);
|
||||
const b =
|
||||
position.dwell || position.nextStop < 0 ? a : stopAt(position.nextStop);
|
||||
if (!a || !b) return null;
|
||||
const f = position.dwell ? 0 : position.fraction;
|
||||
return {
|
||||
...position,
|
||||
lat: a.lat + (b.lat - a.lat) * f,
|
||||
lon: a.lon + (b.lon - a.lon) * f,
|
||||
bearingDeg: NaN,
|
||||
onShape: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule tier for one layer instance.
|
||||
* @param {{source: object,
|
||||
* hasTripUpdates: (feedId: string) => boolean,
|
||||
* pathPackFor?: (feedId: string) => object|null,
|
||||
* now?: () => number,
|
||||
* setInterval?: Function, clearInterval?: Function,
|
||||
* onChange?: (feedId: string) => void}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRailSchedules({
|
||||
source,
|
||||
hasTripUpdates,
|
||||
pathPackFor = () => null,
|
||||
now = () => Date.now(),
|
||||
setInterval: startInterval = (fn, ms) => globalThis.setInterval(fn, ms),
|
||||
clearInterval: stopInterval = (id) => globalThis.clearInterval(id),
|
||||
onChange = () => {},
|
||||
}) {
|
||||
/**
|
||||
* feedId → `{status: 'loading'|'ready'|'none', schedule, retryAt, promise,
|
||||
* failures}`; `failures` counts failed loads in a row.
|
||||
*/
|
||||
const schedules = new Map();
|
||||
/** feedId → `{delays, at, timer, reasons: Set, controller}`. */
|
||||
const updates = new Map();
|
||||
/** `${feedId}\n${trainId}` → `{tripKey, run, via, at}` for live trains. */
|
||||
const matches = new Map();
|
||||
let selectedFeed = null;
|
||||
|
||||
/**
|
||||
* The feed's schedule, loading it on first ask.
|
||||
* @param {string} feedId
|
||||
* @returns {Promise<object|null>} The schedule, or null when it has none.
|
||||
*/
|
||||
function loadSchedule(feedId) {
|
||||
const known = schedules.get(feedId);
|
||||
if (known?.status === 'ready') return Promise.resolve(known.schedule);
|
||||
if (known?.status === 'loading') return known.promise;
|
||||
if (known?.status === 'none' && now() < known.retryAt)
|
||||
return Promise.resolve(null);
|
||||
if (typeof source?.requestSchedule !== 'function') {
|
||||
schedules.set(feedId, {
|
||||
status: 'none',
|
||||
schedule: null,
|
||||
retryAt: Infinity,
|
||||
});
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const slot = {
|
||||
status: 'loading',
|
||||
schedule: null,
|
||||
retryAt: Infinity,
|
||||
failures: known?.failures ?? 0,
|
||||
};
|
||||
slot.promise = (async () => {
|
||||
try {
|
||||
const response = await source.requestSchedule(feedId);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`rail schedule HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const raw = await response.json();
|
||||
let schedule;
|
||||
try {
|
||||
schedule = createSchedule(raw);
|
||||
if (schedule.feedId !== feedId)
|
||||
throw new TypeError('rail schedule pack is for another feed');
|
||||
} catch (error) {
|
||||
error.permanent = true;
|
||||
throw error;
|
||||
}
|
||||
slot.schedule = schedule;
|
||||
slot.status = 'ready';
|
||||
slot.failures = 0;
|
||||
onChange(feedId);
|
||||
return schedule;
|
||||
} catch (error) {
|
||||
slot.status = 'none';
|
||||
slot.failures += 1;
|
||||
// A 404 is "no timetable for this feed", not a fault to retry; a
|
||||
// malformed pack will not mend itself before the next build either.
|
||||
slot.retryAt =
|
||||
error?.status === 404 || error?.permanent === true
|
||||
? Infinity
|
||||
: now() + scheduleRetryDelayMs(slot.failures);
|
||||
if (error?.status !== 404)
|
||||
console.warn(
|
||||
`[Data:Rail] ${feedId} timetable unavailable:`,
|
||||
error?.message || error,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
slot.promise = null;
|
||||
}
|
||||
})();
|
||||
schedules.set(feedId, slot);
|
||||
return slot.promise;
|
||||
}
|
||||
|
||||
/** The feed's schedule if loaded; starts the load otherwise. */
|
||||
function scheduleFor(feedId) {
|
||||
const known = schedules.get(feedId);
|
||||
if (known?.status === 'ready') return known.schedule;
|
||||
void loadSchedule(feedId);
|
||||
return null;
|
||||
}
|
||||
|
||||
function updatesSlot(feedId) {
|
||||
let slot = updates.get(feedId);
|
||||
if (!slot) {
|
||||
slot = {
|
||||
delays: null,
|
||||
at: -Infinity,
|
||||
timer: null,
|
||||
reasons: new Set(),
|
||||
controller: null,
|
||||
};
|
||||
updates.set(feedId, slot);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
async function fetchUpdates(feedId) {
|
||||
const slot = updatesSlot(feedId);
|
||||
if (slot.controller || typeof source?.requestUpdates !== 'function') return;
|
||||
const controller = new AbortController();
|
||||
slot.controller = controller;
|
||||
try {
|
||||
const response = await source.requestUpdates(feedId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`);
|
||||
const body = await response.json();
|
||||
if (slot.controller !== controller) return;
|
||||
// Delays are as old as the server's read of the operator (`fetchedAt`),
|
||||
// not this answer: a cached copy, or the last good one replayed while
|
||||
// the operator fails, stops applying RAIL_UPDATES_MAX_AGE_MS after it,
|
||||
// as positions do (ingestion.js applySnapshot).
|
||||
const at = now();
|
||||
slot.delays = delaysFromUpdates(body);
|
||||
slot.at = Math.min(
|
||||
Number.isFinite(body?.fetchedAt) ? body.fetchedAt : at,
|
||||
at,
|
||||
);
|
||||
onChange(feedId);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError')
|
||||
console.warn(
|
||||
`[Data:Rail] ${feedId} trip updates unavailable:`,
|
||||
error?.message || error,
|
||||
);
|
||||
} finally {
|
||||
if (slot.controller === controller) slot.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a feed's trip updates while `reason` needs them. Reasons are
|
||||
* counted, so every reader shares one poll.
|
||||
* @param {string} feedId
|
||||
* @param {string} reason
|
||||
*/
|
||||
function watchUpdates(feedId, reason) {
|
||||
if (!hasTripUpdates(feedId)) return;
|
||||
const slot = updatesSlot(feedId);
|
||||
slot.reasons.add(reason);
|
||||
if (slot.timer !== null) return;
|
||||
void fetchUpdates(feedId);
|
||||
slot.timer = startInterval(
|
||||
() => void fetchUpdates(feedId),
|
||||
RAIL_UPDATES_POLL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function unwatchUpdates(feedId, reason) {
|
||||
const slot = updates.get(feedId);
|
||||
if (!slot) return;
|
||||
slot.reasons.delete(reason);
|
||||
if (slot.reasons.size || slot.timer === null) return;
|
||||
stopInterval(slot.timer);
|
||||
slot.timer = null;
|
||||
slot.controller?.abort();
|
||||
slot.controller = null;
|
||||
}
|
||||
|
||||
/** Current delays for a feed, or null when none are fresh. */
|
||||
function delaysFor(feedId) {
|
||||
const slot = updates.get(feedId);
|
||||
return slot?.delays && now() - slot.at <= RAIL_UPDATES_MAX_AGE_MS
|
||||
? slot.delays
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The run a live train is on. Its trip id when the timetable has it (live
|
||||
* GTFS trip ids are normally the static ones); otherwise, as a fallback,
|
||||
* the running run of its route nearest the train's report, kept for a
|
||||
* minute so the stop list does not hop between neighbouring runs. A train
|
||||
* its own report says is `liveDelayMs` late is compared with where the
|
||||
* timetable had each run that long ago.
|
||||
*/
|
||||
function matchRun(schedule, train, nowMs, delays, liveDelayMs = 0) {
|
||||
const tripId = train.tripId || null;
|
||||
if (tripId && schedule.runsByTrip.has(tripId))
|
||||
return { trip: tripId, via: 'trip' };
|
||||
const record = train.record || {};
|
||||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||||
return null;
|
||||
const key = `${train.feedId}\n${record.id ?? ''}`;
|
||||
const tripKey = `${tripId ?? ''}\n${train.routeId ?? ''}`;
|
||||
const kept = matches.get(key);
|
||||
if (kept && kept.tripKey === tripKey && nowMs - kept.at < MATCH_KEEP_MS)
|
||||
return kept.run ? { trip: kept.run, via: 'nearest' } : null;
|
||||
const pack = pathPackFor(train.feedId);
|
||||
// A uniform delay is a time shift: the run as timetabled that long ago.
|
||||
const matchMs = nowMs - liveDelayMs;
|
||||
let best = null;
|
||||
let bestM = MATCH_NEAREST_MAX_M;
|
||||
for (const run of activeTrips(schedule, matchMs, {
|
||||
delays,
|
||||
beforeMs: MATCH_BEFORE_MS,
|
||||
afterMs: MATCH_AFTER_MS,
|
||||
})) {
|
||||
if (train.routeId && run.routeId !== train.routeId) continue;
|
||||
const point = scheduledRunPoint(schedule, run, matchMs, delays, pack);
|
||||
if (!point) continue;
|
||||
const m = distanceM(record.lat, record.lon, point.lat, point.lon);
|
||||
if (m < bestM) {
|
||||
bestM = m;
|
||||
best = run;
|
||||
}
|
||||
}
|
||||
matches.set(key, { tripKey, run: best, at: nowMs });
|
||||
return best ? { trip: best, via: 'nearest' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stops provider (see createRailLayer): the selected train's stops and
|
||||
* headsign, or null until the feed's timetable has loaded (or when it has
|
||||
* none, or the train matches no run).
|
||||
* @param {{feedId: string, tripId: string|null, routeId: string|null,
|
||||
* record: object}} train
|
||||
* @param {number} nowMs
|
||||
* @returns {{stops: object[], headsign: string|null,
|
||||
* match: 'trip'|'nearest'}|null}
|
||||
*/
|
||||
function stopsFor(train, nowMs) {
|
||||
if (!train?.feedId) return null;
|
||||
const schedule = scheduleFor(train.feedId);
|
||||
if (!schedule) return null;
|
||||
// A feed without trip updates: the delay the train's report states.
|
||||
const liveDelay = hasTripUpdates(train.feedId)
|
||||
? null
|
||||
: liveDelaySec(train.record);
|
||||
const updates = liveDelay === null ? delaysFor(train.feedId) : null;
|
||||
const match = matchRun(
|
||||
schedule,
|
||||
train,
|
||||
nowMs,
|
||||
updates,
|
||||
(liveDelay ?? 0) * 1000,
|
||||
);
|
||||
if (!match) return null;
|
||||
const tripId =
|
||||
typeof match.trip === 'string' ? match.trip : match.trip.tripId;
|
||||
const delays =
|
||||
liveDelay === null
|
||||
? updates
|
||||
: {
|
||||
[tripId]: {
|
||||
delaySec: liveDelay,
|
||||
startDate:
|
||||
typeof match.trip === 'string' ? null : match.trip.serviceDate,
|
||||
},
|
||||
};
|
||||
const stops = tripStops(schedule, match.trip, nowMs, delays);
|
||||
if (!stops.length) return null;
|
||||
const trip = schedule.trips.get(tripId);
|
||||
return {
|
||||
stops,
|
||||
headsign: trip?.headsign || null,
|
||||
match: match.via,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A train was selected (or the selection cleared): poll its delays once
|
||||
* its feed's timetable is loaded. Delays are shown only at the
|
||||
* timetable's stops, so a feed without one is not polled.
|
||||
*/
|
||||
function onSelect(train) {
|
||||
const feedId = train?.feedId || null;
|
||||
if (feedId === selectedFeed) return;
|
||||
if (selectedFeed) unwatchUpdates(selectedFeed, 'selection');
|
||||
selectedFeed = feedId;
|
||||
if (!feedId) return;
|
||||
void loadSchedule(feedId).then((schedule) => {
|
||||
if (schedule && selectedFeed === feedId)
|
||||
watchUpdates(feedId, 'selection');
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop every poll; loaded schedules stay for the next enable. */
|
||||
function stop() {
|
||||
onSelect(null);
|
||||
for (const [feedId, slot] of updates)
|
||||
for (const reason of [...slot.reasons]) unwatchUpdates(feedId, reason);
|
||||
matches.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release what is held for a feed no longer in use: its parsed schedule,
|
||||
* its last trip updates and its live-train matches. A load or a poll
|
||||
* still under way is left to finish; the feed loads again when next used.
|
||||
* @param {string} feedId
|
||||
*/
|
||||
function forget(feedId) {
|
||||
if (schedules.get(feedId)?.status !== 'loading') schedules.delete(feedId);
|
||||
const slot = updates.get(feedId);
|
||||
if (slot && slot.reasons.size === 0 && slot.timer === null) {
|
||||
slot.controller?.abort();
|
||||
updates.delete(feedId);
|
||||
}
|
||||
const prefix = `${feedId}
|
||||
`;
|
||||
for (const key of [...matches.keys()])
|
||||
if (key.startsWith(prefix)) matches.delete(key);
|
||||
}
|
||||
|
||||
/** Counts for QA probes and tests. */
|
||||
function stats() {
|
||||
return {
|
||||
schedules: Object.fromEntries(
|
||||
[...schedules].map(([feedId, slot]) => [
|
||||
feedId,
|
||||
slot.status === 'ready'
|
||||
? {
|
||||
status: 'ready',
|
||||
trips: slot.schedule.trips.size,
|
||||
runs: slot.schedule.instances.length,
|
||||
}
|
||||
: { status: slot.status },
|
||||
]),
|
||||
),
|
||||
updates: Object.fromEntries(
|
||||
[...updates].map(([feedId, slot]) => [
|
||||
feedId,
|
||||
{
|
||||
polling: slot.timer !== null,
|
||||
reasons: [...slot.reasons],
|
||||
trips: slot.delays ? Object.keys(slot.delays).length : 0,
|
||||
ageMs: Number.isFinite(slot.at) ? now() - slot.at : null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stopsFor,
|
||||
onSelect,
|
||||
loadSchedule,
|
||||
scheduleFor,
|
||||
watchUpdates,
|
||||
unwatchUpdates,
|
||||
delaysFor,
|
||||
stop,
|
||||
forget,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// The Rail layer's schedule tier, the stops provider of live trains: null
|
||||
// before the pack loads, trip-id matching with a nearest-run fallback, delay
|
||||
// polling only while a train is selected on a feed with a timetable. It
|
||||
// places no train itself. Built from a small synthetic schedule pack and path
|
||||
// pack.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MATCH_NEAREST_MAX_M,
|
||||
RAIL_UPDATES_MAX_AGE_MS,
|
||||
RAIL_UPDATES_POLL_MS,
|
||||
SCHEDULE_RETRY_FIRST_MS,
|
||||
SCHEDULE_RETRY_MS,
|
||||
createRailSchedules,
|
||||
delaysFromUpdates,
|
||||
pathsMatch,
|
||||
scheduledRunPoint,
|
||||
scheduleRetryDelayMs,
|
||||
} from './schedules.js';
|
||||
import * as schedules from './schedules.js';
|
||||
import { createSchedule } from '../../data/railSchedule.js';
|
||||
import { createRailPathPack } from '../../data/railPaths.js';
|
||||
|
||||
const FEED = 'test-rail';
|
||||
const LAT = 50;
|
||||
const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180);
|
||||
const at = (hhmm) => Date.parse(`2026-11-06T${hhmm}:00Z`);
|
||||
|
||||
/** Three stops due east 0.1° apart; T1 runs 10:00 → 10:20, dwelling at B. */
|
||||
const rawSchedule = Object.freeze({
|
||||
version: 1,
|
||||
feedId: FEED,
|
||||
timezone: 'UTC',
|
||||
window: { from: '2026-11-05', to: '2026-11-08' },
|
||||
routes: { R1: { type: 2 }, BUS: { type: 3 } },
|
||||
stops: [
|
||||
{ id: 'A', name: 'Alpha', lat: LAT, lon: 0 },
|
||||
{ id: 'B', name: 'Bravo', lat: LAT, lon: 0.1 },
|
||||
{ id: 'C', name: 'Charlie', lat: LAT, lon: 0.2 },
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
shape: 0,
|
||||
stops: [0, 1, 2],
|
||||
seq: [1, 2, 3],
|
||||
dist: [0, STEP_M, 2 * STEP_M],
|
||||
},
|
||||
{
|
||||
shape: 1,
|
||||
stops: [2, 1, 0],
|
||||
seq: [1, 2, 3],
|
||||
dist: [0, STEP_M, 2 * STEP_M],
|
||||
},
|
||||
],
|
||||
profiles: [[0, 0, 600, 660, 1260, 1260]],
|
||||
services: { WK: ['2026-11-06'] },
|
||||
trips: {
|
||||
T1: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: 10 * 3600,
|
||||
route: 'R1',
|
||||
service: 'WK',
|
||||
headsign: 'Charlie',
|
||||
name: 'Train 1',
|
||||
},
|
||||
T2: {
|
||||
pattern: 1,
|
||||
profile: 0,
|
||||
startSec: 10 * 3600,
|
||||
route: 'R1',
|
||||
service: 'WK',
|
||||
headsign: 'Alpha',
|
||||
name: 'Train 2',
|
||||
},
|
||||
B9: {
|
||||
pattern: 0,
|
||||
profile: 0,
|
||||
startSec: 10 * 3600,
|
||||
route: 'BUS',
|
||||
service: 'WK',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rawPaths = Object.freeze({
|
||||
version: 1,
|
||||
feedId: FEED,
|
||||
shapes: [
|
||||
[LAT, 0, LAT, 0.1, LAT, 0.2],
|
||||
[LAT, 0.2, LAT, 0.1, LAT, 0],
|
||||
],
|
||||
dist: [
|
||||
[0, STEP_M, 2 * STEP_M],
|
||||
[0, STEP_M, 2 * STEP_M],
|
||||
],
|
||||
trips: { T1: 0, T2: 1 },
|
||||
routes: { R1: [0, 1] },
|
||||
});
|
||||
|
||||
const reply = (body, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
async json() {
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|
||||
};
|
||||
|
||||
/** A provider over a fake source, fake clock and fake interval timers. */
|
||||
function harness({
|
||||
schedule = () => reply(rawSchedule),
|
||||
updates = () => reply({ trips: [] }),
|
||||
tripUpdates = true,
|
||||
clock = at('10:05'),
|
||||
} = {}) {
|
||||
const calls = { schedule: 0, updates: 0 };
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const h = {
|
||||
calls,
|
||||
timers,
|
||||
now: clock,
|
||||
provider: null,
|
||||
};
|
||||
h.provider = createRailSchedules({
|
||||
source: {
|
||||
async requestSchedule(feedId) {
|
||||
calls.schedule += 1;
|
||||
assert.equal(feedId, FEED);
|
||||
return schedule();
|
||||
},
|
||||
async requestUpdates(feedId) {
|
||||
calls.updates += 1;
|
||||
assert.equal(feedId, FEED);
|
||||
return updates();
|
||||
},
|
||||
},
|
||||
hasTripUpdates: () => tripUpdates,
|
||||
pathPackFor: () => createRailPathPack(rawPaths),
|
||||
now: () => h.now,
|
||||
setInterval: (fn, ms) => {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id) => timers.delete(id),
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
const liveTrain = (record) => ({
|
||||
feedId: FEED,
|
||||
tripId: record.tripId ?? null,
|
||||
routeId: record.routeId ?? null,
|
||||
record,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Where a run is, compared with a live train's report to find its run
|
||||
|
||||
test('the tier places no trains: it has no train records to offer', () => {
|
||||
assert.equal(schedules.scheduledTrainRecords, undefined);
|
||||
const provider = createRailSchedules({
|
||||
source: {},
|
||||
hasTripUpdates: () => false,
|
||||
});
|
||||
assert.deepEqual(Object.keys(provider).sort(), [
|
||||
'delaysFor',
|
||||
'forget',
|
||||
'loadSchedule',
|
||||
'onSelect',
|
||||
'scheduleFor',
|
||||
'stats',
|
||||
'stop',
|
||||
'stopsFor',
|
||||
'unwatchUpdates',
|
||||
'watchUpdates',
|
||||
]);
|
||||
});
|
||||
|
||||
test('without the path pack a run’s point lies between its stops', () => {
|
||||
const schedule = createSchedule(rawSchedule);
|
||||
const point = scheduledRunPoint(schedule, 'T1', at('10:05'), null, null);
|
||||
assert.equal(point.onShape, false);
|
||||
assert.ok(Math.abs(point.lon - 0.05) < 1e-9);
|
||||
assert.ok(Number.isNaN(point.bearingDeg));
|
||||
const onShape = scheduledRunPoint(
|
||||
schedule,
|
||||
'T1',
|
||||
at('10:05'),
|
||||
null,
|
||||
createRailPathPack(rawPaths),
|
||||
);
|
||||
assert.equal(onShape.onShape, true);
|
||||
assert.ok(Math.abs(onShape.bearingDeg - 90) < 1e-6);
|
||||
});
|
||||
|
||||
test('a schedule from another build than its path pack stays between stops', () => {
|
||||
const built = '2026-11-10T08:00:00.000Z';
|
||||
const schedule = createSchedule({ ...rawSchedule, pathsBuiltAt: built });
|
||||
const same = createRailPathPack({ ...rawPaths, builtAt: built });
|
||||
const rebuilt = createRailPathPack({
|
||||
...rawPaths,
|
||||
builtAt: '2026-11-10T09:00:00.000Z',
|
||||
});
|
||||
assert.equal(schedule.pathsBuiltAt, built);
|
||||
assert.equal(same.builtAt, built);
|
||||
assert.equal(pathsMatch(schedule, same), true);
|
||||
assert.equal(pathsMatch(schedule, rebuilt), false);
|
||||
assert.equal(pathsMatch(schedule, null), false);
|
||||
// Packs from before the stamps are taken as a pair.
|
||||
assert.equal(pathsMatch(createSchedule(rawSchedule), rebuilt), true);
|
||||
const onShape = scheduledRunPoint(schedule, 'T1', at('10:05'), null, same);
|
||||
assert.equal(onShape.onShape, true);
|
||||
const between = scheduledRunPoint(schedule, 'T1', at('10:05'), null, rebuilt);
|
||||
assert.equal(between.onShape, false);
|
||||
assert.ok(Math.abs(between.lon - 0.05) < 1e-9);
|
||||
});
|
||||
|
||||
test('trip updates become the delays map the timetable reads', () => {
|
||||
const delays = delaysFromUpdates({
|
||||
trips: [
|
||||
{
|
||||
tripId: 'T1',
|
||||
routeId: 'R1',
|
||||
startDate: '20261106',
|
||||
relationship: 'SCHEDULED',
|
||||
delaySec: null,
|
||||
stops: [{ stopId: 'B', arrival: { time: null, delay: 240 } }],
|
||||
},
|
||||
{ tripId: '', delaySec: 60 },
|
||||
null,
|
||||
],
|
||||
});
|
||||
assert.deepEqual(Object.keys(delays), ['T1']);
|
||||
assert.equal(delays.T1.delaySec, null);
|
||||
assert.equal(delays.T1.stops[0].arrival.delay, 240);
|
||||
assert.deepEqual(delaysFromUpdates(null), {});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stops provider
|
||||
|
||||
test('the provider answers null until the timetable has loaded', async () => {
|
||||
const h = harness();
|
||||
const train = liveTrain({
|
||||
id: 'v1',
|
||||
tripId: 'T1',
|
||||
routeId: 'R1',
|
||||
lat: LAT,
|
||||
lon: 0.05,
|
||||
});
|
||||
assert.equal(h.provider.stopsFor(train, h.now), null);
|
||||
assert.equal(h.calls.schedule, 1);
|
||||
// Asking again while it loads does not fetch twice.
|
||||
assert.equal(h.provider.stopsFor(train, h.now), null);
|
||||
assert.equal(h.calls.schedule, 1);
|
||||
await flush();
|
||||
const trip = h.provider.stopsFor(train, h.now);
|
||||
assert.equal(trip.match, 'trip');
|
||||
assert.equal(trip.headsign, 'Charlie');
|
||||
assert.deepEqual(
|
||||
trip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]),
|
||||
[
|
||||
['Alpha', 'passed', '10:00'],
|
||||
['Bravo', 'next', '10:10'],
|
||||
['Charlie', 'upcoming', '10:21'],
|
||||
],
|
||||
);
|
||||
assert.equal(trip.stops[1].relative, 'T−5');
|
||||
assert.equal(h.calls.schedule, 1);
|
||||
assert.equal(h.provider.stopsFor(null, h.now), null);
|
||||
});
|
||||
|
||||
test('a live trip id the timetable lacks falls back to the nearest run', async () => {
|
||||
const h = harness();
|
||||
await h.provider.loadSchedule(FEED);
|
||||
const near = h.provider.stopsFor(
|
||||
liveTrain({
|
||||
id: 'v7',
|
||||
tripId: 'live-123',
|
||||
routeId: 'R1',
|
||||
lat: LAT,
|
||||
lon: 0.149,
|
||||
}),
|
||||
h.now,
|
||||
);
|
||||
// At 10:05 T2 is at 0.15 (westbound) and T1 at 0.05: T2 is nearest.
|
||||
assert.equal(near.match, 'nearest');
|
||||
assert.equal(near.headsign, 'Alpha');
|
||||
// A train with no route searches every run.
|
||||
const noRoute = h.provider.stopsFor(
|
||||
liveTrain({ id: 'v8', tripId: null, lat: LAT, lon: 0.051 }),
|
||||
h.now,
|
||||
);
|
||||
assert.equal(noRoute.headsign, 'Charlie');
|
||||
// Too far from any run: no stops rather than a wrong train's.
|
||||
const farLon = 0.05 + (2 * MATCH_NEAREST_MAX_M) / (STEP_M * 10);
|
||||
assert.equal(
|
||||
h.provider.stopsFor(
|
||||
liveTrain({
|
||||
id: 'v9',
|
||||
tripId: 'x',
|
||||
routeId: 'R1',
|
||||
lat: LAT + 0.1,
|
||||
lon: farLon,
|
||||
}),
|
||||
h.now,
|
||||
),
|
||||
null,
|
||||
);
|
||||
// Another route's train does not take a run of R1.
|
||||
assert.equal(
|
||||
h.provider.stopsFor(
|
||||
liveTrain({ id: 'v10', tripId: 'x', routeId: 'R9', lat: LAT, lon: 0.05 }),
|
||||
h.now,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('delays are polled every 30 s only while a train on the feed is selected', async () => {
|
||||
const h = harness({
|
||||
updates: () =>
|
||||
reply({
|
||||
trips: [
|
||||
{
|
||||
tripId: 'T1',
|
||||
delaySec: 240,
|
||||
stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const train = liveTrain({
|
||||
id: 'v1',
|
||||
tripId: 'T1',
|
||||
routeId: 'R1',
|
||||
lat: LAT,
|
||||
lon: 0.05,
|
||||
});
|
||||
assert.equal(h.calls.updates, 0);
|
||||
h.provider.onSelect(train);
|
||||
await flush();
|
||||
assert.equal(h.calls.updates, 1);
|
||||
assert.equal(h.timers.size, 1);
|
||||
assert.equal([...h.timers.values()][0].ms, RAIL_UPDATES_POLL_MS);
|
||||
assert.equal(RAIL_UPDATES_POLL_MS, 30_000);
|
||||
const trip = h.provider.stopsFor(train, h.now);
|
||||
const bravo = trip.stops[1];
|
||||
assert.equal(bravo.arrivalDelaySec, 240);
|
||||
assert.equal(bravo.expectedArrival, '10:14');
|
||||
|
||||
// The interval re-reads; reselecting on the same feed adds no second poll.
|
||||
[...h.timers.values()][0].fn();
|
||||
await flush();
|
||||
assert.equal(h.calls.updates, 2);
|
||||
h.provider.onSelect({ ...train, record: { ...train.record, id: 'v2' } });
|
||||
assert.equal(h.timers.size, 1);
|
||||
|
||||
// Delays too old to trust are not applied.
|
||||
h.now += RAIL_UPDATES_MAX_AGE_MS + 1;
|
||||
assert.equal(h.provider.delaysFor(FEED), null);
|
||||
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0);
|
||||
|
||||
// Clearing the selection stops the poll.
|
||||
h.provider.onSelect(null);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('delays are as old as the server’s read, even when it replays them', async () => {
|
||||
let readAt = null;
|
||||
const h = harness({
|
||||
updates: () =>
|
||||
reply({
|
||||
fetchedAt: readAt ?? h.now,
|
||||
trips: [
|
||||
{
|
||||
tripId: 'T1',
|
||||
delaySec: 240,
|
||||
stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const train = liveTrain({
|
||||
id: 'v1',
|
||||
tripId: 'T1',
|
||||
routeId: 'R1',
|
||||
lat: LAT,
|
||||
lon: 0.05,
|
||||
});
|
||||
h.provider.onSelect(train);
|
||||
await flush();
|
||||
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240);
|
||||
|
||||
// The operator fails: each poll is answered with the server's last copy,
|
||||
// still stamped with the time it was read.
|
||||
readAt = h.now;
|
||||
const poll = [...h.timers.values()][0].fn;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
h.now += RAIL_UPDATES_POLL_MS;
|
||||
poll();
|
||||
await flush();
|
||||
}
|
||||
assert.equal(h.calls.updates, 4);
|
||||
assert.ok(3 * RAIL_UPDATES_POLL_MS > RAIL_UPDATES_MAX_AGE_MS);
|
||||
assert.equal(h.provider.delaysFor(FEED), null);
|
||||
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0);
|
||||
|
||||
// A fresh read applies again; a server clock ahead counts as now.
|
||||
readAt = h.now + 5_000;
|
||||
poll();
|
||||
await flush();
|
||||
assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240);
|
||||
h.now += RAIL_UPDATES_MAX_AGE_MS;
|
||||
assert.notEqual(h.provider.delaysFor(FEED), null);
|
||||
});
|
||||
|
||||
test('a selected train’s delays are not polled when its feed has no timetable', async () => {
|
||||
const h = harness({
|
||||
schedule: () => reply({ error: 'No rail schedule' }, 404),
|
||||
});
|
||||
h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' }));
|
||||
await flush();
|
||||
assert.equal(h.calls.schedule, 1);
|
||||
assert.equal(h.calls.updates, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.provider.stats().updates[FEED], undefined);
|
||||
|
||||
// With the pack, the same selection polls; cleared before the pack
|
||||
// arrives, it starts no poll.
|
||||
const ready = harness();
|
||||
ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' }));
|
||||
ready.provider.onSelect(null);
|
||||
await flush();
|
||||
assert.equal(ready.calls.updates, 0);
|
||||
assert.equal(ready.timers.size, 0);
|
||||
ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' }));
|
||||
await flush();
|
||||
assert.equal(ready.calls.updates, 1);
|
||||
assert.equal(ready.timers.size, 1);
|
||||
});
|
||||
|
||||
test('every reader of a feed’s delays shares one poll', async () => {
|
||||
const h = harness();
|
||||
h.provider.watchUpdates(FEED, 'probe');
|
||||
h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' }));
|
||||
await flush();
|
||||
assert.deepEqual(h.provider.stats().updates[FEED].reasons, [
|
||||
'probe',
|
||||
'selection',
|
||||
]);
|
||||
assert.equal(h.timers.size, 1);
|
||||
h.provider.onSelect(null);
|
||||
assert.equal(h.timers.size, 1);
|
||||
h.provider.unwatchUpdates(FEED, 'probe');
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.provider.watchUpdates(FEED, 'probe');
|
||||
h.provider.stop();
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('a feed without trip updates is never polled for them', async () => {
|
||||
const h = harness({ tripUpdates: false });
|
||||
h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' }));
|
||||
h.provider.watchUpdates(FEED, 'probe');
|
||||
await flush();
|
||||
assert.equal(h.calls.updates, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.provider.delaysFor(FEED), null);
|
||||
});
|
||||
|
||||
test('without trip updates, the delay a train reports moves its stops', async () => {
|
||||
// Irish Rail and SJ Nord publish no trip updates, but each report says
|
||||
// how late the train is: 9 minutes here.
|
||||
const h = harness({ tripUpdates: false, clock: at('10:12') });
|
||||
await h.provider.loadSchedule(FEED);
|
||||
const late = (record) =>
|
||||
h.provider.stopsFor(liveTrain({ routeId: 'R1', ...record }), h.now);
|
||||
const byTrip = late({
|
||||
id: 'v1',
|
||||
tripId: 'T1',
|
||||
lat: LAT,
|
||||
lon: 0.06,
|
||||
delaySec: 540,
|
||||
});
|
||||
assert.equal(byTrip.match, 'trip');
|
||||
// On time it would have left Bravo at 10:11; 9 minutes late it is still
|
||||
// to reach it.
|
||||
assert.deepEqual(
|
||||
byTrip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]),
|
||||
[
|
||||
['Alpha', 'passed', '10:09'],
|
||||
['Bravo', 'next', '10:19'],
|
||||
['Charlie', 'upcoming', '10:30'],
|
||||
],
|
||||
);
|
||||
assert.equal(byTrip.stops[1].arrivalDelaySec, 540);
|
||||
// Without a usable trip id the run is found where its timetable was 9
|
||||
// minutes ago: T1 at 10:03, not T2, which is nearer the report at 10:12.
|
||||
const byPlace = late({
|
||||
id: 'v2',
|
||||
tripId: 'SJN:DatedServiceJourney:1',
|
||||
lat: LAT,
|
||||
lon: 0.03,
|
||||
delaySec: 540,
|
||||
});
|
||||
assert.equal(byPlace.match, 'nearest');
|
||||
assert.equal(byPlace.headsign, 'Charlie');
|
||||
assert.equal(byPlace.stops[1].expectedArrival, '10:19');
|
||||
// A train 25 minutes late is still on its run after the timetable ends.
|
||||
h.now = at('10:40');
|
||||
const after = late({
|
||||
id: 'v3',
|
||||
tripId: 'T1',
|
||||
lat: LAT,
|
||||
lon: 0.18,
|
||||
delaySec: 1500,
|
||||
});
|
||||
assert.equal(after.stops[2].state, 'next');
|
||||
assert.equal(after.stops[2].expectedArrival, '10:46');
|
||||
});
|
||||
|
||||
test('a feed with trip updates takes its delays from them, not the report', async () => {
|
||||
const h = harness({ clock: at('10:12') });
|
||||
await h.provider.loadSchedule(FEED);
|
||||
const trip = h.provider.stopsFor(
|
||||
liveTrain({
|
||||
id: 'v1',
|
||||
tripId: 'T1',
|
||||
routeId: 'R1',
|
||||
lat: LAT,
|
||||
lon: 0.15,
|
||||
delaySec: 540,
|
||||
}),
|
||||
h.now,
|
||||
);
|
||||
assert.deepEqual(
|
||||
trip.stops.map((stop) => stop.state),
|
||||
['passed', 'passed', 'next'],
|
||||
);
|
||||
assert.equal(trip.stops[2].expectedArrival, '10:21');
|
||||
});
|
||||
|
||||
test('no timetable (404) is final; a failed load is retried later', async () => {
|
||||
const missing = harness({
|
||||
schedule: () => reply({ error: 'No rail schedule' }, 404),
|
||||
});
|
||||
const train = liveTrain({ id: 'v1', tripId: 'T1' });
|
||||
assert.equal(missing.provider.stopsFor(train, missing.now), null);
|
||||
await flush();
|
||||
missing.now += SCHEDULE_RETRY_MS * 10;
|
||||
assert.equal(missing.provider.stopsFor(train, missing.now), null);
|
||||
await flush();
|
||||
assert.equal(missing.calls.schedule, 1);
|
||||
assert.equal(await missing.provider.loadSchedule(FEED), null);
|
||||
|
||||
let fail = true;
|
||||
const flaky = harness({
|
||||
schedule: () => (fail ? reply({}, 503) : reply(rawSchedule)),
|
||||
});
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
fail = false;
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
assert.equal(flaky.calls.schedule, 1);
|
||||
flaky.now += SCHEDULE_RETRY_MS;
|
||||
assert.ok(await flaky.provider.loadSchedule(FEED));
|
||||
assert.equal(flaky.calls.schedule, 2);
|
||||
// A malformed pack is not fetched again.
|
||||
const broken = harness({ schedule: () => reply({ version: 99 }) });
|
||||
assert.equal(await broken.provider.loadSchedule(FEED), null);
|
||||
broken.now += SCHEDULE_RETRY_MS * 10;
|
||||
assert.equal(await broken.provider.loadSchedule(FEED), null);
|
||||
assert.equal(broken.calls.schedule, 1);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test('a failing pack is tried again after 30 s, then twice as long each time, up to ten minutes', async () => {
|
||||
assert.deepEqual(
|
||||
[1, 2, 3, 4, 5, 6, 7].map(scheduleRetryDelayMs),
|
||||
[30_000, 60_000, 120_000, 240_000, 480_000, 600_000, 600_000],
|
||||
);
|
||||
let fail = true;
|
||||
const flaky = harness({
|
||||
schedule: () => (fail ? reply({}, 502) : reply(rawSchedule)),
|
||||
});
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
// One transient 5xx: back within a minute, not ten.
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
flaky.now += SCHEDULE_RETRY_FIRST_MS - 1;
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
assert.equal(flaky.calls.schedule, 1, 'not before 30 s');
|
||||
flaky.now += 1;
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
assert.equal(flaky.calls.schedule, 2);
|
||||
// Failing again, it waits twice as long.
|
||||
flaky.now += 2 * SCHEDULE_RETRY_FIRST_MS - 1;
|
||||
assert.equal(await flaky.provider.loadSchedule(FEED), null);
|
||||
assert.equal(flaky.calls.schedule, 2, 'not before 60 s');
|
||||
fail = false;
|
||||
flaky.now += 1;
|
||||
assert.ok(await flaky.provider.loadSchedule(FEED));
|
||||
assert.equal(flaky.calls.schedule, 3);
|
||||
// Loaded, it is never fetched again.
|
||||
flaky.now += SCHEDULE_RETRY_MS * 10;
|
||||
assert.ok(await flaky.provider.loadSchedule(FEED));
|
||||
assert.equal(flaky.calls.schedule, 3);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test('a forgotten feed gives back its timetable and loads it again when used', async () => {
|
||||
const h = harness();
|
||||
await h.provider.loadSchedule(FEED);
|
||||
assert.equal(h.provider.stats().schedules[FEED].status, 'ready');
|
||||
assert.equal(h.calls.schedule, 1);
|
||||
h.provider.forget(FEED);
|
||||
assert.equal(h.provider.stats().schedules[FEED], undefined);
|
||||
await h.provider.loadSchedule(FEED);
|
||||
assert.equal(h.calls.schedule, 2);
|
||||
// Trip updates still polled for a reason are not dropped.
|
||||
h.provider.watchUpdates(FEED, 'probe');
|
||||
await flush();
|
||||
h.provider.forget(FEED);
|
||||
assert.ok(h.provider.stats().updates[FEED].polling);
|
||||
h.provider.unwatchUpdates(FEED, 'probe');
|
||||
h.provider.forget(FEED);
|
||||
assert.equal(h.provider.stats().updates[FEED], undefined);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_ID,
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
SELECTED_CARD_REFRESH_MS,
|
||||
buildRailSelectionCopy,
|
||||
createRailSelectedOverlayEntry,
|
||||
} from './policy.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Click-to-select one train, a card that follows it, and the camera handed
|
||||
* to it (follow.js). Escape or a click on anything else deselects it.
|
||||
* @param {object} context
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createSelection({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
/** The card's anchor: the marker's current position, read on every paint. */
|
||||
function anchorFor(entry) {
|
||||
return () => {
|
||||
const p = entry.marker?.show !== false ? entry.marker?.position : null;
|
||||
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
|
||||
};
|
||||
}
|
||||
|
||||
function refreshSelectedCard(force) {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
if (!entry) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS)
|
||||
return;
|
||||
state._selectedCardAt = now;
|
||||
const feed = getRegisteredRailFeed(entry.feedId);
|
||||
if (!feed) return;
|
||||
const copy = buildRailSelectionCopy(
|
||||
feed,
|
||||
entry.record,
|
||||
now,
|
||||
entry.fetchedAt,
|
||||
entry,
|
||||
t,
|
||||
state._trip,
|
||||
);
|
||||
const text = JSON.stringify(copy);
|
||||
if (!force && text === state._selectedCardText) return;
|
||||
state._selectedCardText = text;
|
||||
const card = createRailSelectedOverlayEntry(
|
||||
entry.key,
|
||||
anchorFor(entry),
|
||||
copy,
|
||||
entry.category,
|
||||
);
|
||||
state._overlayHost.setEntries(
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_ID,
|
||||
card ? [card] : [],
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a train without touching the camera. Called by a click (through
|
||||
* selectAndFocusTrain) and by the duplicate filter, which moves the
|
||||
* selection to another feed's copy of the same train; a follow on the old
|
||||
* copy then glides to the new one.
|
||||
* @param {string} key
|
||||
*/
|
||||
function selectTrain(key) {
|
||||
if (!state._trains.has(key)) return;
|
||||
const hadSelection = state._selectedKey !== null;
|
||||
const previous = state._trains.get(state._selectedKey);
|
||||
state._selectedKey = key;
|
||||
const entry = state._trains.get(key);
|
||||
if (previous && previous !== entry) parts.follow.handover(entry);
|
||||
// The first selection dims every other train; a change of selection only
|
||||
// swaps the two trains involved.
|
||||
if (!hadSelection) parts.rendering.paintAll();
|
||||
else {
|
||||
if (previous) parts.rendering.paint(previous);
|
||||
parts.rendering.paint(entry);
|
||||
}
|
||||
// Line and stops first, so the card is built with this trip's stops.
|
||||
parts.routes.onSelectionChanged();
|
||||
refreshSelectedCard(true);
|
||||
// Its feed is polled for it at any distance from now on.
|
||||
parts.viewport.requestProximityCheck();
|
||||
governorRequestRender('rail-select');
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the train the user picked and give it the camera (follow.js): a
|
||||
* moving train is followed, any other flown to once. Picking the train
|
||||
* already followed keeps the follow as it is.
|
||||
* @param {string} key
|
||||
*/
|
||||
function selectAndFocusTrain(key) {
|
||||
if (!state._trains.has(key)) return;
|
||||
const following = parts.follow.isFollowing(key);
|
||||
// Another train's follow lets go first, so this is never taken for a
|
||||
// move between two copies of one train.
|
||||
if (!following) parts.follow.release();
|
||||
selectTrain(key);
|
||||
if (!following) parts.follow.focus(state._trains.get(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect, letting go of the camera where it is.
|
||||
* @param {{untrack?: boolean}} [options] `untrack: false` when another
|
||||
* camera owner took over and its tracked entity must stay.
|
||||
*/
|
||||
function clearSelection({ untrack = true } = {}) {
|
||||
const hadSelection = state._selectedKey !== null;
|
||||
parts.follow.release({ untrack });
|
||||
state._selectedKey = null;
|
||||
state._selectedCardText = null;
|
||||
if (hadSelection) {
|
||||
parts.rendering.paintAll();
|
||||
parts.routes.onSelectionChanged();
|
||||
// A feed held for the train goes back to the viewport's rules.
|
||||
parts.viewport.requestProximityCheck();
|
||||
}
|
||||
state._overlayHost.setEntries(
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
governorRequestRender('rail-deselect');
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && state._selectedKey) clearSelection();
|
||||
}
|
||||
|
||||
function installClickHandler(viewer) {
|
||||
if (state._clickHandler) return;
|
||||
state._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
state._clickHandler.setInputAction((click) => {
|
||||
if (!state._enabled || !isPointerFree()) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = typeof picked?.id === 'string' ? picked.id : null;
|
||||
if (id && state._trains.has(id)) selectAndFocusTrain(id);
|
||||
else if (state._selectedKey) clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
globalThis.document?.addEventListener?.('keydown', onKeyDown);
|
||||
parts.follow.install(viewer);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
state._clickHandler?.destroy();
|
||||
state._clickHandler = null;
|
||||
globalThis.document?.removeEventListener?.('keydown', onKeyDown);
|
||||
parts.follow.uninstall();
|
||||
}
|
||||
|
||||
return {
|
||||
refreshSelectedCard,
|
||||
selectTrain,
|
||||
selectAndFocusTrain,
|
||||
clearSelection,
|
||||
onKeyDown,
|
||||
installClickHandler,
|
||||
removeClickHandler,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user