Merge commit '0ceee8df40b97c6c693e86a4443794cd4ed1ff35' into HEAD

This commit is contained in:
Said Turksever committed 2026-11-11 08:54:28 +00:00
commit f878efe22d
306 files changed
+39352 -1624

No files matched your search

+4
View File
@@ -30,7 +30,9 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationShipwrecks } from './layers/shipwrecks.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationNews } from './layers/news.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
@@ -136,9 +138,11 @@ export function createApplicationCatalog({
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationShipwrecks({ source: sources.shipwrecks }),
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationNews({ source: sources.news }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
+3 -2
View File
@@ -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, 38);
assert.equal(first.layers.length, 40);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'),
@@ -68,6 +68,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('news'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -161,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, 38);
assert.equal(catalog.layers.length, 40);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+6
View File
@@ -0,0 +1,6 @@
import { createNewsLayer } from '../../layers/news/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire the news layer to the application overlay host. */
export function createApplicationNews(options) {
return createNewsLayer({ overlayHost, ...options });
}
+23
View File
@@ -0,0 +1,23 @@
import * as Cesium from 'cesium';
import { createShipwrecksLayer } from '../../layers/shipwrecks/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the EMODnet + Wikidata shipwrecks layer into the application catalog. */
export function createApplicationShipwrecks(options) {
return createShipwrecksLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+2
View File
@@ -49,7 +49,9 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
shipwrecks: contract(['shipwrecks'], ['getSnapshot']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']),
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
+30 -1
View File
@@ -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, 38);
assert.equal(actual.layers.length, 40);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -150,6 +150,35 @@ test('GhostMaps obeys source omission, replacement and explicit removal', async
assert.equal(fetches, 0);
});
test('News is unavailable without a source and the standalone default reads only same-origin /api/news', async (t) => {
const missing = catalog({}, t);
assert.equal(missing.getSourceAvailability('news')?.available, false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: missing.getSourceAvailability },
);
manager.register(missing.get('news'));
assert.equal(await manager.setEnabled('news', true), false);
assert.equal(manager.layers.get('news').initialized, false);
assert.equal(createStandaloneLayerSources({ news: null }).news, null);
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return new Response(
JSON.stringify({
error: 'news_not_configured',
unavailable: true,
retryable: false,
}),
{ status: 503 },
);
});
const standalone = createStandaloneLayerSources().news;
await assert.rejects(standalone.getHotspots(), { unavailable: true });
assert.deepEqual(urls, ['/api/news/hotspots']);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
+12
View File
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { mapAnalystRecord } from './aisLiveVessels.js';
const FULL_RECORD = {
id: '353136000',
mmsi: '353136000',
name: 'EVER GIVEN',
lat: 29.55,
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RECORD);
assert.deepEqual(r, {
id: 'EVER GIVEN',
vesselId: '353136000',
mmsi: '353136000',
name: 'EVER GIVEN',
lat: 29.55,
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
assert.equal(r.name, null);
});
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
assert.equal(shared.vesselId, 'provider-a');
assert.equal(shared.mmsi, '353136000');
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
assert.equal(noMmsi.vesselId, 'provider-b');
assert.equal(noMmsi.mmsi, null);
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
});
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
assert.equal(r.mmsi, null);
+64 -23
View File
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
records: [record],
selectedRecord: record,
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
loaded: true,
lastUpdate: 456,
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
lastMessageAt: 789,
rawRowCount: 1,
acceptedRowCount: 0,
selectedMmsi: record.mmsi,
trailMmsi: record.mmsi,
selectedId: record.mmsi,
trailId: record.mmsi,
trailPositionCount: 1,
sessionId: _getVesselFeedStateForTest().sessionId,
firstConnectPhase: 'idle',
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
records: [replacement],
selectedRecord: replacement,
trail: replacementTrail,
trailMmsi: replacement.mmsi,
trailId: replacement.mmsi,
trailPositions: [POS],
loaded: true,
lastUpdate: 200,
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
let feed = _getVesselFeedStateForTest();
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
assert.equal(feed.selectedMmsi, replacement.mmsi);
assert.equal(feed.selectedId, replacement.mmsi);
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
await replacementLoad;
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
assert.equal(feed.lastUpdate, 200);
assert.equal(feed.error, 'awaiting first AIS message…');
assert.equal(feed.stale, true);
assert.equal(feed.selectedMmsi, replacement.mmsi);
assert.equal(feed.trailMmsi, replacement.mmsi);
assert.equal(feed.selectedId, replacement.mmsi);
assert.equal(feed.trailId, replacement.mmsi);
assert.equal(replacementTrail.clearCalls, 0);
} finally {
globalThis.fetch = priorFetch;
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
test('vessel selection: empty-space click requests deselection', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
{ action: 'deselect' },
);
});
test('vessel selection: no selection plus empty-space click is a no-op', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
{ action: 'none' },
);
});
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
{ action: 'deselect' },
);
assert.deepEqual(
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
assert.deepEqual(
reduceVesselSelection({
selectedMmsi: '353136000',
pickedMmsi: '367123450',
selectedId: '353136000',
pickedId: '367123450',
gesture: 'click',
}),
{ action: 'select' },
);
assert.deepEqual(
reduceVesselSelection({
selectedMmsi: '353136000',
pickedMmsi: '353136000',
selectedId: '353136000',
pickedId: '353136000',
gesture: 'click',
}),
{ action: 'none' },
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
function makeRecord(overrides = {}) {
return {
const record = {
name: 'EVER GIVEN',
mmsi: '353136000',
type: 'Container Ship',
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
billboard: null,
...overrides,
};
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
return record;
}
function makeTrailSpy() {
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
records: [record],
selectedRecord: record,
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
interactionHandlerFactory,
interactionKeyTarget: keyTarget,
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
} finally {
harness.cleanup();
}
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
selectedRecord: harness.record,
enabled: false,
trail: harness.trail,
trailMmsi: harness.record.mmsi,
trailId: harness.record.mmsi,
trailPositions: [POS],
interactionHandlerFactory: harness.interactionHandlerFactory,
interactionKeyTarget: harness.keyTarget,
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
aisLiveVesselsLayer.clearSelection();
assert.equal(harness.trail.clearCalls, 1);
assert.deepEqual(_getVesselStateForTest(), {
trailMmsi: null,
trailId: null,
trailPositionCount: 0,
vesselCount: 1,
});
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
_setVesselStateForTest({
records: [record],
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
});
try {
_reconcileVesselsForTest({}, []);
assert.equal(trail.clearCalls, 1);
assert.deepEqual(_getVesselStateForTest(), {
trailMmsi: null,
trailId: null,
trailPositionCount: 0,
vesselCount: 0,
});
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
}
});
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
const harness = installWireHarness(
{ id: idOnly },
{ records: [idOnly], selectedRecord: null, trailId: null },
);
const requests = [];
harness.windowTarget.addEventListener(
WORLD_FOCUS_REQUEST_EVENT,
(event) => requests.push(event.detail),
);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
assert.equal(requests[0]?.id, 'provider-7');
assert.match(harness.hud.readout.text, /MMSI: -- /);
} finally {
harness.cleanup();
}
});
/**
* Overlay-host recorder whose hitTest answers with one card id. The real host
* only publishes rects for `interactive` entries, so a stub returning a hit is
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
test('vessel card policy: only keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
assert.equal(idOnly.id, 'vessel:provider-7');
});
test('vessel cards are keyed by id and show the MMSI only when known', () => {
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
assert.equal(shared.id, 'vessel:hull-b');
assert.equal(shared.actionable, true);
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
const unknown = buildSelectedVesselCard(
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
);
assert.equal(unknown.actionable, true);
assert.match(unknown.details.at(-1), /^MMSI -- · /);
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
});
test('vessel interaction wire: deselecting never moves the camera', () => {
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
getSnapshot: async () => ({ records: [] }),
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
});
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
try {
harness.record.reference = 'opaque:test-reference';
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
+41
View File
@@ -573,6 +573,38 @@ export const DATA_CREDITS = [
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
'Shown as published; reports are S2 assessments, not verified fact.',
},
{
key: 'news',
html:
'News: every report is credited to its own publisher and links to the original article; ' +
'pictures are the publishers’ own, shown from their sites with the publisher named. ' +
'Places, categories, severity and English headlines are derived automatically, by ' +
'bundled rules, the news provider or the configured news server, and can be wrong.',
},
{
// GDELT's terms ask for a citation and a link.
key: 'gdelt-news',
html:
'News, Conflicts without a news server: events and page titles from the ' +
'<a href="https://www.gdeltproject.org/" target="_blank" rel="noopener">GDELT Project</a> ' +
'(GDELT 2.0 events and GKG; places and categories are machine-coded)',
},
{
// World News API's free plan asks for a visible "World News API" link to
// its site; always listed, so a free-plan key is never shown without it.
key: 'worldnewsapi',
html:
'News, Local without a news server: headlines from ' +
'<a href="https://worldnewsapi.com/" target="_blank" rel="noopener">World News API</a>',
},
{
key: 'shipwrecks',
html:
'Shipwrecks (Europe): <a href="https://emodnet.ec.europa.eu/en/human-activities" target="_blank" rel="noopener">EMODnet Human Activities</a> ' +
'(data originator AND-International, compiled from SHOM, Ireland&rsquo;s National Monuments Service, Historic England and the Oxford Roman Economy Project), ' +
'licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>' +
' · Shipwrecks (worldwide): <a href="https://www.wikidata.org/" target="_blank" rel="noopener">Wikidata</a> (CC0)',
},
{
key: 'drivebc-cctv',
html:
@@ -639,6 +671,15 @@ export const TOMTOM_CREDIT = {
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
};
/** Registered when the News layer shows a Liveuamap report (only with
* LIVEUAMAP_API_KEY set and no news server). */
export const LIVEUAMAP_CREDIT = {
key: 'liveuamap',
html:
'News, Conflicts: mapped reports from ' +
'<a href="https://liveuamap.com/" target="_blank" rel="noopener">Liveuamap</a>',
};
/** Registered when the first Natural Earth region or state/province outline
* resolves (public domain — no attribution required; credited as a courtesy). */
export const NATURAL_EARTH_CREDIT = {
+21
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DATA_CREDITS,
LIVEUAMAP_CREDIT,
NYC_DOT_DISCLAIMER,
transitFeedCredit,
} from './dataCredits.js';
@@ -189,6 +190,26 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
);
});
test('the News layer credits its built-in sources', () => {
const credit = (key) => DATA_CREDITS.find((entry) => entry.key === key);
// GDELT's terms ask for a citation and a link.
assert.match(
credit('gdelt-news').html,
/href="https:\/\/www\.gdeltproject\.org\/"[^>]*>GDELT Project</,
);
// World News API's free plan asks for this exact link.
assert.match(
credit('worldnewsapi').html,
/<a href="https:\/\/worldnewsapi\.com\/"[^>]*>World News API<\/a>/,
);
assert.match(LIVEUAMAP_CREDIT.html, />Liveuamap</);
assert.ok(
!DATA_CREDITS.some((entry) => entry.key === LIVEUAMAP_CREDIT.key),
'Liveuamap is credited only once its key is in use',
);
assert.doesNotMatch(credit('news').html, /No single provider/);
});
test('the PR #1506 camera packs are credited with their sources and terms', () => {
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
+85 -1
View File
@@ -50,6 +50,8 @@ import {
viewScaleForAltitude,
} from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js';
import { SurfaceOcclusion } from './detectionOcclusion.js';
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
import {
createCyberSonarSampler,
cyberSonarFrameDelay,
@@ -268,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC;
let _lastNonOffMode = MODE_BALANCED;
/** @type {LabelArbiter} Shared stable callout selector. */
const _labelArbiter = new LabelArbiter();
/**
* Hides the bracket and callout of a depth-tested contact (a street vehicle)
* that the scene drew something in front of. `_occlusionProbe` asks the GPU
* about each such contact without waiting for the answer; `_surfaceOcclusion`
* remembers the answers and decides, every frame, who is drawn.
*/
const _occlusionProbe = new SceneOcclusionProbe();
const _surfaceOcclusion = new SurfaceOcclusion(_occlusionProbe);
/**
* Keys of callouts the label arbiter still holds for contacts last seen
* hidden. A key stays here until its contact is seen drawn again or the
* arbiter lets the callout go, so the callout cannot come back at its old
* place just because its contact has since left the view.
*/
const _surfaceSuppressedKeys = new Set();
let _surfaceDepthFallbackReported = false;
let _lastLabelSolveAt = 0;
let _labelSolveDirty = true;
let _lastDiagnostics = null;
@@ -372,6 +390,9 @@ export function destroyDetection() {
_suspended = false;
_suspendReason = '';
_labelArbiter.clear();
_surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear();
_occlusionProbe.destroy();
_lastLabelSolveAt = 0;
_labelSolveDirty = true;
_lastDiagnostics = null;
@@ -697,6 +718,9 @@ function _applyModeState() {
_syncSurfaceVisibility();
_setLanesActive(false);
_labelArbiter.clear();
_surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear();
_occlusionProbe.reset();
_lastLabelSolveAt = 0;
_labelSolveDirty = true;
_lastSolveSnapshot = {
@@ -1337,6 +1361,9 @@ function _drawOverlay(frame) {
// every host lane. Detection retains the manual scalar projection below.
const occluder = frame.occluder;
const camPos = frame.cameraPosition;
// Answers to the occlusion tests started on earlier frames land here.
_surfaceOcclusion.beginFrame(now);
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
const keyhole = frame.keyhole;
// Read the OUTSIDE setting ONCE per paint, from the same module state the
// host's own keyhole alpha comes from, so a bracket and its callout can never
@@ -1447,6 +1474,29 @@ function _drawOverlay(frame) {
// The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
// Something the scene drew in front of a depth-tested contact hides its
// bracket and callout too, on every frame, not only on solves.
if (obj.depthTested === true) {
const testedLayerId = obj._layerId || 'unknown';
const testedSourceId = obj.sourceId ?? obj.id ?? i;
const hidden = _surfaceOcclusion.isHidden(
testedLayerId,
testedSourceId,
obj.position,
);
// If the arbiter holds a callout for this contact it would keep painting
// it until the next solve, and then fade it out where it last stood;
// see `_surfaceSuppressedKeys`. Only held contacts cost a key.
if (
(hidden || _surfaceSuppressedKeys.size > 0) &&
renderIdentities.get(testedLayerId)?.has(testedSourceId)
) {
const key = _detectionKey(testedLayerId, testedSourceId);
if (hidden) _surfaceSuppressedKeys.add(key);
else _surfaceSuppressedKeys.delete(key);
}
if (hidden) continue;
}
const sonarFactor =
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
const sonarLabelFactor = sonarSampler.label(sonarFactor);
@@ -1547,6 +1597,8 @@ function _drawOverlay(frame) {
candidateMap.set(candidate.key, candidate);
}
}
// Start this frame's occlusion tests; nothing here waits for them.
_occlusionProbe.endFrame();
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
@@ -1557,6 +1609,13 @@ function _drawOverlay(frame) {
let didSolve = false;
let solveMs = 0;
if (shouldSolve) {
_surfaceOcclusion.prune();
if (_occlusionProbe.mode === 'sync' && !_surfaceDepthFallbackReported) {
_surfaceDepthFallbackReported = true;
console.info(
'[detection] No occlusion queries here (WebGL 1); street-vehicle label occlusion reads the frame depth back synchronously, about 8 times a second.',
);
}
const quotas = allocateLayerQuotas(
demandByLayer,
ambientBudget,
@@ -1643,7 +1702,25 @@ function _drawOverlay(frame) {
_ctx.lineWidth = bracketWidth;
}
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
let renderEntries = _labelArbiter.renderEntries(candidateMap, now);
// A callout whose contact has gone behind something goes with it at once:
// no carry-over to the next solve and no fade painted on the facade.
if (_surfaceSuppressedKeys.size > 0) {
const allEntries = renderEntries;
renderEntries = allEntries.filter(
(entry) => !_surfaceSuppressedKeys.has(entry.candidate.key),
);
// The arbiter has let some of them go: forget those.
if (
allEntries.length - renderEntries.length !==
_surfaceSuppressedKeys.size
) {
const held = new Set();
for (const entry of allEntries) held.add(entry.candidate.key);
for (const key of _surfaceSuppressedKeys)
if (!held.has(key)) _surfaceSuppressedKeys.delete(key);
}
}
const fadingCount = countFadingRenderEntries(renderEntries);
// Demand counts fades in BOTH directions; `fadingCount` above stays the
// fade-OUT tail because that is what the published diagnostics have always
@@ -1698,6 +1775,13 @@ function _drawOverlay(frame) {
keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx,
calloutOcclusionCount: calloutOcclusionRects.length,
surfaceTestedCount: _surfaceOcclusion.testedCount,
surfaceHiddenCount: _surfaceOcclusion.hiddenCount,
surfaceDepthMode: _occlusionProbe.mode,
// Tests started on the last frame, and what collecting answers and
// drawing them cost it.
surfaceProbeTests: _occlusionProbe.lastFrameTests,
surfaceProbeMs: _occlusionProbe.lastFrameMs,
};
} else {
const selectedCount = _labelArbiter.selectedKeys.size;
+292
View File
@@ -36,6 +36,12 @@ import {
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
import {
createFakeDepthScene,
createFakeGl,
depthImage,
viewDistanceToDepth,
} from './fixtures/fakeDepthScene.mjs';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal(countFadingRenderEntries([
@@ -1369,3 +1375,289 @@ test('transit contacts take the vehicle tier, not the civil-air one', () => {
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
});
/**
* Give the host environment a scene that keeps frame depth: the camera 10,000
* km up the +Z axis looking down it, and one depth range in which the whole
* frame is a surface `surfaceDistance()` metres in front of the camera.
*/
function installDepthScene(env, surfaceDistance, { webgl2 = true } = {}) {
const near = 1;
const far = 100_000_000;
const gl = createFakeGl({ webgl2 });
const camera = env.viewer.camera;
camera.viewMatrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -10_000_000));
camera.frustum.fovy = Math.PI / 2;
camera.frustum.aspectRatio = 800 / 600;
const paint = () =>
depthImage(8, 6, () => viewDistanceToDepth(surfaceDistance(), near, far, true));
const image = paint();
const scene = createFakeDepthScene({ gl, ranges: [{ near, far, image }] });
Object.assign(env.viewer.scene, scene, { camera });
return {
gl,
/** Draw the next frame's depth. */
redraw() {
image.data.set(paint().data);
},
};
}
/** Street contacts 3,643 km and 2,000 km below the camera, and a flight. */
function occlusionLayers() {
return [
{
id: 'traffic',
getDetectableObjects: () => [
{ position: new Cesium.Cartesian3(-0.4, 0, 6_356_752), id: 'VEH-0001', type: 'VEH', depthTested: true },
{ position: new Cesium.Cartesian3(0.4, 0, 8_000_000), id: 'VEH-0002', type: 'VEH', depthTested: true },
],
},
{
id: 'flights',
// The same nearer surface is in front of it, but it is not depth-tested.
getDetectableObjects: () => [
{ position: new Cesium.Cartesian3(-0.4, 0.3, 6_356_752), sourceId: 'f1', id: 'AIR1', metric: 'FL300', type: 'AIR' },
],
},
];
}
function paintedText(env) {
return env.ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text);
}
/** One rendered frame 16 ms on; `settleFrame` is one 250 ms on (a solve). */
function nextFrame(env) {
env.advance(16);
env.postRender.raise();
}
test('a depth-tested contact behind drawn geometry loses its bracket and callout, tested on the GPU without anything read back', () => {
const env = installEnvironment();
// A roof 3,000 km below the camera: in front of VEH-0001, behind VEH-0002.
let surface = 3_000_000;
const depth = installDepthScene(env, () => surface);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, occlusionLayers(), () => {});
setMode('DENSE');
settleFrame(env);
let diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceDepthMode, 'async');
assert.equal(diagnostics.surfaceTestedCount, 2, 'only the depth-tested contacts');
assert.equal(diagnostics.surfaceProbeTests, 2);
// New to the view, so held back for the frame or two the first answer
// takes: neither is drawn yet. The flight is.
assert.equal(diagnostics.surfaceHiddenCount, 2, 'no answer has arrived yet');
assert.equal(diagnostics.visibleCount, 1);
assert.equal(depth.gl.counts.queries, 3, 'a control point and one query per contact');
// Frames before the GPU answers start nothing new and wait for nothing.
nextFrame(env);
nextFrame(env);
assert.equal(depth.gl.counts.queries, 3);
assert.equal(depth.gl.counts.resultReads, 0);
// The answers land. The contact above the roof is drawn from then on; the
// one under it never is.
depth.gl.finishGpu();
nextFrame(env);
depth.gl.finishGpu();
settleFrame(env);
env.ctx.calls.length = 0;
nextFrame(env);
assert.ok(!paintedText(env).includes('VEH-0001'));
assert.ok(paintedText(env).includes('VEH-0002'));
// It stays gone, frame after frame and solve after solve.
for (let i = 0; i < 40; i++) {
depth.gl.finishGpu();
env.ctx.calls.length = 0;
nextFrame(env);
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
}
depth.gl.finishGpu();
settleFrame(env);
diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceHiddenCount, 1);
assert.equal(diagnostics.visibleCount, 2, 'the hidden contact has no bracket');
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0002'));
assert.ok(!diagnostics.labeledKeys.includes('traffic:VEH-0001'));
assert.ok(diagnostics.labeledKeys.includes('flights:f1'));
assert.ok(paintedText(env).includes('AIR1'));
assert.equal(depth.gl.counts.readPixels, 0, 'depth never left the GPU');
// The roof goes away: the bracket returns once visible answers have kept
// coming for the reappear time, and the callout once a solve re-admits it.
surface = 5_000_000;
depth.redraw();
for (let i = 0; i < 80; i++) {
depth.gl.finishGpu();
nextFrame(env);
}
for (let i = 0; i < 8; i++) {
depth.gl.finishGpu();
settleFrame(env);
}
diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceHiddenCount, 0);
assert.equal(diagnostics.visibleCount, 3);
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
} finally {
env.cleanup();
}
});
test('a labelled contact that goes behind something loses its callout at once, and does not get it back by leaving the view', () => {
const env = installEnvironment();
// To begin with the drawn surface is below both vehicles.
let surface = 5_000_000;
const depth = installDepthScene(env, () => surface);
const layers = occlusionLayers();
const everyVehicle = layers[0].getDetectableObjects;
const answerAndSolve = (count) => {
for (let i = 0; i < count; i++) {
depth.gl.finishGpu();
settleFrame(env);
}
};
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, layers, () => {});
setMode('DENSE');
answerAndSolve(6);
assert.ok(getDetectionDiagnostics().labeledKeys.includes('traffic:VEH-0001'));
env.ctx.calls.length = 0;
nextFrame(env);
assert.ok(paintedText(env).includes('VEH-0001'));
// A roof is drawn over VEH-0001. Its callout goes on the frame the answer
// lands, which is not a solve: no carry-over, no fade.
surface = 3_000_000;
depth.redraw();
let gone = -1;
for (let i = 0; i < 30 && gone < 0; i++) {
depth.gl.finishGpu();
env.ctx.calls.length = 0;
nextFrame(env);
if (!paintedText(env).includes('VEH-0001')) gone = i;
}
assert.ok(gone >= 0, 'the callout went');
// The layer stops reporting it (it drove out of view). The arbiter still
// remembers where its callout was; it must not paint it there again.
layers[0].getDetectableObjects = () => everyVehicle().slice(1);
for (let i = 0; i < 12; i++) {
depth.gl.finishGpu();
env.ctx.calls.length = 0;
nextFrame(env);
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
}
} finally {
env.cleanup();
}
});
test('without scene depth every contact keeps its bracket and callout', () => {
const env = installEnvironment();
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, occlusionLayers(), () => {});
setMode('DENSE');
settleFrame(env);
settleFrame(env);
const diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceDepthMode, 'off');
assert.equal(diagnostics.surfaceTestedCount, 2);
assert.equal(diagnostics.surfaceProbeTests, 0);
assert.equal(diagnostics.surfaceHiddenCount, 0);
assert.equal(diagnostics.visibleCount, 3);
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
} finally {
env.cleanup();
}
});
test('scene depth that goes away puts hidden contacts back', () => {
const env = installEnvironment();
const depth = installDepthScene(env, () => 3_000_000);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, occlusionLayers(), () => {});
setMode('DENSE');
settleFrame(env);
depth.gl.finishGpu();
settleFrame(env);
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
// The scene stops keeping pick depth (a 2D view, depth picking turned off).
depth.gl.finishGpu();
env.viewer.scene.useDepthPicking = false;
// The hidden time runs on painted frames, so paint a second's worth.
for (let i = 0; i < 70; i++) nextFrame(env);
settleFrame(env);
const diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceDepthMode, 'off');
assert.equal(diagnostics.surfaceHiddenCount, 0);
assert.equal(diagnostics.visibleCount, 3);
} finally {
env.cleanup();
}
});
test('turning detection off drops every test in flight; tearing it down frees the GPU objects', () => {
const env = installEnvironment();
const depth = installDepthScene(env, () => 3_000_000);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, occlusionLayers(), () => {});
setMode('DENSE');
settleFrame(env);
assert.equal(depth.gl.liveQueries.size, 3);
// Off keeps the GPU objects (the shader is not compiled again on the next
// on) but the answers in flight are no longer wanted.
setMode('OFF');
assert.equal(depth.gl.counts.deleted, 0);
depth.gl.finishGpu();
setMode('DENSE');
settleFrame(env);
assert.equal(depth.gl.counts.queries, 6, 'both contacts are tested afresh');
assert.equal(depth.gl.liveQueries.size, 3, 'on the same queries, reused');
depth.gl.finishGpu();
nextFrame(env);
settleFrame(env);
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
assert.equal(getDetectionDiagnostics().visibleCount, 2);
} finally {
env.cleanup();
}
// Tearing detection down releases everything.
assert.equal(depth.gl.liveQueries.size, 0);
assert.equal(depth.gl.counts.deleted, 5);
});
test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
const env = installEnvironment();
const depth = installDepthScene(env, () => 3_000_000, { webgl2: false });
const originalInfo = console.info;
const reported = [];
console.info = (...args) => reported.push(args.join(' '));
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, occlusionLayers(), () => {});
setMode('DENSE');
for (let i = 0; i < 30; i++) nextFrame(env);
settleFrame(env);
const diagnostics = getDetectionDiagnostics();
assert.equal(diagnostics.surfaceDepthMode, 'sync');
assert.equal(diagnostics.surfaceHiddenCount, 1);
assert.equal(diagnostics.visibleCount, 2);
// Half a second of frames: a read about every eighth of one, not one per
// frame and not one per contact.
assert.ok(depth.gl.counts.readPixels >= 3 && depth.gl.counts.readPixels <= 6, `${depth.gl.counts.readPixels} reads`);
assert.equal(reported.length, 1);
assert.match(reported[0], /synchronously/);
} finally {
console.info = originalInfo;
env.cleanup();
}
});
+466
View File
@@ -0,0 +1,466 @@
/**
* Scene-geometry occlusion for detection contacts.
*
* The overlay's own horizon test only knows about the Earth's curve. A contact
* whose marker the scene depth-tests (a street vehicle's dot) can still sit
* behind a building, a 3D tile or a hill, and painting its bracket and callout
* there puts a label on the facade in front of it.
*
* The fix compares each such contact with what the renderer actually drew: if
* the drawn surface along the contact's ray is nearer than the contact by more
* than a small margin, something stands in front of it and its bracket and
* callout are not drawn. `sceneOcclusionProbe.js` runs that comparison on the
* GPU, against the frame's own depth, and hands back a yes or no per contact a
* frame or two later without ever waiting on the GPU. This file holds the
* arithmetic both sides share and the per-contact memory of the answers.
*
* Only contacts that opt in with `depthTested: true` are tested: the ones whose
* marker the scene itself hides behind geometry. Everything else keeps its
* label exactly as before, which covers contacts high above the ground
* (aircraft, satellites): their markers are drawn on top of the scene by
* design, their 3D models can be scaled up to a minimum pixel size and so fill
* the very pixel being read, and a label that hid while its marker stayed on
* screen would be the worse mismatch.
*
* This file is plain arithmetic; it never touches the renderer.
*/
/** Never less than this: covers a dot drawn a little above or into its road. */
export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3;
/** The most the margin grows with range: 2% of it. */
export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02;
/**
* How far above a contact a drawn surface on its line of sight must stand to
* count as in front of it, when the camera looks down on the contact.
*/
export const SURFACE_OCCLUSION_RISE_M = 2;
/**
* How much nearer than the contact the drawn surface must be before it counts
* as standing in front of it.
*
* The margin absorbs a contact placed a little above or below the drawn
* ground: a vehicle's position and the scene's road surface are not the same
* height to the metre. Looking down at the contact, a point on its line of
* sight `s` metres in front of it stands `s * sinElevation` metres above it,
* so a surface there only hides the contact once `s` passes
* `SURFACE_OCCLUSION_RISE_M / sinElevation`. That keeps the margin to a few
* metres from above, where a fixed share of the range would let a wall,
* awning or tree 10 to 20 m in front of a street at 1 km pass as "the road".
* At grazing angles, or looking up, the margin is 2% of the range at most
* (never under 3 m); depth precision is far finer than that at every range
* the overlay labels.
*
* @param {number} contactDistance Camera-to-contact distance in metres.
* @param {number} [sinElevation] Sine of the camera's elevation seen from the
* contact (1 straight overhead, 0 level, negative below); unknown when
* omitted.
* @returns {number} Margin in metres.
*/
export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) {
const distance = Number(contactDistance);
if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M;
let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION;
const rise = Number(sinElevation);
if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise);
return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin);
}
/**
* Whether a drawn surface hides a contact.
* @param {number} contactDistance Camera-to-contact distance in metres.
* @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance
* along the contact's ray; undefined or non-finite when nothing was drawn.
* @param {number} [sinElevation] See `surfaceOcclusionMargin`.
* @returns {boolean}
*/
export function surfaceHidesContact(
contactDistance,
surfaceDistance,
sinElevation = 0,
) {
if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance))
return false;
return (
surfaceDistance <
contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation)
);
}
/**
* Unpack a depth the renderer stored across the four bytes of an RGBA8 texel
* (most significant byte first, each byte a base-255 digit).
* @param {ArrayLike<number>} bytes
* @param {number} offset Index of the texel's red byte.
* @returns {number} Window-space depth in [0, 1].
*/
export function unpackDepth(bytes, offset) {
return (
bytes[offset] / 255 +
bytes[offset + 1] / 65025 +
bytes[offset + 2] / 16581375 +
bytes[offset + 3] / 4228250625
);
}
/**
* Convert a window-space depth to the distance in front of the camera, measured
* along the view direction, for one depth range ("frustum") of the frame.
*
* Logarithmic depth stores `log2(z - near + 1) / log2(far - near + 1)`.
* Standard perspective depth stores `far * (z - near) / (z * (far - near))`.
*
* @param {number} depth Window-space depth in [0, 1].
* @param {number} near Near plane of the range, metres.
* @param {number} far Far plane of the range, metres.
* @param {boolean} logDepth Whether the frame was drawn with logarithmic depth.
* @returns {number} Metres along the view direction, or NaN where the range
* drew nothing (depth at or outside the ends of [0, 1]).
*/
export function depthToViewDistance(depth, near, far, logDepth) {
if (!(depth > 0) || !(depth < 1)) return Number.NaN;
if (!(near > 0) || !(far > near)) return Number.NaN;
if (logDepth)
return near + Math.pow(2, depth * Math.log2(far - near + 1)) - 1;
return (near * far) / (far - depth * (far - near));
}
/**
* Where a world position falls in a frame, and how near a drawn surface has to
* be there to hide it.
*
* A surface hides the contact when its distance along the contact's ray is
* under the contact's own distance less the margin. Along one ray, distance
* and view distance (depth in front of the camera) are in a fixed ratio, so
* the same rule reads: the surface's view distance is under
* `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`,
* and comparing a decoded depth with it is all the GPU has to do.
*
* The margin depends on how steeply the camera looks down on the contact
* (see `surfaceOcclusionMargin`). "Up" at the contact is taken as the
* direction from the world origin to it, which is the Earth's centre in the
* Earth-fixed frame the scene is drawn in; it differs from the true vertical
* by a fraction of a degree at most.
*
* @param {ArrayLike<number>} view World-to-camera matrix, column-major, the
* camera looking down its own -Z axis.
* @param {number} tanHalfFovX
* @param {number} tanHalfFovY
* @param {{x: number, y: number, z: number}} position
* @param {{u: number, v: number, threshold: number}} out `u`, `v` in [0, 1)
* from the bottom-left corner of the frame.
* @returns {boolean} False when the position is behind the camera or outside
* the frame; `out` is then untouched.
*/
export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) {
const { x, y, z } = position;
const viewDistance = -(view[2] * x + view[6] * y + view[10] * z + view[14]);
if (!(viewDistance > 0)) return false;
const ex = view[0] * x + view[4] * y + view[8] * z + view[12];
const ey = view[1] * x + view[5] * y + view[9] * z + view[13];
const u = (ex / (viewDistance * tanHalfFovX)) * 0.5 + 0.5;
const v = (ey / (viewDistance * tanHalfFovY)) * 0.5 + 0.5;
if (!(u >= 0 && u < 1 && v >= 0 && v < 1)) return false;
const contactDistance = Math.sqrt(
ex * ex + ey * ey + viewDistance * viewDistance,
);
// Up at the contact, turned into camera axes, against the direction from
// the contact to the camera (-ex, -ey, viewDistance in camera axes).
const radius = Math.sqrt(x * x + y * y + z * z);
let sinElevation = 0;
if (radius > 0) {
const ux = (view[0] * x + view[4] * y + view[8] * z) / radius;
const uy = (view[1] * x + view[5] * y + view[9] * z) / radius;
const uz = (view[2] * x + view[6] * y + view[10] * z) / radius;
sinElevation = (-ux * ex - uy * ey + uz * viewDistance) / contactDistance;
}
out.u = u;
out.v = v;
out.threshold =
viewDistance *
(1 -
surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance);
return true;
}
/**
* One frame of depth read back to memory, with the camera it was drawn from.
* Only the synchronous fallback uses this (see `sceneOcclusionProbe.js`); the
* normal path never copies depth off the GPU.
*
* `bytes` holds `frustums.length` stacked images of `width` x `height` RGBA8
* texels, nearest depth range first, rows bottom to top (the order the
* graphics API reads them back in).
*/
export class DepthSnapshot {
/**
* @param {{
* width: number, height: number, bytes: Uint8Array,
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
* view: ArrayLike<number>, tanHalfFovX: number, tanHalfFovY: number,
* }} fields
*/
constructor(fields) {
this.width = fields.width;
this.height = fields.height;
this.bytes = fields.bytes;
this.frustums = fields.frustums;
this.logDepth = fields.logDepth === true;
this.view = fields.view;
this.tanHalfFovX = fields.tanHalfFovX;
this.tanHalfFovY = fields.tanHalfFovY;
this._projected = { u: 0, v: 0, threshold: 0 };
}
/**
* Distance along the view direction to the nearest surface drawn at a texel.
* @param {number} column
* @param {number} row Counted from the bottom of the frame.
* @returns {number} Metres, or NaN where nothing was drawn.
*/
surfaceViewDistance(column, row) {
const { width, height, bytes, frustums, logDepth } = this;
const texel = (row * width + column) * 4;
const layer = width * height * 4;
for (let i = 0; i < frustums.length; i++) {
const distance = depthToViewDistance(
unpackDepth(bytes, i * layer + texel),
frustums[i].near,
frustums[i].far,
logDepth,
);
if (distance === distance) return distance;
}
return Number.NaN;
}
/**
* Whether the frame drew something in front of a world position.
* @param {{x: number, y: number, z: number}} position
* @returns {boolean|null} Null when the position is outside the frame.
*/
hides(position) {
const projected = this._projected;
if (
!projectContact(
this.view,
this.tanHalfFovX,
this.tanHalfFovY,
position,
projected,
)
)
return null;
return (
this.surfaceViewDistance(
Math.floor(projected.u * this.width),
Math.floor(projected.v * this.height),
) < projected.threshold
);
}
}
/**
* A contact is tested again at most this long, and at least half this long,
* after its last test was started, so a view full of contacts spreads its
* tests over about ten frames instead of repeating all of them on every one.
* With more contacts in view than the probe's per-frame budget can serve at
* that pace, the interval stretches (see `SurfaceOcclusion.beginFrame`).
*/
export const SURFACE_RETEST_MS = 200;
/**
* A contact stays hidden at least this long after the last test that found it
* hidden, and never less than three retest intervals.
*
* Going behind something takes effect as soon as the answer arrives. Coming
* back takes several visible answers in a row, so a contact on a silhouette
* edge, which tests either way from one frame to the next while the camera
* orbits, stays off instead of blinking. It is also the clean failure: when
* answers stop arriving (no depth to test against) every contact is drawn
* again within this time, exactly as before this test existed.
*/
export const SURFACE_REAPPEAR_MS = 600;
/**
* A contact that has never had an answer is held back this long, at most,
* while a working probe gets it one.
*
* A contact new to the view would otherwise be drawn for the frame or two its
* first test takes, and then vanish if it turned out to be behind something:
* a camera orbiting a city would flash a bracket for every vehicle that
* swings into the frame behind a building. With no working probe nothing is
* held back, and past this time the contact is drawn whatever happens.
*/
export const SURFACE_FIRST_ANSWER_MS = 400;
/**
* The most one frame advances the occlusion clock.
*
* Every interval above is measured on a clock that only runs while the
* overlay is painting: each painted frame moves it on by the time since the
* last one, but never by more than this. Answers only arrive while frames are
* painted, so on a wall clock a pause (a hidden tab, a scene with nothing to
* redraw) would run out every "hidden" at once and the first frame afterwards
* would paint every label over the buildings again until fresh answers came.
* On this clock a pause of any length counts as one slow frame.
*/
export const SURFACE_MAX_FRAME_STEP_MS = 50;
/** A contact not drawn for this long is forgotten. */
export const SURFACE_FORGET_MS = 2000;
/**
* What is remembered about one contact.
* @typedef {{lastHiddenAt: number, hiddenUntil: number, appliedHiddenAt: number,
* queuedAt: number, pending: boolean, answered: boolean, firstSeenAt: number,
* seenAt: number}} SurfaceContact Times are on the occlusion clock.
*/
/**
* Per-contact occlusion answers, and the schedule that keeps them fresh.
*
* `probe` is whatever tests a contact against the drawn scene. It has
* `add(contact, position)`, which starts a test (setting `contact.pending`)
* and returns false when it cannot take one now, and it reports back by
* clearing `contact.pending`, setting `contact.answered` and, when the
* contact was hidden, setting `contact.lastHiddenAt`. `probe.maxPerFrame`, if
* set, is how many tests it takes in one frame; `probe.mode` reads `'async'`
* while answers come a frame or two after the test.
*/
export class SurfaceOcclusion {
/**
* @param {{add: (contact: SurfaceContact, position: object) => boolean}} probe
*/
constructor(probe) {
this.probe = probe;
/** @type {Map<string, Map<*, SurfaceContact>>} Layer id -> source id. */
this.contacts = new Map();
/**
* The occlusion clock, in milliseconds of painted time (see
* `SURFACE_MAX_FRAME_STEP_MS`). The probe must date its answers by it.
*/
this.now = 0;
this._wall = Number.NEGATIVE_INFINITY;
/** Depth-tested contacts drawn or hidden this frame. */
this.testedCount = 0;
this.hiddenCount = 0;
this.retestMs = SURFACE_RETEST_MS;
this.reappearMs = SURFACE_REAPPEAR_MS;
this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS;
this._phase = 0;
}
/**
* Open a frame and move the occlusion clock on.
* @param {number} wallNow Monotonic milliseconds.
*/
beginFrame(wallNow) {
// Contacts arrive in the same order every frame and the probe takes only
// so many, so if more fell due per frame than it takes, the ones at the
// end of the order would never be tested. Pace the retests to what the
// last frame showed the probe can keep up with: all of the contacts, once
// per interval, with a third of the budget to spare.
const elapsed = wallNow - this._wall;
const frameMs =
elapsed > 0 ? Math.min(SURFACE_MAX_FRAME_STEP_MS, elapsed) : 0;
this._wall = wallNow;
const budget = Number(this.probe.maxPerFrame) || Number.POSITIVE_INFINITY;
this.retestMs = Math.max(
SURFACE_RETEST_MS,
(this.testedCount / budget) * frameMs * 2,
);
this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3);
// At a stretched pace a crowd of new contacts takes longer to get its
// first answers; the hold-back stretches with it.
this.firstAnswerMs = Math.max(SURFACE_FIRST_ANSWER_MS, this.retestMs * 2);
this.now += frameMs;
this.testedCount = 0;
this.hiddenCount = 0;
}
/**
* Whether a contact's bracket and callout should be left out of this frame.
* Starts a fresh test of the contact when one is due.
* @param {string} layerId
* @param {*} sourceId
* @param {{x: number, y: number, z: number}} position
* @returns {boolean}
*/
isHidden(layerId, sourceId, position) {
const now = this.now;
let layer = this.contacts.get(layerId);
if (!layer) {
layer = new Map();
this.contacts.set(layerId, layer);
}
let contact = layer.get(sourceId);
if (!contact || now - contact.seenAt > this.retestMs) {
// New, or back in view after longer than a retest interval. A returning
// contact's last answer is older than any contact that stayed in view
// runs on, and the view has probably turned since: it is held back
// like a new one until a fresh answer comes, or orbiting flashes it
// behind the building it went behind while out of view. It gets a new
// record, so a test still in flight from before answers the old one
// and cannot end the hold-back with what the previous view showed. A
// "hidden" it already had keeps running.
contact = {
lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY,
hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY,
appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY,
queuedAt: Number.NEGATIVE_INFINITY,
pending: false,
answered: false,
firstSeenAt: now,
seenAt: now,
};
layer.set(sourceId, contact);
}
contact.seenAt = now;
this.testedCount++;
if (
!contact.pending &&
now - contact.queuedAt >= this.retestMs &&
this.probe.add(contact, position)
) {
// Contacts that came into view together would otherwise all fall due on
// the same frames for ever. Each test backdates itself by a different
// part of half the interval, which spreads them out within a second.
contact.queuedAt = now - this.retestMs * 0.5 * this._phase;
this._phase = (this._phase + 0.618034) % 1;
}
// A new "hidden" answer sets when the contact may come back, once, with
// the pace in force now. A later change of pace must not bring back a
// "hidden" that has already run out.
if (contact.lastHiddenAt !== contact.appliedHiddenAt) {
contact.appliedHiddenAt = contact.lastHiddenAt;
contact.hiddenUntil = contact.lastHiddenAt + this.reappearMs;
}
const hidden =
now < contact.hiddenUntil ||
// Not yet answered: held back while a working probe finds out.
(!contact.answered &&
this.probe.mode === 'async' &&
now - contact.firstSeenAt < this.firstAnswerMs);
if (hidden) this.hiddenCount++;
return hidden;
}
/** Forget contacts that are no longer drawn. */
prune() {
for (const [layerId, layer] of this.contacts) {
for (const [sourceId, contact] of layer) {
if (this.now - contact.seenAt > SURFACE_FORGET_MS)
layer.delete(sourceId);
}
if (layer.size === 0) this.contacts.delete(layerId);
}
}
/** Forget every contact (mode off, viewer swap). */
clear() {
this.contacts.clear();
this.testedCount = 0;
this.hiddenCount = 0;
}
}
+851
View File
@@ -0,0 +1,851 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DepthSnapshot,
SURFACE_FIRST_ANSWER_MS,
SURFACE_FORGET_MS,
SURFACE_MAX_FRAME_STEP_MS,
SURFACE_OCCLUSION_MIN_MARGIN_M,
SURFACE_REAPPEAR_MS,
SURFACE_RETEST_MS,
SurfaceOcclusion,
depthToViewDistance,
projectContact,
surfaceHidesContact,
surfaceOcclusionMargin,
unpackDepth,
} from './detectionOcclusion.js';
import {
depthImage,
packDepth,
viewDistanceToDepth,
} from './fixtures/fakeDepthScene.mjs';
test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => {
assert.equal(surfaceOcclusionMargin(10), SURFACE_OCCLUSION_MIN_MARGIN_M);
assert.equal(surfaceOcclusionMargin(150), 3);
assert.equal(surfaceOcclusionMargin(500), 10);
assert.equal(surfaceOcclusionMargin(5000), 100);
assert.equal(surfaceOcclusionMargin(0), 3);
assert.equal(surfaceOcclusionMargin(Number.NaN), 3);
});
test('looking down on a contact, the margin is what a 2 m rise on its line of sight takes', () => {
// 1 km away, seen from 30 degrees up: 2 m of rise is 4 m along the ray.
assert.ok(Math.abs(surfaceOcclusionMargin(1000, 0.5) - 4) < 1e-9);
// Straight down: the 3 m floor.
assert.equal(surfaceOcclusionMargin(1000, 1), SURFACE_OCCLUSION_MIN_MARGIN_M);
// Grazing, level or from below: never more than 2% of the range.
assert.equal(surfaceOcclusionMargin(1000, 0.01), 20);
assert.equal(surfaceOcclusionMargin(1000, 0), 20);
assert.equal(surfaceOcclusionMargin(1000, -0.3), 20);
assert.equal(surfaceOcclusionMargin(1000, Number.NaN), 20);
assert.equal(surfaceOcclusionMargin(100, 0.05), 3);
// A low wall 15 m in front of a street 1 km off, seen from 25 degrees up,
// stands about 6 m above the vehicle's line of sight: it hides it.
const sin25 = Math.sin((25 * Math.PI) / 180);
assert.equal(surfaceHidesContact(1000, 985, sin25), true);
assert.equal(surfaceHidesContact(1000, 985), false, 'unknown angle: 2%');
assert.equal(surfaceHidesContact(1000, 996, sin25), false, 'its own road');
});
test('a surface hides a contact only when nearer by more than the margin', () => {
// 500 m away: margin 10 m.
assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front');
assert.equal(surfaceHidesContact(500, 491), false, 'its own road or dot');
assert.equal(surfaceHidesContact(500, 500), false);
assert.equal(surfaceHidesContact(500, 520), false, 'the ground behind it');
// Close range keeps the 3 m floor.
assert.equal(surfaceHidesContact(60, 56), true);
assert.equal(surfaceHidesContact(60, 58), false);
});
test('nothing drawn, or no distance, never hides a contact', () => {
assert.equal(surfaceHidesContact(500, undefined), false);
assert.equal(surfaceHidesContact(500, Number.NaN), false);
assert.equal(surfaceHidesContact(Number.NaN, 10), false);
});
test('packed depth unpacks most significant byte first', () => {
assert.equal(unpackDepth([0, 0, 0, 0], 0), 0);
assert.equal(unpackDepth([255, 0, 0, 0], 0), 1);
assert.equal(unpackDepth([9, 9, 51, 0, 0, 0], 2), 0.2);
for (const depth of [0.000123, 0.25, 0.5, 0.987654321]) {
const round = unpackDepth(packDepth(depth), 0);
assert.ok(Math.abs(round - depth) < 1e-9, `${depth} -> ${round}`);
}
});
test('logarithmic depth decodes to metres in front of the camera', () => {
const near = 0.1;
const far = 10_000_000;
// depth = log2(z - near + 1) / log2(far - near + 1)
for (const z of [0.5, 12, 480, 25_000, 3_000_000]) {
const depth = Math.log2(z - near + 1) / Math.log2(far - near + 1);
const decoded = depthToViewDistance(depth, near, far, true);
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
}
});
test('standard perspective depth decodes to metres in front of the camera', () => {
const near = 1;
const far = 5000;
// depth = far * (z - near) / (z * (far - near))
for (const z of [1.5, 40, 900, 4999]) {
const depth = (far * (z - near)) / (z * (far - near));
const decoded = depthToViewDistance(depth, near, far, false);
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
}
assert.ok(
Math.abs(depthToViewDistance(0.5, 1, 5000, false) - 10000 / 5001) < 1e-9,
'half depth sits just past twice the near plane',
);
});
test('the ends of the depth range, and a bad range, mean nothing was drawn', () => {
for (const log of [true, false]) {
assert.ok(Number.isNaN(depthToViewDistance(0, 1, 100, log)));
assert.ok(Number.isNaN(depthToViewDistance(1, 1, 100, log)));
assert.ok(Number.isNaN(depthToViewDistance(Number.NaN, 1, 100, log)));
assert.ok(Number.isNaN(depthToViewDistance(0.5, 0, 100, log)));
assert.ok(Number.isNaN(depthToViewDistance(0.5, 100, 100, log)));
}
});
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
/** A point `distance` metres ahead, `side` of the way to the frame edge. */
const ahead = (distance, side = 0, up = 0) => ({
x: side * distance,
y: up * distance,
z: -distance,
});
test('a contact projects to its place in the frame, from the bottom-left corner', () => {
const out = { u: -1, v: -1, threshold: -1 };
// 90 degree field of view: the frame edge is as far out as the point is ahead.
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500), out), true);
assert.deepEqual([out.u, out.v], [0.5, 0.5]);
assert.equal(
projectContact(IDENTITY, 1, 1, ahead(500, -0.5, 0.5), out),
true,
);
assert.deepEqual([out.u, out.v], [0.25, 0.75]);
// A wider horizontal field of view pulls the same point towards the middle.
assert.equal(
projectContact(IDENTITY, 2, 1, ahead(500, -0.5, 0.5), out),
true,
);
assert.deepEqual([out.u, out.v], [0.375, 0.75]);
});
test('the threshold is the margin rule restated as a view distance', () => {
const out = { u: 0, v: 0, threshold: 0 };
// Straight ahead, distance and view distance are the same thing.
projectContact(IDENTITY, 1, 1, ahead(500), out);
assert.ok(Math.abs(out.threshold - 490) < 1e-9, '500 m less 2%');
projectContact(IDENTITY, 1, 1, ahead(60), out);
assert.ok(Math.abs(out.threshold - 57) < 1e-9, '60 m less the 3 m floor');
// Off axis the ray is longer than the view distance; the rule holds along
// the ray, so a surface at view distance d sits d * range / viewDistance out.
const corner = ahead(400, -0.9, 0.9);
const range = Math.hypot(corner.x, corner.y, corner.z);
projectContact(IDENTITY, 1, 1, corner, out);
for (const surfaceView of [out.threshold - 0.01, out.threshold + 0.01]) {
const alongRay = (surfaceView * range) / 400;
assert.equal(
surfaceHidesContact(range, alongRay),
surfaceView < out.threshold,
);
}
assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9);
});
test('the threshold uses how steeply the camera looks down on the contact', () => {
// A street on a sphere the size of the Earth, under a camera 1 km up and
// 1 km back along the surface, looking straight at it: 45 degrees down.
const R = 6_371_000;
const street = { x: 0, y: 0, z: R };
const camera = { x: 0, y: -1000, z: R + 1000 };
// Camera axes: x along world x, looking along (0, 1, -1) / sqrt 2.
const s = Math.SQRT1_2;
const forward = [0, s, -s];
const up = [0, s, s];
const right = [1, 0, 0];
const t = (axis) =>
-(axis[0] * camera.x + axis[1] * camera.y + axis[2] * camera.z);
// Rows: right, up, -forward (the camera looks down its -Z).
const back = forward.map((c) => -c);
const view = [
right[0],
up[0],
back[0],
0,
right[1],
up[1],
back[1],
0,
right[2],
up[2],
back[2],
0,
t(right),
t(up),
t(back),
1,
];
const out = { u: 0, v: 0, threshold: 0 };
assert.equal(projectContact(view, 1, 1, street, out), true);
assert.ok(Math.abs(out.u - 0.5) < 1e-9 && Math.abs(out.v - 0.5) < 1e-9);
const range = Math.SQRT2 * 1000;
// 2 m of rise at 45 degrees is 2.83 m along the ray: the 3 m floor, not
// 2% of 1414 m (28 m).
assert.ok(Math.abs(out.threshold - (range - 3)) < 1e-6, `${out.threshold}`);
});
test('off the middle of the frame, with the camera turned, the threshold matches the world-space rule', () => {
// Rotations about the three axes, then a camera placed and turned freely;
// the threshold must equal the margin rule worked out in world space.
const rot = (ax, ay, az) => {
const [cx, sx, cy, sy, cz, sz] = [
Math.cos(ax),
Math.sin(ax),
Math.cos(ay),
Math.sin(ay),
Math.cos(az),
Math.sin(az),
];
// Rows of Rz * Ry * Rx.
return [
[cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
[sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
[-sy, cy * sx, cy * cx],
];
};
const R = 6_371_000;
let checked = 0;
for (let k = 0; k < 200; k++) {
// A deterministic spread of poses.
const a = (n) => Math.sin(k * 12.9898 + n * 78.233) * 3;
const m = rot(a(1), a(2), a(3)); // world-to-camera rotation
const lat = a(4) / 3;
const lon = a(5);
const contact = {
x: R * Math.cos(lat) * Math.cos(lon),
y: R * Math.cos(lat) * Math.sin(lon),
z: R * Math.sin(lat),
};
// Camera 300 to 2300 m from the contact, in a direction above or below it.
const d = [a(6), a(7), a(8)];
const dl = Math.hypot(...d) || 1;
const dist = 1300 + 1000 * Math.sin(k);
const cam = {
x: contact.x + (d[0] / dl) * dist,
y: contact.y + (d[1] / dl) * dist,
z: contact.z + (d[2] / dl) * dist,
};
const t = m.map(
(row) => -(row[0] * cam.x + row[1] * cam.y + row[2] * cam.z),
);
const view = [
m[0][0],
m[1][0],
m[2][0],
0,
m[0][1],
m[1][1],
m[2][1],
0,
m[0][2],
m[1][2],
m[2][2],
0,
t[0],
t[1],
t[2],
1,
];
const out = { u: 0, v: 0, threshold: 0 };
if (!projectContact(view, 2, 2, contact, out)) continue;
checked++;
const toCam = [cam.x - contact.x, cam.y - contact.y, cam.z - contact.z];
const range = Math.hypot(...toCam);
const sinElevation =
(toCam[0] * contact.x + toCam[1] * contact.y + toCam[2] * contact.z) /
(range * R);
const viewDistance = -(
m[2][0] * contact.x +
m[2][1] * contact.y +
m[2][2] * contact.z +
t[2]
);
const expected =
viewDistance * (1 - surfaceOcclusionMargin(range, sinElevation) / range);
assert.ok(
Math.abs(out.threshold - expected) < 1e-6,
`pose ${k}: ${out.threshold} vs ${expected}`,
);
}
assert.ok(checked > 20, `${checked} poses in frame`);
});
test('behind the camera or outside the frame there is nothing to test', () => {
const out = { u: 7, v: 7, threshold: 7 };
assert.equal(
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 500 }, out),
false,
);
assert.equal(
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 0 }, out),
false,
);
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, -1.2), out), false);
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, 0, 1.01), out), false);
assert.equal(
projectContact(IDENTITY, 1, 1, ahead(500, 1), out),
false,
'u = 1',
);
assert.deepEqual(out, { u: 7, v: 7, threshold: 7 }, 'left untouched');
});
test('a contact is projected with the camera it is given', () => {
// Camera 1000 m up the +Z axis looking down -Z: view = translate(0, 0, -1000).
const view = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -1000, 1];
const out = { u: 0, v: 0, threshold: 0 };
assert.equal(
projectContact(view, 1, 1, { x: -250, y: 0, z: 500 }, out),
true,
);
assert.equal(out.u, 0.25);
assert.equal(projectContact(view, 1, 1, { x: 0, y: 0, z: 1500 }, out), false);
});
/**
* A snapshot from a camera at the origin looking down -Z with a 90 degree
* field of view. By default one depth range in which the left half of the
* frame is a wall `wall` metres away and the right half is ground `ground`
* metres away.
*/
function snapshotWithWall({
wall = 400,
ground = 500,
logDepth = true,
size = 8,
frustums,
images,
} = {}) {
const ranges = frustums || [{ near: 1, far: 100_000 }];
const layers = images || [
depthImage(size, size, (column) =>
viewDistanceToDepth(
column < size / 2 ? wall : ground,
ranges[0].near,
ranges[0].far,
logDepth,
),
),
];
const bytes = new Uint8Array(size * size * 4 * layers.length);
layers.forEach((image, i) => bytes.set(image.data, i * size * size * 4));
return new DepthSnapshot({
width: size,
height: size,
bytes,
frustums: ranges,
logDepth,
view: IDENTITY,
tanHalfFovX: 1,
tanHalfFovY: 1,
});
}
test('a contact behind a drawn wall is hidden; one on drawn ground, or in front of the wall, is not', () => {
for (const logDepth of [true, false]) {
const snapshot = snapshotWithWall({ logDepth });
assert.equal(snapshot.hides(ahead(500, -0.5)), true);
assert.equal(snapshot.hides(ahead(500, 0.5)), false);
assert.equal(snapshot.hides(ahead(300, -0.5)), false);
// 2% of 405 m is 8.1 m: 5 m behind the wall is still the wall's own face.
assert.equal(snapshot.hides(ahead(405, -0.5)), false);
assert.equal(snapshot.hides(ahead(412, -0.5)), true);
assert.equal(snapshot.hides(ahead(500, -1.5)), null, 'outside the frame');
}
});
test('rows are read bottom to top', () => {
const size = 8;
const image = depthImage(size, size, (_column, row) =>
viewDistanceToDepth(row < size / 2 ? 400 : 500, 1, 100_000, true),
);
const snapshot = snapshotWithWall({ images: [image] });
assert.equal(snapshot.hides(ahead(500, 0, -0.5)), true, 'below centre');
assert.equal(snapshot.hides(ahead(500, 0, 0.5)), false, 'above centre');
});
test('the nearest depth range that drew something answers', () => {
const size = 4;
const frustums = [
{ near: 1, far: 1000 },
{ near: 999, far: 1_000_000 },
];
// Near range: a wall 400 m away on the left, nothing on the right.
const nearImage = depthImage(size, size, (column) =>
column < 2 ? viewDistanceToDepth(400, 1, 1000, true) : 1,
);
// Far range: ground 20 km away everywhere.
const farImage = depthImage(size, size, () =>
viewDistanceToDepth(20_000, 999, 1_000_000, true),
);
const snapshot = snapshotWithWall({
size,
frustums,
images: [nearImage, farImage],
});
assert.ok(Math.abs(snapshot.surfaceViewDistance(0, 0) - 400) < 0.01);
assert.ok(Math.abs(snapshot.surfaceViewDistance(3, 0) - 20_000) < 1);
assert.equal(snapshot.hides(ahead(15_000, -0.5)), true);
assert.equal(snapshot.hides(ahead(15_000, 0.5)), false);
assert.equal(snapshot.hides(ahead(30_000, 0.5)), true);
});
test('where nothing was drawn a contact is visible', () => {
const size = 4;
const snapshot = snapshotWithWall({
size,
images: [depthImage(size, size, () => 1)],
});
assert.ok(Number.isNaN(snapshot.surfaceViewDistance(1, 1)));
assert.equal(snapshot.hides(ahead(500)), false);
});
/**
* A probe that answers when told to. `hidden` decides each test's answer at
* the time it is resolved.
*/
function manualProbe() {
const inFlight = [];
return {
accepting: true,
started: 0,
inFlight,
add(contact) {
if (!this.accepting) return false;
this.started++;
contact.pending = true;
inFlight.push(contact);
return true;
},
/** Deliver every answer in flight. */
answer(hidden, now) {
for (const contact of inFlight.splice(0)) {
contact.pending = false;
contact.answered = true;
if (hidden) contact.lastHiddenAt = now;
}
},
};
}
const anywhere = { x: 0, y: 0, z: -500 };
test('a contact is drawn until an answer says it is hidden, then hidden at once', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(1000);
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
assert.equal(probe.started, 1, 'a new contact is tested straight away');
occlusion.beginFrame(1016);
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
assert.equal(probe.started, 1, 'one test in flight per contact');
probe.answer(true, occlusion.now);
occlusion.beginFrame(1032);
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
assert.equal(occlusion.testedCount, 1);
assert.equal(occlusion.hiddenCount, 1);
// The same source id on another layer is another contact.
assert.equal(occlusion.isHidden('transit', 'a', anywhere), false);
});
test('with a working probe a new contact is held back until its first answer, and no longer than the limit', () => {
const probe = manualProbe();
probe.mode = 'async';
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(0);
// New to the view: tested at once, and not drawn while the answer is out.
assert.equal(occlusion.isHidden('traffic', 'street', anywhere), true);
assert.equal(occlusion.isHidden('traffic', 'roofed', anywhere), true);
assert.equal(occlusion.isHidden('traffic', 'unanswered', anywhere), true);
assert.equal(probe.started, 3);
const [street, roofed] = probe.inFlight.splice(0, 2);
street.pending = false;
street.answered = true;
roofed.pending = false;
roofed.answered = true;
roofed.lastHiddenAt = occlusion.now;
occlusion.beginFrame(16);
assert.equal(
occlusion.isHidden('traffic', 'street', anywhere),
false,
'drawn from its first answer',
);
assert.equal(
occlusion.isHidden('traffic', 'roofed', anywhere),
true,
'never drawn at all',
);
// The third answer never comes. It is drawn once the limit has passed.
const firstSeen = occlusion.now - 16;
const drawn = [];
for (let wall = 32; wall < 1000; wall += 16) {
occlusion.beginFrame(wall);
drawn.push([
occlusion.now - firstSeen,
!occlusion.isHidden('traffic', 'unanswered', anywhere),
]);
}
for (const [age, shown] of drawn)
assert.equal(shown, age >= SURFACE_FIRST_ANSWER_MS, `at ${age} ms`);
// No working probe: a new contact is drawn at once, as before.
for (const mode of ['off', 'sync', undefined]) {
const idle = manualProbe();
idle.mode = mode;
const plain = new SurfaceOcclusion(idle);
plain.beginFrame(0);
assert.equal(plain.isHidden('traffic', 'a', anywhere), false, String(mode));
}
});
test('a contact is retested at most every retest interval and at least every half of it', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
const starts = [];
let before = 0;
for (let now = 0; now <= 2000; now += 4) {
probe.answer(false, now);
occlusion.beginFrame(now);
occlusion.isHidden('traffic', 'a', anywhere);
if (probe.started !== before) {
starts.push(now);
before = probe.started;
}
}
const gaps = starts.slice(1).map((at, i) => at - starts[i]);
assert.ok(gaps.length >= 9);
assert.ok(
gaps.every((gap) => gap <= SURFACE_RETEST_MS),
`${gaps}`,
);
assert.ok(
gaps.every((gap) => gap >= SURFACE_RETEST_MS / 2),
`${gaps}`,
);
assert.ok(new Set(gaps).size > 3, 'not all on the same beat');
});
test('contacts that appear together drift apart instead of all testing on the same frames', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
const perFrame = [];
for (let frame = 0; frame < 240; frame++) {
const now = frame * 16;
probe.answer(false, now);
occlusion.beginFrame(now);
const before = probe.started;
for (let id = 0; id < 600; id++)
occlusion.isHidden('traffic', id, anywhere);
perFrame.push(probe.started - before);
}
assert.equal(perFrame[0], 600, 'every new contact is tested at once');
const settled = perFrame.slice(120);
assert.ok(Math.max(...settled) < 200, `peak ${Math.max(...settled)}`);
assert.ok(Math.min(...settled) > 0);
});
test('with more contacts than the probe takes per frame, the retests slow down and none is starved', () => {
// A probe that takes 256 tests a frame, 2,400 contacts in view, 30 fps.
const inFlight = [];
let taken = 0;
const probe = {
maxPerFrame: 256,
add(contact) {
if (taken >= 256) return false;
taken++;
contact.pending = true;
inFlight.push(contact);
return true;
},
};
const occlusion = new SurfaceOcclusion(probe);
const tests = new Array(2400).fill(0);
const lastStart = new Array(2400).fill(0);
let longestGap = 0;
for (let frame = 0; frame < 300; frame++) {
const now = frame * 33;
for (const contact of inFlight.splice(0)) contact.pending = false;
taken = 0;
occlusion.beginFrame(now);
for (let id = 0; id < 2400; id++) {
const before = taken;
occlusion.isHidden('traffic', id, anywhere);
if (taken !== before) {
tests[id]++;
if (frame > 60) longestGap = Math.max(longestGap, now - lastStart[id]);
lastStart[id] = now;
}
}
}
assert.ok(
Math.min(...tests) >= 8,
`fewest tests for one contact: ${Math.min(...tests)}`,
);
assert.ok(occlusion.retestMs > SURFACE_RETEST_MS);
assert.ok(longestGap <= occlusion.retestMs + 33, `longest gap ${longestGap}`);
assert.ok(occlusion.reappearMs >= 3 * occlusion.retestMs);
// A view the budget covers easily keeps the normal pace.
const few = new SurfaceOcclusion(probe);
for (let frame = 0; frame < 10; frame++) {
taken = 0;
few.beginFrame(frame * 16);
for (let id = 0; id < 300; id++) few.isHidden('traffic', id, anywhere);
}
assert.equal(few.retestMs, SURFACE_RETEST_MS);
assert.equal(few.reappearMs, SURFACE_REAPPEAR_MS);
});
test('a hidden contact comes back only after the reappear time without a hidden answer', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(0);
occlusion.isHidden('traffic', 'a', anywhere);
const hiddenAt = occlusion.now;
probe.answer(true, hiddenAt);
// Visible answers from now on; the contact still waits out the time.
let reappeared = null;
for (let wall = 16; wall < 2000 && reappeared === null; wall += 16) {
probe.answer(false, occlusion.now);
occlusion.beginFrame(wall);
if (!occlusion.isHidden('traffic', 'a', anywhere))
reappeared = occlusion.now;
}
assert.ok(reappeared - hiddenAt >= SURFACE_REAPPEAR_MS);
assert.ok(reappeared - hiddenAt < SURFACE_REAPPEAR_MS + 17);
});
test('a contact on an edge, answering either way in turn, stays hidden', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
const drawn = [];
let answers = 0;
for (let wall = 0; wall < 4000; wall += 16) {
if (probe.inFlight.length > 0)
probe.answer(answers++ % 2 === 0, occlusion.now);
occlusion.beginFrame(wall);
drawn.push(!occlusion.isHidden('traffic', 'a', anywhere));
}
// Drawn only until the first answer arrives.
assert.equal(drawn.lastIndexOf(true), 0);
});
test('when the probe stops answering, hidden contacts are drawn again and stay drawn', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(0);
occlusion.isHidden('traffic', 'a', anywhere);
const hiddenAt = occlusion.now;
probe.answer(true, hiddenAt);
// No depth any more: the probe takes no tests and answers nothing.
probe.accepting = false;
const drawn = [];
for (let wall = 16; wall < 3000; wall += 16) {
occlusion.beginFrame(wall);
drawn.push([
occlusion.now - hiddenAt,
!occlusion.isHidden('traffic', 'a', anywhere),
]);
}
for (const [age, shown] of drawn)
assert.equal(shown, age >= SURFACE_REAPPEAR_MS, `at ${age} ms`);
// A probe that never accepted anything hides nothing.
const dead = new SurfaceOcclusion({ add: () => false });
dead.beginFrame(0);
assert.equal(dead.isHidden('traffic', 'a', anywhere), false);
});
test('a change of pace does not bring back a hidden that has run out', () => {
// 2,400 contacts with a 256-a-frame probe: the hide time is longer at 33 ms
// frames than at 16 ms ones.
const inFlight = [];
let taken = 0;
const probe = {
maxPerFrame: 256,
add(contact) {
if (taken >= 256) return false;
taken++;
contact.pending = true;
inFlight.push(contact);
return true;
},
};
const occlusion = new SurfaceOcclusion(probe);
let wall = 0;
const paint = (step) => {
wall += step;
for (const contact of inFlight.splice(0)) contact.pending = false;
taken = 0;
occlusion.beginFrame(wall);
let watched = false;
for (let id = 0; id < 2400; id++) {
const hidden = occlusion.isHidden('traffic', id, anywhere);
if (id === 0) watched = hidden;
}
return watched;
};
for (let i = 0; i < 30; i++) paint(16);
// One hidden answer for contact 0, then visible answers only.
occlusion.contacts.get('traffic').get(0).lastHiddenAt = occlusion.now;
const states = [];
for (let i = 0; i < 80; i++) states.push(paint(16));
assert.equal(states[0], true);
assert.equal(states.at(-1), false, 'ran out at the fast pace');
// The frames slow down, which lengthens the hide time for new answers.
const slow = [];
for (let i = 0; i < 40; i++) slow.push(paint(33));
assert.ok(occlusion.reappearMs > SURFACE_REAPPEAR_MS);
assert.ok(
slow.every((hidden) => hidden === false),
'and stays out',
);
});
test('a pause in painting does not run the hidden time out', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(0);
occlusion.isHidden('traffic', 'a', anywhere);
probe.answer(true, occlusion.now);
occlusion.beginFrame(16);
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
// Nothing is painted for a minute (a hidden tab, an idle scene). On the
// first frame back the contact is still hidden: no answer could have come.
const before = occlusion.now;
occlusion.beginFrame(60_016);
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
// A clock that jumps backwards moves nothing.
occlusion.beginFrame(100);
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
});
test('prune forgets contacts that are no longer drawn; clear forgets everything', () => {
const probe = manualProbe();
const occlusion = new SurfaceOcclusion(probe);
let wall = 0;
/** Paint frames for `ms` of painted time, drawing the given contacts. */
const paint = (ms, ids) => {
for (const end = occlusion.now + ms; occlusion.now < end;) {
wall += 50;
occlusion.beginFrame(wall);
for (const id of ids) occlusion.isHidden('traffic', id, anywhere);
}
};
paint(100, ['gone', 'here']);
paint(SURFACE_FORGET_MS + 50, ['here']);
occlusion.prune();
assert.deepEqual([...occlusion.contacts.get('traffic').keys()], ['here']);
paint(SURFACE_FORGET_MS + 50, []);
occlusion.prune();
assert.equal(occlusion.contacts.size, 0);
occlusion.isHidden('traffic', 'again', anywhere);
occlusion.clear();
assert.equal(occlusion.contacts.size, 0);
assert.equal(occlusion.testedCount, 0);
});
test('a contact back in view after a while waits for a fresh answer, like a new one', () => {
const probe = manualProbe();
probe.mode = 'async';
const occlusion = new SurfaceOcclusion(probe);
let wall = 0;
const step = () => occlusion.beginFrame((wall += 16));
step();
occlusion.isHidden('traffic', 'a', anywhere);
probe.answer(false, occlusion.now);
step();
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
// Out of view for a moment shorter than a retest: drawn straight away.
for (let i = 0; i < 5; i++) step();
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
// Out of view for longer (the camera swung away and back): the old
// "visible" may now be behind a building. Held back, and tested at once.
const started = probe.started;
for (let i = 0; i < 30; i++) step();
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
assert.equal(probe.started, started + 1);
probe.answer(true, occlusion.now);
step();
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
// It leaves with a test in flight, and comes back. That test's answer was
// about the previous view: when it lands, it must not end the hold-back.
const other = new SurfaceOcclusion(probe);
probe.answer(false, 0);
other.beginFrame((wall += 16));
other.isHidden('traffic', 'b', anywhere);
assert.equal(probe.inFlight.length, 1, 'tested, unanswered');
for (let i = 0; i < 30; i++) other.beginFrame((wall += 16));
const before = probe.started;
assert.equal(other.isHidden('traffic', 'b', anywhere), true);
assert.equal(probe.started, before + 1, 'tested afresh at once');
const [stale] = probe.inFlight.splice(0, 1);
stale.pending = false;
stale.answered = true;
other.beginFrame((wall += 16));
assert.equal(
other.isHidden('traffic', 'b', anywhere),
true,
'the old answer did not end the hold-back',
);
probe.answer(false, other.now);
other.beginFrame((wall += 16));
assert.equal(other.isHidden('traffic', 'b', anywhere), false);
});
test('at a stretched retest pace, new contacts are held back longer for their first answer', () => {
const probe = manualProbe();
probe.mode = 'async';
probe.maxPerFrame = 100;
const occlusion = new SurfaceOcclusion(probe);
occlusion.beginFrame(0);
for (let id = 0; id < 2000; id++) occlusion.isHidden('traffic', id, anywhere);
occlusion.beginFrame(33);
// 2000 contacts at 100 a frame and 33 ms frames: a retest every 1.3 s.
assert.ok(occlusion.retestMs > 1000, `${occlusion.retestMs}`);
assert.equal(occlusion.firstAnswerMs, occlusion.retestMs * 2);
assert.ok(occlusion.firstAnswerMs > SURFACE_FIRST_ANSWER_MS);
// A contact the probe has not reached yet is still held back past the
// usual limit, and drawn once the stretched one has run out.
probe.accepting = false;
let wall = 33;
const ages = [];
const firstSeen = occlusion.now;
occlusion.isHidden('traffic', 'late', anywhere);
while (occlusion.now - firstSeen < occlusion.firstAnswerMs + 100) {
occlusion.beginFrame((wall += 33));
for (let id = 0; id < 2000; id++)
occlusion.isHidden('traffic', id, anywhere);
ages.push([
occlusion.now - firstSeen,
occlusion.isHidden('traffic', 'late', anywhere),
]);
}
const limit = occlusion.firstAnswerMs;
assert.ok(
ages.some(([age, hidden]) => age > SURFACE_FIRST_ANSWER_MS + 50 && hidden),
'held past 400 ms',
);
for (const [age, hidden] of ages)
if (age >= limit) assert.equal(hidden, false, `at ${age} ms`);
});
+1 -1
View File
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
const SIBLING_PICKS = [
['flights', 'a1b2c3', { id: 'a1b2c3' }],
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
['satellites', '25544', { id: 25544 }],
];
+351
View File
@@ -0,0 +1,351 @@
/**
* A stand-in scene and WebGL context for tests of the occlusion probe: just
* the members `sceneOcclusionProbe.js` uses, with depth images the test
* controls.
*
* The fake context cannot run GLSL. When the probe draws a point inside a
* query, the fake decides whether the point draws by applying the same rule
* to the same inputs the vertex shader gets (the uploaded attributes, the
* bound depth textures and the uniforms), using the CPU decode from
* `detectionOcclusion.js`. That checks everything around the shader; the
* shader itself is checked against `scene.pickPosition` in a real browser.
*/
import * as Cesium from 'cesium';
import { depthToViewDistance, unpackDepth } from '../detectionOcclusion.js';
/** Inverse of `unpackDepth`: four base-255 digits, most significant first. */
export function packDepth(depth) {
const bytes = [];
let rest = Math.min(Math.max(depth, 0), 1);
for (let i = 0; i < 4; i++) {
rest *= 255;
const digit = Math.min(255, Math.floor(rest));
bytes.push(digit);
rest -= digit;
}
return bytes;
}
/** Inverse of `depthToViewDistance`. */
export function viewDistanceToDepth(distance, near, far, logDepth) {
if (logDepth)
return Math.log2(distance - near + 1) / Math.log2(far - near + 1);
return (far * (distance - near)) / (distance * (far - near));
}
/**
* An RGBA8 depth image filled from a function of the texel.
* @param {number} width
* @param {number} height
* @param {(column: number, row: number) => number} depthAt Window depth; rows
* count from the bottom.
*/
export function depthImage(width, height, depthAt) {
const data = new Uint8Array(width * height * 4);
for (let row = 0; row < height; row++)
for (let column = 0; column < width; column++)
data.set(packDepth(depthAt(column, row)), (row * width + column) * 4);
return { width, height, data };
}
const GL = {
FRAMEBUFFER: 0x8d40,
READ_FRAMEBUFFER: 0x8ca8,
DRAW_FRAMEBUFFER: 0x8ca9,
FRAMEBUFFER_BINDING: 0x8ca6,
DRAW_FRAMEBUFFER_BINDING: 0x8ca6,
READ_FRAMEBUFFER_BINDING: 0x8caa,
TEXTURE_BINDING_2D: 0x8069,
TEXTURE_2D: 0x0de1,
TEXTURE0: 0x84c0,
ACTIVE_TEXTURE: 0x84e0,
COLOR_ATTACHMENT0: 0x8ce0,
FRAMEBUFFER_COMPLETE: 0x8cd5,
ARRAY_BUFFER: 0x8892,
ARRAY_BUFFER_BINDING: 0x8894,
VERTEX_ARRAY_BINDING: 0x85b5,
CURRENT_PROGRAM: 0x8b8d,
STREAM_DRAW: 0x88e0,
SCISSOR_TEST: 0x0c11,
VIEWPORT: 0x0ba2,
POINTS: 0x0000,
FLOAT: 0x1406,
RGBA: 0x1908,
RGBA8: 0x8058,
UNSIGNED_BYTE: 0x1401,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
ANY_SAMPLES_PASSED_CONSERVATIVE: 0x8d6a,
QUERY_RESULT: 0x8866,
QUERY_RESULT_AVAILABLE: 0x8867,
};
/**
* A recording WebGL context. `webgl2: false` leaves out queries, vertex
* arrays and immutable textures, as a WebGL 1 context does. Query results
* stay unavailable until `finishGpu()`.
*/
export function createFakeGl({ webgl2 = true } = {}) {
const state = {
read: null,
draw: null,
units: [null, null, null, null, null, null, null, null],
unit: 0,
array: null,
vertexArray: null,
program: null,
scissor: false,
viewport: [0, 0, 1920, 1080],
lost: false,
/** Whether a point that should draw does (false: a broken draw path). */
drawsLand: true,
/** Whether shaders compile: a boolean, or a function of the shader type. */
compiles: true,
};
const counts = {
readPixels: 0,
queries: 0,
draws: 0,
resultReads: 0,
deleted: 0,
};
const liveQueries = new Set();
const liveShaders = new Set();
let activeQuery = null;
const gl = {
...GL,
state,
counts,
liveQueries,
liveShaders,
isContextLost: () => state.lost,
getParameter(name) {
if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read;
if (name === GL.DRAW_FRAMEBUFFER_BINDING) return state.draw;
if (name === GL.TEXTURE_BINDING_2D) return state.units[state.unit];
if (name === GL.ACTIVE_TEXTURE) return GL.TEXTURE0 + state.unit;
if (name === GL.ARRAY_BUFFER_BINDING) return state.array;
if (name === GL.VERTEX_ARRAY_BINDING) return state.vertexArray;
if (name === GL.CURRENT_PROGRAM) return state.program;
if (name === GL.VIEWPORT) return Int32Array.from(state.viewport);
return null;
},
isEnabled: (cap) => cap === GL.SCISSOR_TEST && state.scissor,
enable(cap) {
if (cap === GL.SCISSOR_TEST) state.scissor = true;
},
disable(cap) {
if (cap === GL.SCISSOR_TEST) state.scissor = false;
},
createFramebuffer: () => ({ image: null }),
createTexture: () => ({ image: null }),
createBuffer: () => ({ data: null }),
deleteFramebuffer() {
counts.deleted++;
},
deleteTexture() {
counts.deleted++;
},
deleteBuffer() {
counts.deleted++;
},
activeTexture(unit) {
state.unit = unit - GL.TEXTURE0;
},
bindTexture(_target, texture) {
state.units[state.unit] = texture;
},
bindFramebuffer(target, framebuffer) {
if (target !== GL.DRAW_FRAMEBUFFER) state.read = framebuffer;
if (target !== GL.READ_FRAMEBUFFER) state.draw = framebuffer;
},
bindBuffer(_target, buffer) {
state.array = buffer;
},
readPixels(x, y, width, height, _format, _type, destination) {
counts.readPixels++;
const image = state.read.image;
for (let row = 0; row < height; row++) {
const from = ((y + row) * image.width + x) * 4;
destination.set(
image.data.subarray(from, from + width * 4),
row * width * 4,
);
}
},
};
if (webgl2) {
Object.assign(gl, {
texStorage2D(_target, _levels, _format, width, height) {
state.units[state.unit].image = {
width,
height,
data: new Uint8Array(width * height * 4),
};
},
framebufferTexture2D(_target, _attachment, _kind, texture) {
state.draw.image = texture.image;
},
checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE,
createShader(type) {
const shader = { type };
liveShaders.add(shader);
return shader;
},
shaderSource() {},
compileShader() {},
getShaderParameter: (shader) =>
typeof state.compiles === 'function'
? state.compiles(shader.type)
: state.compiles,
deleteShader(shader) {
liveShaders.delete(shader);
},
createProgram: () => ({ uniforms: {} }),
attachShader() {},
linkProgram() {},
getProgramParameter: () => true,
deleteProgram() {
counts.deleted++;
},
getUniformLocation: (_program, name) => name,
useProgram(program) {
state.program = program;
},
viewport(x, y, width, height) {
state.viewport = [x, y, width, height];
},
uniform1i(name, value) {
state.program.uniforms[name] = value;
},
uniform4f(name, x, y, z, w) {
state.program.uniforms[name] = [x, y, z, w];
},
createVertexArray: () => ({ buffer: null }),
deleteVertexArray() {
counts.deleted++;
},
bindVertexArray(vertexArray) {
state.vertexArray = vertexArray;
},
enableVertexAttribArray() {},
vertexAttribPointer() {
state.vertexArray.buffer = state.array;
},
bufferData(_target, data) {
state.array.data = Float32Array.from(data);
},
createQuery() {
const query = { drew: false, available: false, ended: false };
liveQueries.add(query);
return query;
},
deleteQuery(query) {
liveQueries.delete(query);
},
beginQuery(_target, query) {
counts.queries++;
query.drew = false;
query.available = false;
query.ended = false;
activeQuery = query;
},
endQuery() {
activeQuery.ended = true;
activeQuery = null;
},
/** The vertex shader's rule, applied to what the shader would be given. */
drawArrays(_mode, first) {
counts.draws++;
const uniforms = state.program.uniforms;
const data = state.vertexArray.buffer.data;
const u = data[first * 3];
const v = data[first * 3 + 1];
const threshold = data[first * 3 + 2];
let surface = -1;
for (let i = 0; i < uniforms.u_count && surface < 0; i++) {
const image = state.units[uniforms[`u_depth${i}`]].image;
const column = Math.min(image.width - 1, Math.floor(u * image.width));
const row = Math.min(image.height - 1, Math.floor(v * image.height));
const distance = depthToViewDistance(
unpackDepth(image.data, (row * image.width + column) * 4),
uniforms.u_near[i],
uniforms.u_far[i],
uniforms.u_logDepth === 1,
);
if (distance === distance) surface = distance;
}
const hidden = surface > 0 && surface < threshold;
// A point at the middle of the viewport lands only if that is the
// target's one pixel and the scissor test is not in the way. A
// sampler left on a unit with no texture fails the whole draw.
const samplersBound = [0, 1, 2, 3].every(
(i) => state.units[uniforms[`u_depth${i}`]]?.image,
);
const lands =
state.drawsLand &&
samplersBound &&
!state.scissor &&
state.draw?.image?.width === 1 &&
state.viewport.join() === '0,0,1,1';
if (activeQuery && !hidden && lands) activeQuery.drew = true;
},
getQueryParameter(query, name) {
if (name === GL.QUERY_RESULT_AVAILABLE) return query.available;
counts.resultReads++;
return query.available && query.drew ? 1 : 0;
},
});
}
/** Let every query issued so far finish. */
gl.finishGpu = () => {
for (const query of liveQueries) if (query.ended) query.available = true;
};
return gl;
}
/**
* A scene drawn in the given depth ranges, nearest first.
* @param {{
* gl?: object,
* ranges: Array<{near: number, far: number, image: object}>,
* logDepth?: boolean, fovy?: number, aspectRatio?: number,
* viewMatrix?: Cesium.Matrix4,
* }} options
*/
export function createFakeDepthScene({
gl = createFakeGl(),
ranges,
logDepth = true,
fovy = Math.PI / 2,
aspectRatio = 1,
viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
}) {
return {
mode: Cesium.SceneMode.SCENE3D,
useDepthPicking: true,
opaqueFrustumNearOffset: 0.9999,
context: { _gl: gl, depthTexture: true, webgl2: !!gl.createQuery },
_environmentState: { useGlobeDepthFramebuffer: true },
frameState: { useLogDepth: logDepth },
camera: { frustum: { fovy, aspectRatio }, viewMatrix },
view: {
frustumCommandsList: ranges.map(({ near, far }) => ({ near, far })),
pickDepths: ranges.map(({ image }) => {
const texture = { image };
return {
framebuffer: {
_framebuffer: { image },
getColorTexture: () => ({
width: image.width,
height: image.height,
_texture: texture,
}),
},
};
}),
},
};
}
+2
View File
@@ -693,6 +693,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'i',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'news', token: '00', disposition: 'enabled-only' }),
Object.freeze({
id: 'radio',
token: 'r',
@@ -722,6 +723,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'shipwrecks', token: '01', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
+18 -12
View File
@@ -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, 36);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
assert.equal(REGISTERED_LAYER_IDS.length, 38);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -243,17 +243,23 @@ 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);
}
assert.equal(nextLayerStateToken(), '00');
// 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.
const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS };
delete beforeNews.news;
delete beforeNews.shipwrecks;
assert.equal(nextLayerStateToken(beforeNews), '00');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
...beforeNews,
alpha: '9',
bravo: '00',
}),
'01',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...beforeNews,
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -304,8 +310,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true,
);
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
validateLayerStateAllocations(beforeNews, {
...beforeNews,
future: '00',
next: '01',
}),
@@ -313,8 +319,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
);
assert.throws(
() =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
validateLayerStateAllocations(beforeNews, {
...beforeNews,
future: '01',
}),
/next free token 00/,
@@ -341,8 +347,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
{ ...beforeNews, merged: '9' },
{ ...beforeNews, merged: '9', competing: '9' },
),
/next free token 00/,
);
@@ -500,7 +506,7 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
});
test('malformed enabled-layer lists reject the entire payload', () => {
for (const value of ['.c', 'c.', 'c..e', 'c.c', '00', 'c.00']) {
for (const value of ['.c', 'c.', 'c..e', 'c.c', 'zz', 'c.zz']) {
assert.equal(
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
null,
+104 -81
View File
@@ -97,8 +97,10 @@ function withPublishedBase(
}
}
const preWildlifeRows = reservationRows.filter(
([id]) => id !== 'wildlife-sightings',
// The ledger before Wildlife Sightings took the last digit and News and
// Shipwrecks the first pairs, for the allocation rules written against it.
const beforeLastDigitRows = reservationRows.filter(
([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id),
);
function writeCodecFixture(cwd, rows, entries) {
@@ -115,6 +117,16 @@ function writeCodecFixture(cwd, rows, entries) {
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
'',
);
if (!rows.some(([id]) => id === 'news'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'news',[\s\S]*?\}\),\n/,
'',
);
if (!rows.some(([id]) => id === 'shipwrecks'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/,
'',
);
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
const additions = entries
@@ -138,7 +150,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, 36);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -151,6 +163,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'ski-pistes': '7',
'drone-airspace': '8',
'wildlife-sightings': '9',
news: '00',
shipwrecks: '01',
},
);
assert.throws(
@@ -176,9 +190,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
parseLayerStateTokenReservations([...reservationRows, ['future', '02']])
.future,
'00',
'02',
);
assert.throws(
() =>
@@ -196,7 +210,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
{ rows: [...beforeLastDigitRows, ['retired-layer', '9']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
@@ -228,80 +242,86 @@ test('published retired digits and competing PRs advance the merge-time allocati
});
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
withPublishedBase(
{ rows: [...beforeLastDigitRows, ['pr-a', '9']] },
(cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
assert.match(
staleRebase.stderr,
/Duplicate layer-state token reservation/,
);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /36 published, 1 new/);
});
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...beforeLastDigitRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...beforeLastDigitRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /36 published, 1 new/);
},
);
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...preWildlifeRows,
...beforeLastDigitRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
@@ -360,7 +380,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
token,
}));
const fixtureRows = [
...preWildlifeRows,
...beforeLastDigitRows,
...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]),
];
@@ -369,13 +389,16 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal(
nextLayerStateToken(
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
parseLayerStateTokenReservations([
...beforeLastDigitRows,
...priorDigits,
]),
),
'00',
);
assert.equal(
validateLayerStateAllocations(
parseLayerStateTokenReservations(preWildlifeRows),
parseLayerStateTokenReservations(beforeLastDigitRows),
codec.LAYER_STATE_TOKEN_RESERVATIONS,
),
true,
@@ -400,19 +423,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', '00']],
rows: [...reservationRows, ['retired-layer', '02']],
source:
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n',
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 37);
assert.equal(published['retired-layer'], '00');
assert.equal(Object.keys(published).length, 39);
assert.equal(published['retired-layer'], '02');
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '00',
newcomer: '02',
}),
/changed or removed/,
);
@@ -18,12 +18,14 @@
["military", "m"],
["military-awareness", "g"],
["military-installations", "i"],
["news", "00"],
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["sea-marks", "6"],
["shipwrecks", "01"],
["ski-pistes", "7"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
+45 -3
View File
@@ -1,8 +1,11 @@
# Natural Earth packs
Offline named-region polygons for the voice-annotation resolver
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
range geometry with no network dependency.
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
layer reads the countries and the disputed areas
(`server/providers/news/areas.js`).
| File | Source dataset | Features |
|------|----------------|----------|
@@ -10,6 +13,7 @@ range geometry with no network dependency.
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
| `disputed_areas.json` | `ne_10m_admin_0_disputed_areas` (disputed, breakaway and UN-patrolled areas; a hand-picked set, see below) | 11 of 99 |
| `populated_places.json` | `ne_10m_populated_places_simple` (cities and towns, as points) | 7,342 |
**Source:** Natural Earth 10m physical vectors, via the canonical
@@ -105,6 +109,44 @@ Includes England, Scotland, Wales and Northern Ireland from
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
## Disputed areas (`disputed_areas.json`)
The News layer's areas that no one country's name may stand for
(`server/providers/news/areas.js`): a place inside one is named with the
area ("Crimea"), and Local there reads no country's outlets.
**Source:** `geojson/ne_10m_admin_0_disputed_areas.geojson` at the same pinned
commit (SHA-256
`9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5`), fetched
2026-11-05. Public domain, as above.
**Selection:** a hand-picked 11 of the source's 99 features. No rule picks
them; this is the list:
| Area | Natural Earth's note (`NOTE_BRK`) |
|------|-----------------------------------|
| Golan Heights | Admin. By Israel; Claimed by Syria |
| UNDOF Zone | UN Patrolled Zone in Syria |
| Transnistria | Self admin.; Claimed by Moldova |
| Abyei | Admin. by Sudan; Claimed by South Sudan |
| Crimea | Admin. by Russia; Claimed by Ukraine |
| Shebaa Farms | Admin. By Israel; Claimed by Lebanon |
| Bir Tawil | Between Egypt and Sudan |
| Halayib Triangle | Admin. by Egypt; Claimed by Sudan |
| East Jerusalem | Admin. By Israel; Claimed by Palestine |
| Abu Musa I. | Admin. by Iran; Claimed by UAE |
| Doumera Island | Admin. By Eritrea; Claimed by Djibouti |
The source names three of these by a country ("Moldova", "Sudan", "Iran");
the pack names them as the area. The 2014 Donetsk and Luhansk lines are left
out as out of date. Every other feature is left out too, so a place there is
named by its country in `countries.json`.
**Curation:** `kind` (`held`, `self`, `un`, `unclaimed`), `heldBy` and
`claimedBy` are read from `NOTE_BRK`, and `note` keeps it as published. Every
part is kept; each ring is Douglas-Peucker simplified at 0.003°, coordinates
are rounded to 3 decimals and rings are stored open. Provenance is in the
pack's `meta`. Result: 14,504 bytes.
## Populated places (`populated_places.json`)
The orbital view's "near <city>" (`src/data/placeBelow.js`): from 8x it
@@ -0,0 +1 @@
{"meta":{"title":"Natural Earth admin-0 disputed areas, for the News layer","source":"ne_10m_admin_0_disputed_areas","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_disputed_areas.geojson","sha256":"9cafef8b7dfb6b164dc58f218f981f4ace9f716f6c03795d4c62d1ac9f3d50f5","fetched":"2026-11-05","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","curation":"A hand-picked 11 of the source's 99 features: Golan Heights, UNDOF Zone, Transnistria, Abyei, Crimea, Shebaa Farms, Bir Tawil, Halayib Triangle, East Jerusalem, Abu Musa I. and Doumera Island. The source names three of them by a country (Moldova, Sudan, Iran); they are named here as the area. kind, heldBy and claimedBy are read from NOTE_BRK; note is NOTE_BRK as published. The 2014 Donetsk and Luhansk lines are left out as out of date. Every part kept; each ring Douglas-Peucker simplified at 0.003 degrees, coordinates rounded to 3 decimals, rings stored open."},"areas":[{"name":"Golan Heights","kind":"held","heldBy":"IL","claimedBy":"SY","note":"Admin. By Israel; Claimed by Syria","rings":[[[35.821,33.407],[35.81,33.36],[35.786,33.343],[35.764,33.334],[35.802,33.312],[35.769,33.273],[35.804,33.248],[35.808,33.202],[35.83,33.19],[35.833,33.161],[35.811,33.127],[35.811,33.112],[35.849,33.099],[35.846,33.085],[35.859,32.99],[35.888,32.945],[35.85,32.896],[35.838,32.866],[35.842,32.854],[35.834,32.828],[35.784,32.778],[35.758,32.744],[35.74,32.741],[35.685,32.711],[35.636,32.679],[35.612,32.682],[35.595,32.707],[35.6,32.717],[35.634,32.752],[35.635,32.769],[35.641,32.772],[35.635,32.794],[35.641,32.82],[35.638,32.832],[35.641,32.865],[35.638,32.875],[35.602,32.909],[35.615,32.921],[35.62,32.984],[35.588,33.018],[35.621,33.05],[35.634,33.052],[35.637,33.077],[35.656,33.125],[35.672,33.15],[35.661,33.213],[35.66,33.236],[35.666,33.247],[35.635,33.249],[35.608,33.239],[35.605,33.244],[35.618,33.256],[35.62,33.27],[35.631,33.275],[35.654,33.262],[35.712,33.307],[35.718,33.317],[35.716,33.327],[35.744,33.331],[35.769,33.343],[35.786,33.358],[35.806,33.391]]]},{"name":"UNDOF Zone","kind":"un","note":"UN Patrolled Zone in Syria","rings":[[[35.758,32.744],[35.784,32.778],[35.834,32.828],[35.842,32.854],[35.838,32.866],[35.85,32.896],[35.888,32.945],[35.859,32.99],[35.846,33.085],[35.849,33.099],[35.811,33.112],[35.811,33.127],[35.833,33.161],[35.83,33.19],[35.808,33.202],[35.804,33.248],[35.769,33.273],[35.802,33.312],[35.764,33.334],[35.786,33.343],[35.81,33.36],[35.821,33.407],[35.87,33.431],[35.873,33.416],[35.87,33.407],[35.845,33.389],[35.83,33.365],[35.83,33.351],[35.843,33.322],[35.845,33.304],[35.84,33.295],[35.819,33.284],[35.82,33.281],[35.851,33.276],[35.866,33.226],[35.889,33.199],[35.882,33.189],[35.885,33.179],[35.9,33.18],[35.911,33.166],[35.911,33.135],[35.892,33.124],[35.897,33.117],[35.903,33.117],[35.903,33.11],[35.868,33.107],[35.865,33.082],[35.877,32.993],[35.898,32.986],[35.924,32.968],[35.912,32.947],[35.89,32.929],[35.861,32.88],[35.843,32.822],[35.815,32.787],[35.791,32.774],[35.77,32.748],[35.764,32.747]]]},{"name":"Transnistria","kind":"self","claimedBy":"MD","note":"Self admin.; Claimed by Moldova","rings":[[[29.899,46.553],[29.868,46.58],[29.84,46.582],[29.831,46.588],[29.824,46.601],[29.776,46.58],[29.728,46.594],[29.748,46.617],[29.776,46.629],[29.759,46.658],[29.749,46.665],[29.728,46.67],[29.703,46.665],[29.696,46.676],[29.667,46.676],[29.69,46.707],[29.698,46.728],[29.665,46.801],[29.652,46.813],[29.62,46.815],[29.611,46.827],[29.619,46.832],[29.615,46.841],[29.59,46.824],[29.563,46.787],[29.549,46.793],[29.543,46.793],[29.531,46.783],[29.512,46.787],[29.498,46.801],[29.502,46.821],[29.495,46.828],[29.494,46.862],[29.477,46.889],[29.471,46.941],[29.46,46.953],[29.436,46.957],[29.376,46.935],[29.35,46.937],[29.316,46.958],[29.296,46.992],[29.325,46.988],[29.371,47.003],[29.378,47.012],[29.374,47.011],[29.364,47.033],[29.381,47.029],[29.383,47.042],[29.378,47.071],[29.372,47.078],[29.33,47.108],[29.296,47.122],[29.291,47.14],[29.237,47.162],[29.227,47.149],[29.221,47.149],[29.213,47.158],[29.188,47.162],[29.179,47.156],[29.18,47.145],[29.194,47.124],[29.19,47.111],[29.157,47.113],[29.148,47.096],[29.125,47.074],[29.099,47.092],[29.07,47.101],[29.095,47.135],[29.112,47.15],[29.145,47.163],[29.164,47.192],[29.179,47.197],[29.164,47.231],[29.094,47.31],[29.08,47.332],[29.079,47.348],[29.089,47.351],[29.123,47.329],[29.14,47.329],[29.154,47.336],[29.165,47.348],[29.139,47.376],[29.083,47.406],[29.091,47.437],[29.108,47.454],[29.111,47.464],[29.104,47.478],[29.093,47.487],[29.049,47.506],[29.034,47.518],[29.025,47.533],[29.016,47.582],[28.993,47.605],[28.987,47.622],[28.996,47.651],[28.988,47.661],[28.981,47.687],[28.976,47.743],[28.981,47.765],[29.018,47.827],[29.019,47.853],[28.981,47.875],[28.861,47.899],[28.829,47.917],[28.831,47.941],[28.813,47.965],[28.785,47.983],[28.754,47.979],[28.719,47.999],[28.698,48.022],[28.682,48.031]Line truncated
+1 -1
View File
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
[
'aisLiveVessels',
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
],
]) {
assert.match(
+2 -2
View File
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
value.callsign,
value.name,
value.label,
value.mmsi,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
const id = item?.icao24 || item?.id || item?.mmsi;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
+181 -8
View File
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
/** @constant {number} Dead reckoning never moves a contact faster than this
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
* cannot fly a coasting plane hundreds of kilometres. */
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
export const STALE_COAST_MAX_SEC = 300;
/** @constant {number} A report this close to a known ground height holds
* instead of coasting. */
export const STALE_HOLD_MAX_HEIGHT_M = 150;
/**
* Whether a contact whose reports have stopped holds at its last reported
* position instead of coasting: it reported on the ground, or a known ground
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
* taking off). A plane that low has no evidence of being anywhere but where
* it was last heard. Where the ground is unknown, barometric altitude is not
* read as height above it; the contact coasts under the stale cap.
* @param {object} report
* @param {boolean} [report.onGround]
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
* @returns {boolean}
*/
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
if (onGround === true) return true;
return (
Number.isFinite(heightM) &&
Number.isFinite(groundM) &&
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
);
}
/**
* The hold-or-coast decision for a report, made once and kept on the report,
* so a ground floor that resolves while the contact coasts cannot flip it and
* snap the icon back. Height and position come from the report itself.
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
* fix; `staleHolds` is stored on it.
* @param {object} args
* @param {boolean} [args.onGround] - The contact's ground flag.
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
* @returns {boolean}
*/
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
const carto = Cesium.Cartographic.fromCartesian(
fix.position,
Cesium.Ellipsoid.WGS84,
_holdCarto,
);
const groundM =
carto && typeof cachedGroundFloor === 'function'
? cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
)
: null;
fix.staleHolds = staleHoldsAtLastReport({
onGround,
heightM: carto?.height,
groundM,
});
return fix.staleHolds;
}
const _holdCarto = new Cesium.Cartographic();
/**
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
* after it, counting any coast a synthetic fix already took since the real
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
* never coasts further than one report would.
* @param {object} args
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
* @param {number} args.elapsedSec - Display time past `newest`.
* @param {number} args.coastLimitSec - Source limit past `newest`.
* @param {boolean} args.holds
* @returns {number}
*/
export function staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}) {
const alreadySec = Number.isFinite(newest?.reportEpochMs)
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
: 0;
return Math.max(
0,
staleCoastSeconds({
elapsedSec: elapsedSec + alreadySec,
coastLimitSec: coastLimitSec + alreadySec,
holds,
}) - alreadySec,
);
}
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
export const COAST_FLOOR_LIFT_M = 1.5;
/**
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
* ground is known; a coast runs level from the report and terrain may rise
* under it.
* @param {Cesium.Cartesian3} position - Mutated in place.
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
* @returns {Cesium.Cartesian3}
*/
export function floorCoastedPosition(position, cachedGroundFloor) {
if (typeof cachedGroundFloor !== 'function') return position;
const carto = Cesium.Cartographic.fromCartesian(
position,
Cesium.Ellipsoid.WGS84,
_coastCarto,
);
if (!carto) return position;
const ground = cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
return position;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
ground + COAST_FLOOR_LIFT_M,
Cesium.Ellipsoid.WGS84,
position,
);
}
const _coastCarto = new Cesium.Cartographic();
/**
* Seconds to coast past the last report: the elapsed time, bounded by the
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
* holds.
* @param {object} args
* @param {number} args.elapsedSec - Display time past the last report.
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
* @returns {number}
*/
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
if (holds || !(elapsedSec > 0)) return 0;
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
}
/**
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* kinematics. This prevents a late course change from reprojecting the entire
* stale interval and snapping the rendered contact by kilometres.
*
* The same stale rules as the display apply: no fix for a contact that holds
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
* most STALE_COAST_MAX_SEC counted from the real report
* (`reportEpochMs` carries it along a chain), and never below known ground.
* The result is a motion fix only, never a report for the trail.
*
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
* velocity?: number, track?: number}} newest Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
* Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
* cachedGroundFloor?: Function}} next
* New kinematics and the wall/source epoch at which they become authoritative.
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
* velocity: number, track: number}|null} Forward synthetic fix, or null.
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
* Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
if (newest.staleHolds === true) return null;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
const dtSec = staleCoastSecondsFrom({
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
elapsedSec: (next.epochMs - startEpochMs) / 1000,
coastLimitSec: Infinity,
holds: false,
});
arcOffsetEnu(
previousVelocity,
previousTrack,
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
Cesium.Ellipsoid.WGS84,
_forwardFixEnu,
);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
const position = floorCoastedPosition(
Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
),
next.cachedGroundFloor,
);
return {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: startEpochMs,
staleHolds: newest.staleHolds,
position,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
+2 -4
View File
@@ -21,7 +21,7 @@ const _owners = new Map();
*
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
* the vessel record OBJECT (identity = its `id`), and entity picks surface
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
* String so predicates match against one canonical form.
*
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
const unwrap = (id) => {
if (id === null || id === undefined) return undefined;
if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
return id.mmsi;
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined;
}
+8 -6
View File
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
});
test('resolvePickId: AIS vessel record object → its mmsi', () => {
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
assert.equal(resolvePickId({ id: record }), '367123450');
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
assert.equal(resolvePickId({ id: shared }), 'hull-b');
});
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
assert.equal(resolvePickId(null), null);
assert.equal(resolvePickId(undefined), null);
assert.equal(resolvePickId({}), null);
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
assert.equal(resolvePickId({ id: {} }), null); // object with no id
assert.equal(resolvePickId({ primitive: {} }), null);
});
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
try {
// Flights asking about a satellite pick (raw numeric id → String upstream)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
// Flights asking about a vessel pick (record object → mmsi)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
// Flights asking about a vessel pick (record object → id)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
// A layer never owns its own pick via the sibling scan
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
// Unknown ids belong to nobody
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
const scene = {
pick: () => mapTile,
drillPick: () => [mapTile, first, { id: 'civil' }],
+2 -2
View File
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
function fromRecord(record) {
const layerId = record.layerId || null;
const id =
layerId === 'ais-live-vessels' && record.properties?.mmsi
? String(record.properties.mmsi)
layerId === 'ais-live-vessels' && record.properties?.vesselId
? String(record.properties.vesselId)
: String(record.id);
return {
layerId,
+8 -7
View File
@@ -1,11 +1,12 @@
/**
* @module planespottersPhoto
* @description Reads a Planespotters photo answer into the one credited
* thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked
* on the condition that the photographer is credited next to it and the image
* links back to the photo's page, so a photo without a photographer, or whose
* image or page is not on Planespotters' own hosts, is "no photo": nothing
* arbitrary is ever shown or linked.
* @description Reads a Planespotters photo answer into the one thumbnail
* every surface shows. Planespotters lets the thumbnail be hotlinked on the
* condition that the photographer is credited next to it and the image links
* back to the photo's page. A photo whose answer names no photographer is
* still shown, linked to its page, with no credit line; a photo whose image or
* page is not on Planespotters' own hosts is "no photo": nothing arbitrary is
* ever shown or linked.
*
* Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`,
* `{photos: [...]}`), the cached server shape
@@ -63,7 +64,7 @@ function checked({ src, width, height, photographer, link }) {
const credit = typeof photographer === 'string' ? photographer.trim() : '';
const image = imageSrc(src);
const page = pageLink(link);
if (!credit || !image || !page) return null;
if (!image || !page) return null;
return {
found: true,
src: image,
+10 -3
View File
@@ -105,6 +105,16 @@ test('a server’s cached shape falls back to its small thumbnail when the large
}
});
test('a photo that names no photographer is still a photo, uncredited', () => {
for (const photographer of ['', ' ', 42, undefined]) {
assert.deepEqual(
parsePlanespottersPhoto(photo({ photographer })),
{ ...PHOTO, photographer: '' },
String(photographer),
);
}
});
test('no photo, junk, and missing fields are "no photo"', () => {
for (const payload of [
{ photos: [] },
@@ -114,9 +124,6 @@ test('no photo, junk, and missing fields are "no photo"', () => {
'nope',
{ photos: 'nope' },
{ photos: [null] },
photo({ photographer: '' }),
photo({ photographer: ' ' }),
photo({ photographer: 42 }),
photo({ link: '' }),
photo({ thumbnail: null, thumbnail_large: null }),
]) {
+38
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
normalizeRegionalArticles,
recentRegionalArticles,
regionalNewsWindowHours,
normalizeRegionalPlace,
normalizeRegionalWeather,
regionalDistanceM,
@@ -61,3 +65,37 @@ test('regional distance handles nearby movement and missing positions', () => {
assert.ok(distance > 11000 && distance < 11200);
assert.equal(regionalDistanceM(null, null), Infinity);
});
test('regional news is held to one 24-hour window, newest first, undated dropped', () => {
assert.equal(REGIONAL_NEWS_WINDOW_HOURS, 24);
assert.equal(googleNewsWindowOperator(), 'when:1d');
assert.equal(googleNewsWindowOperator(6), 'when:6h');
assert.equal(regionalNewsWindowHours('12'), 12);
assert.equal(regionalNewsWindowHours(0), 24);
assert.equal(regionalNewsWindowHours(undefined), 24);
const now = Date.parse('2026-11-04T12:00:00Z');
const at = (hours) => new Date(now - hours * 3_600_000).toISOString();
const rows = [
{ title: 'a', publishedAt: at(23) },
{ title: 'stale', publishedAt: at(25) },
{ title: 'undated', publishedAt: null },
{ title: 'garbled', publishedAt: 'not a date' },
{ title: 'b', publishedAt: at(1) },
{ title: 'future', publishedAt: at(-2) },
{ title: 'c', publishedAt: at(5) },
];
assert.deepEqual(
recentRegionalArticles(rows, { now }).map((row) => row.title),
['b', 'c', 'a'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, limit: 2 }).map((row) => row.title),
['b', 'c'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, windowHours: 4 }).map((row) => row.title),
['b'],
);
assert.deepEqual(recentRegionalArticles([], { now }), []);
assert.deepEqual(recentRegionalArticles(undefined, { now }), []);
});
+7
View File
@@ -55,6 +55,13 @@ export function normalizeRegionalPlace(payload) {
};
}
export {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
recentRegionalArticles,
regionalNewsWindowHours,
} from './regionalNewsWindow.js';
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
+66
View File
@@ -0,0 +1,66 @@
/** The regional-news freshness window, shared by the servers and the Cockpit. */
/** At most this many articles answer a regional brief. */
export const REGIONAL_NEWS_ROWS = 5;
/**
* The regional-news freshness window, in hours. The servers ask upstream for
* this window, drop anything older and report it as `newsWindowHours`; the
* Cockpit labels its list with it.
*/
export const REGIONAL_NEWS_WINDOW_HOURS = 24;
// A publish time this far ahead of the clock is skew, not a future story.
const REGIONAL_NEWS_CLOCK_SKEW_MS = 15 * 60_000;
/**
* A served window in hours, or the default when it is missing or out of range.
* @param {unknown} value
* @returns {number}
*/
export function regionalNewsWindowHours(value) {
const hours = Number(value);
return Number.isInteger(hours) && hours >= 1 && hours <= 168
? hours
: REGIONAL_NEWS_WINDOW_HOURS;
}
/**
* The Google News search operator asking for the window: `when:1d` for 24 h.
* @param {number} [hours]
* @returns {string}
*/
export function googleNewsWindowOperator(hours = REGIONAL_NEWS_WINDOW_HOURS) {
const span = regionalNewsWindowHours(hours);
return span % 24 === 0 ? `when:${span / 24}d` : `when:${span}h`;
}
/**
* The articles published inside the window, newest first, at most `limit`.
* An article without a parseable publish time is dropped: nothing shows it is
* recent. One dated more than 15 minutes ahead of `now` is dropped too.
* @param {Array<{publishedAt?: string|null}>} articles
* @param {{now?: number, windowHours?: number, limit?: number}} [options]
* @returns {Array<object>}
*/
export function recentRegionalArticles(
articles,
{
now = Date.now(),
windowHours = REGIONAL_NEWS_WINDOW_HOURS,
limit = REGIONAL_NEWS_ROWS,
} = {},
) {
const oldest = now - regionalNewsWindowHours(windowHours) * 3_600_000;
return (Array.isArray(articles) ? articles : [])
.map((article) => ({ article, at: Date.parse(article?.publishedAt) }))
.filter(
({ at }) =>
Number.isFinite(at) &&
at >= oldest &&
at <= now + REGIONAL_NEWS_CLOCK_SKEW_MS,
)
.sort((a, b) => b.at - a.at)
.slice(0, Math.max(0, limit))
.map(({ article }) => article);
}
+48
View File
@@ -11,6 +11,7 @@ import createViteConfig, {
validMilitaryInstallationBox,
validRegionalPoint,
} from '../../vite.config.js';
import { fetchRegionalNews } from '../../server/providers/regional/news.js';
test('missing Google place context is a quiet keyless capability, not a 503', () => {
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
@@ -113,3 +114,50 @@ test('regional brief treats an all-source outage as total failure', () => {
news: { status: 'unavailable' },
}), true);
});
test('local regional news asks for the last day, drops old and undated items, newest first', async (t) => {
const realFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = realFetch; });
const urls = [];
let rss;
let gdelt;
globalThis.fetch = async (url) => {
urls.push(new URL(String(url)));
const answer = String(url).includes('news.google') ? rss : gdelt;
return typeof answer === 'function' ? answer() : answer.clone();
};
const at = (hours) => new Date(Date.now() - hours * 3_600_000).toUTCString();
const item = (title, date) =>
`<item><title>${title}</title><link>https://news.example/${title}</link>${date ? `<pubDate>${date}</pubDate>` : ''}</item>`;
rss = new Response(`<rss><channel>${[
item('old', at(4 * 24)),
item('undated', null),
item('day', at(20)),
item('hour', at(1)),
].join('')}</channel></rss>`);
const place = { locality: 'Austin' };
const ready = await fetchRegionalNews(place);
assert.equal(urls[0].searchParams.get('q'), 'Austin when:1d');
assert.equal(ready.status, 'ready');
assert.deepEqual(ready.articles.map((row) => row.title), ['hour', 'day']);
// Nothing recent in RSS: GDELT is asked for the same 24 h window.
rss = new Response(`<rss><channel>${item('old', at(30))}</channel></rss>`);
gdelt = Response.json({ articles: [] });
urls.length = 0;
const empty = await fetchRegionalNews(place);
assert.equal(urls[1].searchParams.get('timespan'), '24h');
assert.deepEqual(empty, { status: 'empty', query: 'Austin', articles: [], source: 'GDELT fallback' });
// RSS answered with nothing recent and GDELT failed: still a true empty.
rss = new Response('<rss><channel></channel></rss>');
gdelt = () => Promise.reject(new TypeError('network'));
assert.equal((await fetchRegionalNews(place)).status, 'empty');
// Neither source answered, or no place: unavailable.
rss = () => Promise.resolve(new Response('busy', { status: 503 }));
const failed = await fetchRegionalNews(place);
assert.equal(failed.status, 'unavailable');
assert.deepEqual(failed.articles, []);
assert.equal((await fetchRegionalNews(null)).status, 'unavailable');
});
+10 -1
View File
@@ -46,7 +46,13 @@ function alongTrackKm(position, origin, destination) {
}
const NEAR_ENDPOINT_KM = 130;
/** Least allowed distance off the great circle, and how far past either
* endpoint the plane may be along it. */
const CROSS_TRACK_KM = 200;
/** On a long route the allowed distance off the great circle grows to this
* share of its length: airways and winds take a coast-to-coast flight several
* hundred kilometres off it (JFK–SFO: 623 km). */
const CROSS_TRACK_ROUTE_FRACTION = 0.15;
const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm
const LOCAL_AIRPORT_KM = 150;
@@ -90,6 +96,9 @@ export function routePlausible({
// existing loose check when the segment cannot be judged reliably.
const segmentKnown =
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
const corridorKm = segmentKnown
? Math.max(CROSS_TRACK_KM, CROSS_TRACK_ROUTE_FRACTION * routeKm)
: CROSS_TRACK_KM;
geomOk =
Math.abs(
crossTrackKm(
@@ -100,7 +109,7 @@ export function routePlausible({
destination.lat,
destination.lon,
),
) < CROSS_TRACK_KM &&
) < corridorKm &&
(!segmentKnown ||
(segmentAlongKm >= -CROSS_TRACK_KM &&
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
+18
View File
@@ -27,6 +27,24 @@ test('plane in London with an SFO→LAX route: implausible', () => {
}), false);
});
test('a long route allows a wider corridor; a short one does not', () => {
// JFK→SFO over Kansas, 384 km south of the great circle.
assert.equal(routePlausible({
latDeg: 38.21, lonDeg: -99.69, altitudeM: 10370, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), true);
// 516 km off the 543 km SFO→LAX route is still the wrong route.
assert.equal(routePlausible({
latDeg: 37.9, lonDeg: -115.0, altitudeM: 10000, verticalRateMps: 0,
origin: SFO, destination: LAX,
}), false);
// 960 km off, past 15 % of the 4,152 km JFK→SFO route, is still wrong.
assert.equal(routePlausible({
latDeg: 33.0, lonDeg: -99.69, altitudeM: 10000, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), false);
});
test('vertical trend: low climbing plane near SFO — origin must be local', () => {
const nearSfo = { latDeg: 37.7, lonDeg: -122.4, altitudeM: 2500 };
// JFK→SFO passes geometry (near destination), but a CLIMBING plane here just
+4 -1
View File
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
].map((name) => [name, {}]),
);
services.aircraftPresentation = { isTr3b: () => false };
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
const parts = {
queries: { _contactLabel: (_, info) => info.callsign },
motion: { _signalLost: () => null },
};
const tracking = createTracking({ flightState, services, parts });
for (const [position, expected] of [
[beyond, false],
+796
View File
@@ -0,0 +1,796 @@
/**
* Asks the GPU, per contact, whether the scene drew something in front of it,
* without waiting for the answer.
*
* The scene already keeps the depth of the frame it has just drawn (for
* picking). `scene.pickPosition` reads one texel of it back and waits, a
* fraction of a millisecond to over a millisecond per call; asking once per
* labelled contact turns a 2 ms frame into a 40 ms one. Copying the whole
* depth image back instead is no better where it matters: Chromium answers
* `getBufferSubData` with a round trip to its GPU process even after a sync
* object has signalled, measured here at 1.5 ms on a quiet machine and 15 to
* 25 ms on a busy one.
*
* So nothing is read back at all. Each tested contact becomes one point drawn
* into a private 1x1 target, inside an occlusion query. The vertex shader looks
* up the frame's depth at the contact's pixel, decodes it, and throws the point
* away when the drawn surface is nearer than the contact's threshold (see
* `projectContact`). The query's "did anything draw" bit is the answer, and
* asking whether a query result is in is the one question WebGL 2 lets a page
* put to the GPU without waiting (Chromium publishes results to the page
* through shared memory between frames): `_collect` just looks.
*
* - `beginFrame(scene, now)` collects the answers that have arrived.
* - `add(contact, position)` queues a contact for testing against this frame.
* - `endFrame()` draws the queued points. Answers land a frame or two later.
*
* A WebGL 1 context has no queries. There the probe falls back to reading the
* frame's depth back synchronously, at most every `SYNC_READ_INTERVAL_MS`, and
* answering on the CPU; `mode` then reads `'sync'`. Where the scene keeps no
* pick depth, or is not a 3D perspective view, `mode` reads `'off'`, nothing
* is tested and callers carry on as if no contact were hidden.
*
* Every read of renderer internals is in `readSceneDepthSource` below; the
* rest of the file is plain WebGL on the handles that function returns.
*/
import * as Cesium from 'cesium';
import { DepthSnapshot, projectContact } from './detectionOcclusion.js';
/**
* Depth ranges tested, nearest first. Logarithmic depth draws nearly every
* view in one or two; anything past the fourth is beyond where depth-tested
* contacts are labelled.
*/
export const PROBE_MAX_FRUSTUMS = 4;
/** Contacts tested per frame; the rest wait for the next one. */
export const PROBE_MAX_PER_FRAME = 256;
/** Painted frames a batch of queries may stay unanswered before it is dropped. */
export const PROBE_MAX_PENDING_FRAMES = 120;
/** Batches in a row whose control point failed before the probe stands down. */
export const PROBE_MAX_CONTROL_FAILURES = 3;
/** How long the probe stands down after a failure before trying again. */
export const PROBE_RETRY_AFTER_MS = 10_000;
/** Shortest gap between two synchronous depth reads (WebGL 1 fallback). */
export const SYNC_READ_INTERVAL_MS = 125;
/**
* Everything this module takes from the renderer, in one place.
*
* RENDERER INTERNALS. Checked against cesium 1.138.0 (@cesium/engine 22.3.0);
* re-check on an engine upgrade. Undocumented members used, and what each is
* assumed to be:
*
* - `scene.context._gl`: the WebGL context. `scene.context.webgl2` says which.
* - `scene.view.frustumCommandsList[i].near` / `.far`: the depth ranges the
* last frame was drawn in, nearest first.
* - `scene.view.pickDepths[i].framebuffer`: one framebuffer per depth range,
* refreshed every frame while `scene.useDepthPicking` is on and
* `scene._environmentState.useGlobeDepthFramebuffer` is true, whose first
* colour attachment is an RGBA8 texture holding window-space depth packed
* four bytes to a texel (`czm_packDepth`; see `unpackDepth`).
* `framebuffer._framebuffer` is the raw WebGL framebuffer, and
* `framebuffer.getColorTexture(0)` the texture (`._texture` its raw handle).
* - Opaque geometry in every range but the nearest is drawn with its near
* plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as
* `scene.pickPosition` assumes when it decodes the same textures.
* - `scene.frameState.useLogDepth`: whether depth is logarithmic (see
* `depthToViewDistance`). Anything but a boolean there is "no depth".
*
* Any of these missing or shaped differently returns null, which callers treat
* as "no depth".
*
* @param {Cesium.Scene} scene
* @param {Float64Array} [viewScratch] Sixteen numbers to write the view matrix
* into instead of allocating; the result's `view` is then this array.
* @returns {null | {
* gl: WebGLRenderingContext|WebGL2RenderingContext, webgl2: boolean,
* width: number, height: number, framebuffers: WebGLFramebuffer[],
* textures: WebGLTexture[],
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
* view: Float64Array, tanHalfFovX: number, tanHalfFovY: number,
* }}
*/
export function readSceneDepthSource(scene, viewScratch) {
try {
if (!scene || scene.mode !== Cesium.SceneMode.SCENE3D) return null;
if (scene.useDepthPicking !== true) return null;
const context = scene.context;
const gl = context?._gl;
if (!gl || context.depthTexture !== true) return null;
if (scene._environmentState?.useGlobeDepthFramebuffer !== true) return null;
const camera = scene.camera;
const frustum = camera?.frustum;
const fovy = frustum?.fovy;
const aspectRatio = frustum?.aspectRatio;
if (!(fovy > 0 && fovy < Math.PI) || !(aspectRatio > 0)) return null;
if (frustum.xOffset || frustum.yOffset) return null;
const view = scene.view;
const ranges = view?.frustumCommandsList;
const pickDepths = view?.pickDepths;
if (!ranges?.length || !pickDepths?.length) return null;
const count = Math.min(
ranges.length,
pickDepths.length,
PROBE_MAX_FRUSTUMS,
);
const nearOffset = Number(scene.opaqueFrustumNearOffset) || 1;
const framebuffers = [];
const textures = [];
const frustums = [];
let width = 0;
let height = 0;
for (let i = 0; i < count; i++) {
const framebuffer = pickDepths[i]?.framebuffer;
const raw = framebuffer?._framebuffer;
const texture = framebuffer?.getColorTexture?.(0);
const near = ranges[i]?.near * (i === 0 ? 1 : nearOffset);
const far = ranges[i]?.far;
if (!raw || !texture?._texture || !(near > 0) || !(far > near))
return null;
if (i === 0) {
width = texture.width;
height = texture.height;
} else if (texture.width !== width || texture.height !== height) {
return null;
}
framebuffers.push(raw);
textures.push(texture._texture);
frustums.push({ near, far });
}
if (!(width > 0) || !(height > 0)) return null;
// Read as standard depth, logarithmic depth puts every surface a few
// metres from the camera and would hide every contact: unknown is "no".
const logDepth = scene.frameState?.useLogDepth;
if (typeof logDepth !== 'boolean') return null;
const tanHalfFovY = Math.tan(fovy / 2);
return {
gl,
webgl2: context.webgl2 === true,
width,
height,
framebuffers,
textures,
frustums,
logDepth,
view: Cesium.Matrix4.toArray(
camera.viewMatrix,
viewScratch || new Float64Array(16),
),
tanHalfFovX: tanHalfFovY * aspectRatio,
tanHalfFovY,
};
} catch {
return null;
}
}
/**
* One point per contact. `a_contact` is (u, v, threshold): where the contact
* is in the frame, and the view distance a drawn surface must be under to hide
* it. A hidden contact's point is moved outside the clip volume, so nothing is
* drawn and its query reports no samples. A visible one lands on the single
* pixel of the 1x1 target (the viewport is set to match while these draw).
*/
export const PROBE_VERTEX_SHADER = `#version 300 es
precision highp float;
precision highp sampler2D;
layout(location = 0) in vec3 a_contact;
uniform sampler2D u_depth0;
uniform sampler2D u_depth1;
uniform sampler2D u_depth2;
uniform sampler2D u_depth3;
uniform int u_count;
uniform bool u_logDepth;
uniform vec4 u_near;
uniform vec4 u_far;
float viewDistance(sampler2D depthTexture, float near, float far) {
ivec2 size = textureSize(depthTexture, 0);
ivec2 texel = min(ivec2(a_contact.xy * vec2(size)), size - 1);
float depth = dot(
texelFetch(depthTexture, texel, 0),
vec4(1.0, 1.0 / 255.0, 1.0 / 65025.0, 1.0 / 16581375.0));
if (depth <= 0.0 || depth >= 1.0) return -1.0;
if (u_logDepth) return near + exp2(depth * log2(far - near + 1.0)) - 1.0;
return near * far / (far - depth * (far - near));
}
void main() {
float surface = viewDistance(u_depth0, u_near.x, u_far.x);
if (surface < 0.0 && u_count > 1) surface = viewDistance(u_depth1, u_near.y, u_far.y);
if (surface < 0.0 && u_count > 2) surface = viewDistance(u_depth2, u_near.z, u_far.z);
if (surface < 0.0 && u_count > 3) surface = viewDistance(u_depth3, u_near.w, u_far.w);
bool hidden = surface > 0.0 && surface < a_contact.z;
gl_Position = hidden ? vec4(2.0, 2.0, 2.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 1.0;
}
`;
const FRAGMENT_SHADER = `#version 300 es
precision mediump float;
out vec4 color;
void main() { color = vec4(1.0); }
`;
function canQuery(gl) {
return (
typeof gl.createQuery === 'function' &&
typeof gl.beginQuery === 'function' &&
typeof gl.getQueryParameter === 'function' &&
typeof gl.createVertexArray === 'function'
);
}
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
/** Owns the GPU objects and the query batches in flight. */
export class SceneOcclusionProbe {
/**
* @param {{
* now?: () => number,
* readSource?: typeof readSceneDepthSource,
* }} [options]
*/
constructor({
now = () => performance.now(),
readSource = readSceneDepthSource,
} = {}) {
this._clock = now;
this._readSource = readSource;
/** Most tests one frame takes; read by whoever schedules them. */
this.maxPerFrame = PROBE_MAX_PER_FRAME;
/** `'async'` (queries), `'sync'` (blocking read), or `'off'`. */
this.mode = 'off';
/** Milliseconds the last frame spent collecting answers and drawing. */
this.lastFrameMs = 0;
/** Contact tests started in the last frame. */
this.lastFrameTests = 0;
this._scene = null;
this._now = 0;
this._source = undefined;
this._gl = null;
/** A context the probe's shader failed to build on; never retried. */
this._unsupportedGl = null;
this._resources = null;
this._queryPool = [];
this._batches = [];
this._attributes = new Float32Array((PROBE_MAX_PER_FRAME + 1) * 3);
this._queued = [];
this._projected = { u: 0, v: 0, threshold: 0 };
this._controlFailures = 0;
this._retryAt = Number.NEGATIVE_INFINITY;
this._frame = 0;
this._view = new Float64Array(16);
this._snapshot = null;
this._snapshotAt = Number.NEGATIVE_INFINITY;
this._spent = 0;
}
/**
* Open a frame: take in the answers that have arrived. Call after the scene
* has rendered (a post-render listener), before `add`.
* @param {Cesium.Scene} scene
* @param {number} now The caller's clock, in milliseconds. Answers are
* dated by it, so it must be the clock the caller judges their age on.
*/
beginFrame(scene, now) {
const started = this._clock();
this._scene = scene;
this._now = now;
this._frame++;
this._source = undefined;
// A frame that never reached `endFrame` leaves its contacts untested.
this._release(this._queued);
this._queued.length = 0;
this.lastFrameTests = 0;
this._collect();
this._spent = this._clock() - started;
}
/**
* Queue a contact for testing against the frame just drawn.
* @param {{lastHiddenAt: number, pending: boolean, answered: boolean}}
* contact Updated when the answer arrives: `pending` cleared, `answered`
* set and, if the contact was hidden, `lastHiddenAt` set to the time of
* the frame it was tested against.
* @param {{x: number, y: number, z: number}} position
* @returns {boolean} False when the contact cannot be tested now.
*/
add(contact, position) {
if (this._source === undefined) this._openSource();
const source = this._source;
if (!source) return false;
if (this.mode === 'sync') {
const hidden = this._snapshot?.hides(position);
if (hidden === null || hidden === undefined) return false;
contact.answered = true;
if (hidden) contact.lastHiddenAt = this._now;
this.lastFrameTests++;
return true;
}
const queued = this._queued;
if (queued.length >= PROBE_MAX_PER_FRAME) return false;
const projected = this._projected;
if (
!projectContact(
source.view,
source.tanHalfFovX,
source.tanHalfFovY,
position,
projected,
)
)
return false;
// Slot 0 is the control point; contacts start at 1.
const at = (queued.length + 1) * 3;
const attributes = this._attributes;
attributes[at] = projected.u;
attributes[at + 1] = projected.v;
attributes[at + 2] = projected.threshold;
queued.push(contact);
contact.pending = true;
this.lastFrameTests++;
return true;
}
/** Close the frame: draw the queued contacts' queries. */
endFrame() {
const started = this._clock();
if (this._queued.length > 0 && this._source && this.mode === 'async') {
try {
this._draw(this._source);
} catch {
this._release(this._queued);
this._fail();
}
}
this._queued.length = 0;
this._source = undefined;
this.lastFrameMs = this._spent + (this._clock() - started);
}
/**
* Drop every test in flight but keep the GPU objects, so that switching the
* overlay off and on again does not compile the shader a second time.
*/
reset() {
const gl = this._gl;
for (const batch of this._batches) this._release(batch.contacts);
this._release(this._queued);
try {
if (gl && !gl.isContextLost?.()) {
for (const batch of this._batches)
for (let i = batch.cursor; i < batch.queries.length; i++)
this._queryPool.push(batch.queries[i]);
}
} catch {
// A context that is going away has already released them.
}
this._batches.length = 0;
this._queued.length = 0;
this._source = undefined;
this._snapshot = null;
this._snapshotAt = Number.NEGATIVE_INFINITY;
this._controlFailures = 0;
this._retryAt = Number.NEGATIVE_INFINITY;
this.mode = 'off';
}
/** Delete the GPU objects and drop every test in flight. */
destroy() {
this.reset();
const gl = this._gl;
try {
if (gl && !gl.isContextLost?.()) {
for (const query of this._queryPool) gl.deleteQuery(query);
const resources = this._resources;
if (resources) {
gl.deleteProgram(resources.program);
gl.deleteVertexArray(resources.vertexArray);
gl.deleteBuffer(resources.buffer);
gl.deleteFramebuffer(resources.framebuffer);
gl.deleteTexture(resources.texture);
}
}
} catch {
// A context that is going away has already released them.
}
this._queryPool.length = 0;
this._resources = null;
this._gl = null;
this._unsupportedGl = null;
}
/** Tests that will never be answered stop counting as in flight. */
_release(contacts) {
for (const contact of contacts) if (contact) contact.pending = false;
}
/**
* Something in the query path broke: stand down for a while and keep
* everything drawn, then try again in case the cause was passing.
*/
_fail() {
this._retryAt = this._now + PROBE_RETRY_AFTER_MS;
this._controlFailures = 0;
this.mode = 'off';
}
/** Read this frame's depth source, once, when the first contact asks. */
_openSource() {
this._source = null;
if (this._now < this._retryAt) return;
const source = this._readSource(this._scene, this._view);
if (!source) {
this.mode = 'off';
return;
}
if (source.gl !== this._gl) {
// A different context (a rebuilt viewer) orphans whatever was in flight.
this.destroy();
this._gl = source.gl;
}
try {
if (source.gl.isContextLost?.()) {
// Everything made on the lost context went with it.
this.reset();
this._queryPool.length = 0;
this._resources = null;
return;
}
if (source.gl === this._unsupportedGl) {
// The shader did not build on this context: nothing is tested here.
this.mode = 'off';
return;
}
if (source.webgl2 && canQuery(source.gl)) {
if (!this._resources && !this._createResources(source.gl)) {
this._fail();
return;
}
this.mode = 'async';
} else {
this.mode = 'sync';
if (this._now - this._snapshotAt >= SYNC_READ_INTERVAL_MS) {
this._snapshot = this._readSync(source);
this._snapshotAt = this._now;
}
}
this._source = source;
} catch {
this._fail();
}
}
/**
* Compile the shader and make the 1x1 target. This is the one place the
* probe does wait on the GPU process (compile, link and framebuffer status
* are answered synchronously): once per WebGL context, on the first frame a
* depth-tested contact is in view. `reset` keeps the result.
*/
_createResources(gl) {
const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER);
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
if (!vertex || !fragment) {
if (vertex) gl.deleteShader(vertex);
if (fragment) gl.deleteShader(fragment);
// The same source will not compile on this context next time either.
this._unsupportedGl = gl;
return false;
}
const program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
this._unsupportedGl = gl;
return false;
}
const uniform = (name) => gl.getUniformLocation(program, name);
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
const previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
const vertexArray = gl.createVertexArray();
const buffer = gl.createBuffer();
gl.bindVertexArray(vertexArray);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(previousArray);
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
const texture = gl.createTexture();
const framebuffer = gl.createFramebuffer();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1);
gl.bindTexture(gl.TEXTURE_2D, previousTexture);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.DRAW_FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
);
const complete =
gl.checkFramebufferStatus(gl.DRAW_FRAMEBUFFER) ===
gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
if (!complete) {
// Leave nothing half-made behind, so a later retry starts from scratch.
gl.deleteProgram(program);
gl.deleteVertexArray(vertexArray);
gl.deleteBuffer(buffer);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(texture);
return false;
}
this._resources = {
program,
vertexArray,
buffer,
texture,
framebuffer,
depth: [0, 1, 2, 3].map((i) => uniform(`u_depth${i}`)),
count: uniform('u_count'),
logDepth: uniform('u_logDepth'),
near: uniform('u_near'),
far: uniform('u_far'),
};
return true;
}
/**
* Draw one query per queued contact, preceded by a control point that must
* always draw. The renderer caches the framebuffer, viewport and scissor
* state it last set, so those are put back exactly as found, along with
* every binding touched here. Depth, stencil, blending and the colour mask
* need no care: the target has no depth or stencil attachment, and a query
* counts samples whether or not they are written.
*/
_draw(source) {
const gl = source.gl;
const resources = this._resources;
const contacts = this._queued.slice();
const count = contacts.length + 1;
const attributes = this._attributes;
// The control point: mid-frame, with a threshold nothing can be under.
attributes[0] = 0.5;
attributes[1] = 0.5;
attributes[2] = -1;
const layers = source.textures.length;
const frustums = source.frustums;
const previousProgram = gl.getParameter(gl.CURRENT_PROGRAM);
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
const previousUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
const previousViewport = gl.getParameter(gl.VIEWPORT);
const previousTextures = [];
const scissor = gl.isEnabled(gl.SCISSOR_TEST);
const queries = [];
const pool = this._queryPool;
let open = false;
try {
for (let i = 0; i < layers; i++) {
gl.activeTexture(gl.TEXTURE0 + i);
previousTextures.push(gl.getParameter(gl.TEXTURE_BINDING_2D));
gl.bindTexture(gl.TEXTURE_2D, source.textures[i]);
}
if (scissor) gl.disable(gl.SCISSOR_TEST);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resources.framebuffer);
gl.viewport(0, 0, 1, 1);
gl.useProgram(resources.program);
// Samplers past the ranges in use point at the first range's texture:
// every sampler a program declares must be drawable, read or not.
for (let i = 0; i < PROBE_MAX_FRUSTUMS; i++)
gl.uniform1i(resources.depth[i], i < layers ? i : 0);
gl.uniform1i(resources.count, layers);
gl.uniform1i(resources.logDepth, source.logDepth ? 1 : 0);
gl.uniform4f(
resources.near,
frustums[0].near,
frustums[1]?.near ?? 1,
frustums[2]?.near ?? 1,
frustums[3]?.near ?? 1,
);
gl.uniform4f(
resources.far,
frustums[0].far,
frustums[1]?.far ?? 2,
frustums[2]?.far ?? 2,
frustums[3]?.far ?? 2,
);
gl.bindVertexArray(resources.vertexArray);
gl.bindBuffer(gl.ARRAY_BUFFER, resources.buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
attributes.subarray(0, count * 3),
gl.STREAM_DRAW,
);
for (let i = 0; i < count; i++) {
const query = pool.pop() || gl.createQuery();
queries.push(query);
gl.beginQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE, query);
open = true;
gl.drawArrays(gl.POINTS, i, 1);
gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
open = false;
}
} catch (error) {
// Leave no query open and none unaccounted for; the caller stands down.
try {
if (open) gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
for (const query of queries) gl.deleteQuery(query);
} catch {
// Nothing more can be done for them.
}
throw error;
} finally {
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
gl.bindVertexArray(previousArray);
gl.useProgram(previousProgram);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
if (previousViewport)
gl.viewport(
previousViewport[0],
previousViewport[1],
previousViewport[2],
previousViewport[3],
);
if (scissor) gl.enable(gl.SCISSOR_TEST);
for (let i = previousTextures.length - 1; i >= 0; i--) {
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, previousTextures[i]);
}
gl.activeTexture(previousUnit);
}
this._batches.push({
queries,
contacts,
at: this._now,
frame: this._frame,
cursor: 0,
control: false,
});
}
/**
* Take in whatever answers have arrived, oldest batch first, without
* waiting for any that have not. An answer is dated to the frame its test
* was drawn on, so one that arrives late (the overlay was not painting for
* a while) is as stale as it is old.
*/
_collect() {
const batches = this._batches;
if (batches.length === 0) return;
const gl = this._gl;
try {
if (gl.isContextLost?.()) {
// Queries made on a lost context are gone with it.
this.reset();
this._queryPool.length = 0;
this._resources = null;
return;
}
while (batches.length > 0) {
const batch = batches[0];
const { queries, contacts } = batch;
while (batch.cursor < queries.length) {
const query = queries[batch.cursor];
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
const drew = !!gl.getQueryParameter(query, gl.QUERY_RESULT);
if (batch.cursor === 0) {
batch.control = drew;
} else {
const contact = contacts[batch.cursor - 1];
contact.pending = false;
// Without the control point's yes the batch means nothing: not a
// "hidden", and not a first answer that ends a new contact's
// hold-back either.
if (batch.control) {
contact.answered = true;
if (!drew && batch.at > contact.lastHiddenAt)
contact.lastHiddenAt = batch.at;
}
}
this._queryPool.push(query);
batch.cursor++;
}
if (batch.cursor < queries.length) break;
batches.shift();
if (batch.control) {
this._controlFailures = 0;
} else if (++this._controlFailures >= PROBE_MAX_CONTROL_FAILURES) {
this._fail();
}
}
// Whatever is still unanswered after too many frames is dropped,
// wherever it stands in the queue, so its contacts can be tested again.
const overdue = this._frame - PROBE_MAX_PENDING_FRAMES;
for (let i = batches.length - 1; i >= 0; i--) {
const batch = batches[i];
if (batch.frame >= overdue) continue;
for (let k = batch.cursor; k < batch.queries.length; k++)
gl.deleteQuery(batch.queries[k]);
for (
let k = Math.max(0, batch.cursor - 1);
k < batch.contacts.length;
k++
)
batch.contacts[k].pending = false;
batches.splice(i, 1);
}
} catch {
for (const batch of batches) this._release(batch.contacts);
batches.length = 0;
this._fail();
}
}
/**
* No queries (WebGL 1): read each depth range at full size and wait for it.
* Costs milliseconds per read; used only where nothing better exists.
*/
_readSync(source) {
const gl = source.gl;
const { width, height } = source;
const layers = source.framebuffers.length;
const layerBytes = width * height * 4;
const byteLength = layerBytes * layers;
const bytes =
this._snapshot?.bytes.length === byteLength
? this._snapshot.bytes
: new Uint8Array(byteLength);
const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING);
for (let i = 0; i < layers; i++) {
gl.bindFramebuffer(gl.FRAMEBUFFER, source.framebuffers[i]);
gl.readPixels(
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
bytes.subarray(i * layerBytes, (i + 1) * layerBytes),
);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
return new DepthSnapshot({
width,
height,
bytes,
frustums: source.frustums,
logDepth: source.logDepth,
// The source's matrix is a scratch array the next frame overwrites.
view: Float64Array.from(source.view),
tanHalfFovX: source.tanHalfFovX,
tanHalfFovY: source.tanHalfFovY,
});
}
}
+688
View File
@@ -0,0 +1,688 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
PROBE_MAX_CONTROL_FAILURES,
PROBE_MAX_FRUSTUMS,
PROBE_MAX_PENDING_FRAMES,
PROBE_MAX_PER_FRAME,
PROBE_RETRY_AFTER_MS,
PROBE_VERTEX_SHADER,
SYNC_READ_INTERVAL_MS,
SceneOcclusionProbe,
readSceneDepthSource,
} from './sceneOcclusionProbe.js';
import {
createFakeDepthScene,
createFakeGl,
depthImage,
viewDistanceToDepth,
} from './fixtures/fakeDepthScene.mjs';
const NEAR = 1;
const FAR = 100_000;
/** Left half a wall 400 m away, right half ground 500 m away. */
function wallImage(width, height, logDepth = true, wall = 400) {
return depthImage(width, height, (column) =>
viewDistanceToDepth(column < width / 2 ? wall : 500, NEAR, FAR, logDepth),
);
}
function wallScene({ width = 64, height = 32, logDepth = true, gl } = {}) {
return createFakeDepthScene({
gl,
logDepth,
aspectRatio: width / height,
ranges: [
{ near: NEAR, far: FAR, image: wallImage(width, height, logDepth) },
],
});
}
const behindWall = { x: -250, y: 0, z: -500 };
const onStreet = { x: 250, y: 0, z: -500 };
const contact = () => ({
lastHiddenAt: Number.NEGATIVE_INFINITY,
pending: false,
});
/** One frame: collect, queue the given contacts, draw. */
function frame(probe, scene, now, tests = []) {
probe.beginFrame(scene, now);
const accepted = tests.map(([entry, position]) => probe.add(entry, position));
probe.endFrame();
return accepted;
}
test('a test is drawn at once and answered on a later frame, without anything being read back', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const hidden = contact();
const shown = contact();
assert.equal(probe.mode, 'off');
assert.deepEqual(
frame(probe, scene, 1000, [
[hidden, behindWall],
[shown, onStreet],
]),
[true, true],
);
assert.equal(probe.mode, 'async');
assert.equal(probe.lastFrameTests, 2);
assert.equal(hidden.pending, true);
assert.equal(
gl.counts.queries,
3,
'the control point and one query per contact',
);
assert.equal(gl.counts.draws, 3);
// Frames pass before the GPU has answered: nothing changes, nothing waits.
frame(probe, scene, 1016);
frame(probe, scene, 1032);
assert.equal(hidden.pending, true);
assert.equal(hidden.lastHiddenAt, Number.NEGATIVE_INFINITY);
assert.equal(gl.counts.resultReads, 0);
assert.equal(gl.counts.readPixels, 0);
gl.finishGpu();
frame(probe, scene, 1048);
assert.equal(hidden.pending, false);
assert.equal(shown.pending, false);
assert.equal(hidden.answered, true);
assert.equal(shown.answered, true);
assert.equal(hidden.lastHiddenAt, 1000, 'dated to its test frame');
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
assert.equal(gl.counts.readPixels, 0, 'depth never leaves the GPU');
});
test('queries are reused, and every depth range is tested nearest first', () => {
const width = 64;
const height = 32;
const farNear = 900;
const farFar = 5_000_000;
const scene = createFakeDepthScene({
aspectRatio: 2,
ranges: [
// Near range: the wall on the left, nothing drawn on the right.
{
near: NEAR,
far: 1000,
image: depthImage(width, height, (column) =>
column < width / 2 ? viewDistanceToDepth(400, NEAR, 1000, true) : 1,
),
},
// Far range: ground 20 km away, drawn with its near plane pulled in.
{
near: farNear,
far: farFar,
image: depthImage(width, height, () =>
viewDistanceToDepth(20_000, farNear * 0.9999, farFar, true),
),
},
],
});
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const cases = [
[contact(), { x: -7500, y: 0, z: -15_000 }, true], // behind the near wall
[contact(), { x: 7500, y: 0, z: -15_000 }, false], // above the far ground
[contact(), { x: 15_000, y: 0, z: -30_000 }, true], // under the far ground
[contact(), { x: -150, y: 0, z: -300 }, false], // in front of the wall
];
for (let round = 0; round < 3; round++) {
frame(
probe,
scene,
round * 100,
cases.map(([entry, position]) => [entry, position]),
);
gl.finishGpu();
frame(probe, scene, round * 100 + 16);
for (const [entry, , hidden] of cases) {
assert.equal(entry.pending, false);
assert.equal(entry.lastHiddenAt === round * 100, hidden);
}
}
assert.equal(gl.liveQueries.size, 5, 'five queries serve every round');
const source = readSceneDepthSource(scene);
assert.equal(source.frustums[1].near, farNear * 0.9999);
});
test('standard depth scenes are tested too', () => {
const scene = wallScene({ logDepth: false });
const probe = new SceneOcclusionProbe();
const hidden = contact();
const shown = contact();
frame(probe, scene, 0, [
[hidden, behindWall],
[shown, onStreet],
]);
scene.context._gl.finishGpu();
frame(probe, scene, 16);
assert.equal(hidden.lastHiddenAt, 0);
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
});
test('every binding the renderer relies on is left as found', () => {
const scene = wallScene();
const gl = scene.context._gl;
const found = {
read: { image: null },
draw: { image: null },
units: [{}, {}, {}, {}, {}, {}, {}, {}],
unit: 5,
array: {},
vertexArray: {},
program: { uniforms: {} },
scissor: true,
viewport: [12, 34, 640, 480],
};
Object.assign(gl.state, found, { units: [...found.units] });
const probe = new SceneOcclusionProbe();
const hidden = contact();
frame(probe, scene, 0, [[hidden, behindWall]]);
for (const key of [
'read',
'draw',
'unit',
'array',
'vertexArray',
'program',
'scissor',
])
assert.equal(gl.state[key], found[key], key);
assert.deepEqual(gl.state.units, found.units);
assert.deepEqual(gl.state.viewport, [12, 34, 640, 480]);
// The scissor test and the offset viewport would both have kept the points
// off the target, so neither was in force for them: the control point drew,
// and the answer counts.
gl.finishGpu();
frame(probe, scene, 16);
assert.equal(hidden.lastHiddenAt, 0);
assert.equal(gl.state.scissor, true);
});
test('a contact outside the frame, or past the frame budget, is not taken', () => {
const scene = wallScene();
const probe = new SceneOcclusionProbe();
probe.beginFrame(scene, 0);
const outside = contact();
assert.equal(probe.add(outside, { x: -1100, y: 0, z: -500 }), false);
assert.equal(probe.add(outside, { x: 0, y: 0, z: 500 }), false);
assert.equal(outside.pending, false);
const taken = [];
for (let i = 0; i < PROBE_MAX_PER_FRAME + 10; i++)
taken.push(probe.add(contact(), onStreet));
probe.endFrame();
assert.equal(taken.filter(Boolean).length, PROBE_MAX_PER_FRAME);
assert.equal(taken.lastIndexOf(true), PROBE_MAX_PER_FRAME - 1);
assert.equal(scene.context._gl.counts.queries, PROBE_MAX_PER_FRAME + 1);
});
test('a frame with nothing to test touches nothing', () => {
let reads = 0;
const probe = new SceneOcclusionProbe({
readSource: () => {
reads++;
return null;
},
});
frame(probe, {}, 0);
frame(probe, {}, 16);
assert.equal(reads, 0);
assert.equal(probe.lastFrameTests, 0);
});
test('no depth source: nothing is tested and nothing is in flight', () => {
const broken = [
null,
{},
{ ...wallScene(), mode: Cesium.SceneMode.SCENE2D },
{ ...wallScene(), mode: Cesium.SceneMode.COLUMBUS_VIEW },
{ ...wallScene(), useDepthPicking: false },
{ ...wallScene(), _environmentState: { useGlobeDepthFramebuffer: false } },
{ ...wallScene(), view: { frustumCommandsList: [], pickDepths: [] } },
{ ...wallScene(), view: undefined },
(() => {
const scene = wallScene();
scene.context.depthTexture = false;
return scene;
})(),
(() => {
const scene = wallScene();
scene.camera.frustum = { width: 1000, aspectRatio: 2 }; // orthographic
return scene;
})(),
(() => {
const scene = wallScene();
scene.camera.frustum.xOffset = 10;
return scene;
})(),
(() => {
const scene = wallScene();
scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet
return scene;
})(),
// Whether depth is logarithmic, unknown: decoding it the wrong way would
// put every surface in front of every contact.
{ ...wallScene(), frameState: {} },
{ ...wallScene(), frameState: { useLogDepth: 1 } },
{ ...wallScene(), frameState: undefined },
(() => {
const scene = wallScene();
Object.defineProperty(scene, 'view', {
get() {
throw new Error('renderer changed shape');
},
});
return scene;
})(),
];
for (const scene of broken) {
assert.equal(readSceneDepthSource(scene), null);
const probe = new SceneOcclusionProbe();
const entry = contact();
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
assert.equal(probe.mode, 'off');
assert.equal(entry.pending, false);
}
});
test('depth that comes and goes is picked up frame by frame', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entry = contact();
scene.useDepthPicking = false;
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
assert.equal(probe.mode, 'off');
scene.useDepthPicking = true;
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [true]);
assert.equal(probe.mode, 'async');
gl.finishGpu();
frame(probe, scene, 32);
assert.equal(entry.lastHiddenAt, 16);
});
test('depth ranges past the limit are left out, nearest kept', () => {
const image = wallImage(8, 8);
const ranges = [];
for (let i = 0; i < PROBE_MAX_FRUSTUMS + 2; i++)
ranges.push({ near: 10 ** i, far: 10 ** (i + 1), image });
const source = readSceneDepthSource(createFakeDepthScene({ ranges }));
assert.equal(source.frustums.length, PROBE_MAX_FRUSTUMS);
assert.equal(source.textures.length, PROBE_MAX_FRUSTUMS);
assert.equal(source.frustums[0].near, 1);
});
test('a control point that does not draw voids its batch; a run of them stands the probe down for a while', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
// Something outside the probe's reach stops every point from drawing, so
// every query reads "nothing drew", which would otherwise mean "hidden".
gl.state.drawsLand = false;
let now = 0;
for (let i = 0; i < PROBE_MAX_CONTROL_FAILURES; i++) {
const entry = contact();
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [true]);
gl.finishGpu();
now += 16;
frame(probe, scene, now);
assert.equal(entry.pending, false);
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed');
assert.notEqual(entry.answered, true, 'and not a first answer either');
now += 16;
}
assert.equal(probe.mode, 'off');
const stoodDownAt = now - 16;
assert.deepEqual(frame(probe, scene, now, [[contact(), behindWall]]), [
false,
]);
assert.deepEqual(
frame(probe, scene, stoodDownAt + PROBE_RETRY_AFTER_MS - 1, [
[contact(), behindWall],
]),
[false],
);
// The cause has passed: the probe picks up where it left off.
gl.state.drawsLand = true;
const entry = contact();
const retryAt = stoodDownAt + PROBE_RETRY_AFTER_MS;
assert.deepEqual(frame(probe, scene, retryAt, [[entry, behindWall]]), [true]);
assert.equal(probe.mode, 'async');
gl.finishGpu();
frame(probe, scene, retryAt + 16);
assert.equal(entry.lastHiddenAt, retryAt);
assert.equal(entry.answered, true);
// One good batch in between resets the count.
const healthy = new SceneOcclusionProbe();
const other = wallScene();
for (let i = 0; i < 6; i++) {
other.context._gl.state.drawsLand = i % 2 === 1;
frame(healthy, other, i * 32, [[contact(), onStreet]]);
other.context._gl.finishGpu();
frame(healthy, other, i * 32 + 16);
}
assert.equal(healthy.mode, 'async');
});
test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => {
let scene = wallScene();
// Only the fragment shader fails: the vertex shader that did compile must
// not be left behind.
scene.context._gl.state.compiles = (type) =>
type !== scene.context._gl.FRAGMENT_SHADER;
let probe = new SceneOcclusionProbe();
let entry = contact();
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
assert.equal(probe.mode, 'off');
assert.equal(entry.pending, false);
// Nothing half-made is kept, and the same context is never asked again:
// compiling is a wait on the GPU process and would fail the same way.
let gl = scene.context._gl;
assert.equal(gl.liveShaders.size, 0, 'no shader left behind');
let shaderCalls = 0;
const createShader = gl.createShader;
gl.createShader = (...args) => {
shaderCalls++;
return createShader(...args);
};
for (const now of [PROBE_RETRY_AFTER_MS + 16, 5 * PROBE_RETRY_AFTER_MS])
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [false]);
assert.equal(shaderCalls, 0);
assert.equal(probe.mode, 'off');
// A draw that throws part-way: bindings restored, no query left behind.
scene = wallScene();
gl = scene.context._gl;
const found = {
draw: { image: null },
program: { uniforms: {} },
viewport: [0, 0, 800, 600],
};
Object.assign(gl.state, found, { scissor: true });
let draws = 0;
const drawArrays = gl.drawArrays;
gl.drawArrays = (...args) => {
if (++draws === 2) throw new Error('driver');
return drawArrays(...args);
};
probe = new SceneOcclusionProbe();
entry = contact();
const other = contact();
assert.deepEqual(
frame(probe, scene, 0, [
[entry, behindWall],
[other, onStreet],
]),
[true, true],
);
assert.equal(entry.pending, false, 'released when the draw failed');
assert.equal(other.pending, false);
assert.equal(probe.mode, 'off');
assert.equal(gl.state.draw, found.draw);
assert.equal(gl.state.program, found.program);
assert.deepEqual(gl.state.viewport, found.viewport);
assert.equal(gl.state.scissor, true);
assert.equal(gl.liveQueries.size, 0);
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [false]);
scene = wallScene();
gl = scene.context._gl;
probe = new SceneOcclusionProbe();
entry = contact();
frame(probe, scene, 0, [[entry, behindWall]]);
gl.getQueryParameter = () => {
throw new Error('driver');
};
frame(probe, scene, 16);
assert.equal(entry.pending, false);
assert.equal(probe.mode, 'off');
});
test('a frame that never reaches its end leaves no contact waiting for an answer', () => {
const scene = wallScene();
const probe = new SceneOcclusionProbe();
const entry = contact();
probe.beginFrame(scene, 0);
assert.equal(probe.add(entry, behindWall), true);
assert.equal(entry.pending, true);
// The painter threw before endFrame; the next frame starts clean.
probe.beginFrame(scene, 16);
assert.equal(entry.pending, false);
assert.equal(probe.add(entry, behindWall), true);
probe.endFrame();
assert.equal(
scene.context._gl.counts.queries,
2,
'only the second frame drew',
);
});
test('an answer collected late is dated to its test, not to its arrival', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entry = contact();
frame(probe, scene, 1000, [[entry, behindWall]]);
gl.finishGpu();
// The overlay paints nothing for 20 seconds, then resumes.
frame(probe, scene, 21_000);
assert.equal(entry.pending, false);
assert.equal(entry.lastHiddenAt, 1000);
// An older answer never overwrites a newer one.
entry.lastHiddenAt = 30_000;
frame(probe, scene, 22_000, [[entry, behindWall]]);
gl.finishGpu();
frame(probe, scene, 22_016);
assert.equal(entry.lastHiddenAt, 30_000);
});
test('samplers for depth ranges not in use point at a texture that is bound', () => {
const scene = wallScene();
const gl = scene.context._gl;
// Whatever the renderer left on the other units is not the probe's to read.
gl.state.units = [null, null, null, null, null, null, null, null];
const probe = new SceneOcclusionProbe();
const entry = contact();
frame(probe, scene, 0, [[entry, behindWall]]);
gl.finishGpu();
frame(probe, scene, 16);
assert.equal(
entry.lastHiddenAt,
0,
'the draw was valid, so the answer counts',
);
assert.deepEqual(gl.state.units, [
null,
null,
null,
null,
null,
null,
null,
null,
]);
});
test('reset drops tests in flight but keeps the GPU objects for next time', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entry = contact();
frame(probe, scene, 0, [[entry, behindWall]]);
probe.reset();
assert.equal(entry.pending, false);
assert.equal(probe.mode, 'off');
assert.equal(gl.counts.deleted, 0);
gl.finishGpu();
frame(probe, scene, 16);
assert.equal(
entry.lastHiddenAt,
Number.NEGATIVE_INFINITY,
'the dropped answer is not applied',
);
frame(probe, scene, 32, [[entry, behindWall]]);
assert.equal(probe.mode, 'async');
assert.equal(gl.liveQueries.size, 2, 'queries are reused, not recreated');
});
test('a lost context drops what was in flight and starts over when it returns', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entry = contact();
frame(probe, scene, 0, [[entry, behindWall]]);
gl.state.lost = true;
assert.deepEqual(frame(probe, scene, 16, [[contact(), behindWall]]), [false]);
assert.equal(entry.pending, false);
assert.equal(probe.mode, 'off');
gl.state.lost = false;
assert.deepEqual(frame(probe, scene, 32, [[entry, behindWall]]), [true]);
gl.finishGpu();
frame(probe, scene, 48);
assert.equal(entry.lastHiddenAt, 32);
});
test('queries that are never answered are abandoned', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entry = contact();
frame(probe, scene, 0, [[entry, behindWall]]);
for (let i = 1; i <= PROBE_MAX_PENDING_FRAMES; i++)
frame(probe, scene, i * 16);
assert.equal(entry.pending, true);
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + 1) * 16);
assert.equal(entry.pending, false);
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY);
assert.equal(gl.liveQueries.size, 0);
assert.equal(probe.mode, 'async', 'and the next contact is tested as usual');
});
test('every batch times out on its own, however many are queued ahead of it', () => {
const scene = wallScene();
const gl = scene.context._gl;
const probe = new SceneOcclusionProbe();
const entries = [];
// Ten frames each start a batch; the GPU answers none of them.
for (let i = 0; i < 10; i++) {
const entry = contact();
entries.push(entry);
frame(probe, scene, i * 16, [[entry, behindWall]]);
}
for (let i = 10; i <= PROBE_MAX_PENDING_FRAMES; i++)
frame(probe, scene, i * 16);
assert.ok(entries.every((entry) => entry.pending));
// Ten more frames and all ten have been dropped, not one every 120 frames.
for (let i = 1; i <= 10; i++)
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + i) * 16);
assert.ok(entries.every((entry) => !entry.pending));
assert.equal(gl.liveQueries.size, 0);
});
test('a target that cannot be made leaves nothing behind, and the next try starts from scratch', () => {
const scene = wallScene();
const gl = scene.context._gl;
const complete = gl.checkFramebufferStatus;
gl.checkFramebufferStatus = () => 0;
const probe = new SceneOcclusionProbe();
const entry = contact();
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
assert.equal(probe.mode, 'off');
assert.equal(gl.counts.deleted, 5, 'program, array, buffer, target');
// Memory pressure passes; after the stand-down the probe builds it all again.
gl.checkFramebufferStatus = complete;
assert.deepEqual(
frame(probe, scene, PROBE_RETRY_AFTER_MS, [[entry, behindWall]]),
[true],
);
assert.equal(probe.mode, 'async');
gl.finishGpu();
frame(probe, scene, PROBE_RETRY_AFTER_MS + 16);
assert.equal(entry.lastHiddenAt, PROBE_RETRY_AFTER_MS);
});
test('a new WebGL context, or destroy, releases everything', () => {
const probe = new SceneOcclusionProbe();
const first = wallScene();
const entry = contact();
frame(probe, first, 0, [[entry, behindWall]]);
const second = wallScene();
const again = contact();
assert.deepEqual(frame(probe, second, 16, [[again, behindWall]]), [true]);
assert.equal(entry.pending, false, 'the first context will never answer');
assert.equal(first.context._gl.liveQueries.size, 0);
assert.equal(
first.context._gl.counts.deleted,
5,
'program, array, buffer, target',
);
probe.destroy();
assert.equal(again.pending, false);
assert.equal(second.context._gl.liveQueries.size, 0);
assert.equal(probe.mode, 'off');
});
test('without queries (WebGL 1) depth is read back synchronously, at most every interval, and the mode says so', () => {
const gl = createFakeGl({ webgl2: false });
const scene = wallScene({ gl });
assert.equal(scene.context.webgl2, false);
const sceneFramebuffer = { image: null };
gl.state.read = sceneFramebuffer;
gl.state.draw = sceneFramebuffer;
const probe = new SceneOcclusionProbe();
const hidden = contact();
const shown = contact();
assert.deepEqual(
frame(probe, scene, 1000, [
[hidden, behindWall],
[shown, onStreet],
[contact(), { x: -1100, y: 0, z: -500 }],
]),
[true, true, false],
);
assert.equal(probe.mode, 'sync');
assert.equal(gl.counts.readPixels, 1);
assert.equal(gl.state.read, sceneFramebuffer);
assert.equal(gl.state.draw, sceneFramebuffer);
assert.equal(hidden.lastHiddenAt, 1000, 'answered on the spot');
assert.equal(hidden.pending, false);
assert.equal(shown.answered, true);
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
// Frames inside the interval answer from the read already made.
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS - 1, [[hidden, behindWall]]);
assert.equal(gl.counts.readPixels, 1);
assert.equal(hidden.lastHiddenAt, 1000 + SYNC_READ_INTERVAL_MS - 1);
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS, [[hidden, behindWall]]);
assert.equal(gl.counts.readPixels, 2);
});
test('the shader decodes depth the way the CPU decode does', () => {
// The fake context stands in for the shader, so pin the parts of the source
// the two must agree on: the unpack weights, both depth formulas, the
// nearest-range-first order and the comparison.
const source = PROBE_VERTEX_SHADER.replace(/\s+/g, ' ');
assert.match(
source,
/vec4\(1\.0, 1\.0 \/ 255\.0, 1\.0 \/ 65025\.0, 1\.0 \/ 16581375\.0\)/,
);
assert.match(source, /if \(depth <= 0\.0 \|\| depth >= 1\.0\) return -1\.0;/);
assert.match(
source,
/near \+ exp2\(depth \* log2\(far - near \+ 1\.0\)\) - 1\.0/,
);
assert.match(source, /near \* far \/ \(far - depth \* \(far - near\)\)/);
assert.match(
source,
/bool hidden = surface > 0\.0 && surface < a_contact\.z;/,
);
assert.ok(
source.indexOf('u_depth0, u_near.x') < source.indexOf('u_depth1, u_near.y'),
);
});
+332
View File
@@ -0,0 +1,332 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { shipwrecksProxy } from '../../server/providers/shipwrecks.js';
const EMODNET = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'heritageshipwrecks.1',
geometry: { type: 'Point', coordinates: [-0.92, 50.74] },
properties: { name: 'HMS/m A1', sink_yr: '1911', country: 'UK' },
},
],
};
const WIKIDATA = {
results: {
bindings: [
{
item: { value: 'http://www.wikidata.org/entity/Q25173' },
itemLabel: { value: 'Titanic' },
coord: { value: 'Point(-49.9469 41.7325)' },
},
],
},
};
/**
* Upstream fake. Each source answers 'ok', 'down' (HTTP 503) or 'malformed'
* (HTTP 200 with a body that is not that source's data); change `state` to
* switch between requests.
*/
function fakeFetch({ wikidataFails = false, emodnetFails = false } = {}) {
const calls = [];
const state = {
emodnet: emodnetFails ? 'down' : 'ok',
wikidata: wikidataFails ? 'down' : 'ok',
};
const fetchImpl = async (url, options) => {
calls.push({ url: String(url), options });
const href = String(url);
const name = href.includes('query.wikidata.org')
? 'wikidata'
: href.includes('ows.emodnet-humanactivities.eu')
? 'emodnet'
: null;
if (!name) return new Response('', { status: 404 });
if (state[name] === 'down') return new Response('', { status: 503 });
if (state[name] === 'malformed') return Response.json({});
return Response.json(name === 'wikidata' ? WIKIDATA : EMODNET);
};
const callsTo = (name) =>
calls.filter(({ url }) =>
url.includes(
name === 'wikidata'
? 'query.wikidata.org'
: 'ows.emodnet-humanactivities',
),
).length;
return { fetchImpl, calls, state, callsTo };
}
const HOUR = 60 * 60 * 1000;
function handlerOf(plugin) {
let handler;
plugin.configureServer({
middlewares: { use: (_path, fn) => (handler = fn) },
});
return handler;
}
function request(handler, { method = 'GET', url = '/' } = {}) {
return new Promise((resolve) => {
const res = {
destroyed: false,
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
resolve({
status: this.status,
headers: this.headers,
body: JSON.parse(body),
});
},
};
handler(
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
res,
);
});
}
test('serves one cached snapshot from fixed upstreams', async () => {
const { fetchImpl, calls } = fakeFetch();
const handler = handlerOf(shipwrecksProxy({ fetchImpl }));
const first = await request(handler);
assert.equal(first.status, 200);
assert.equal(first.body.records.length, 2);
const a1 = first.body.records.find((record) => record.name === 'HMS/m A1');
assert.equal(a1.year, 1911);
assert.deepEqual(first.body.sources, { emodnet: true, wikidata: true });
for (const { url } of calls)
assert.match(
url,
/^https:\/\/(ows\.emodnet-humanactivities\.eu\/wfs\?|query\.wikidata\.org\/sparql\?)/,
);
const count = calls.length;
await request(handler);
assert.equal(calls.length, count, 'second request is served from cache');
});
test('one failing source still serves the other; both failing is a 502', async () => {
const partial = handlerOf(
shipwrecksProxy({
fetchImpl: fakeFetch({ wikidataFails: true }).fetchImpl,
}),
);
const ok = await request(partial);
assert.equal(ok.status, 200);
assert.deepEqual(ok.body.sources, { emodnet: true, wikidata: false });
const down = handlerOf(
shipwrecksProxy({
fetchImpl: fakeFetch({ wikidataFails: true, emodnetFails: true })
.fetchImpl,
}),
);
assert.equal((await request(down)).status, 502);
});
test('a failed refresh serves the last snapshot as stale', async () => {
let clock = 0;
let fail = false;
const { fetchImpl: good } = fakeFetch();
const { fetchImpl: bad } = fakeFetch({
wikidataFails: true,
emodnetFails: true,
});
const handler = handlerOf(
shipwrecksProxy({
fetchImpl: (...args) => (fail ? bad : good)(...args),
now: () => clock,
}),
);
await request(handler);
fail = true;
clock = 25 * 60 * 60 * 1000;
const stale = await request(handler);
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.headers['X-Data-Stale'], 'true');
});
test('rejects other methods and routes', async () => {
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: fakeFetch().fetchImpl }),
);
assert.equal((await request(handler, { method: 'POST' })).status, 405);
assert.equal((await request(handler, { url: '/x' })).status, 404);
});
test("a refresh that gets nothing usable keeps every source's last good wrecks", async () => {
let clock = 0;
const upstream = fakeFetch();
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
);
const first = await request(handler);
assert.equal(first.body.records.length, 2);
// A day later EMODnet is down and Wikidata answers 200 with no results.
upstream.state.emodnet = 'down';
upstream.state.wikidata = 'malformed';
clock = 25 * HOUR;
const later = await request(handler);
assert.equal(later.status, 200);
assert.equal(later.body.records.length, 2, 'no wreck is lost');
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
assert.equal(later.body.stale, true);
assert.equal(later.body.fetchedAt, 0, 'the age of the oldest data served');
});
test('one source failing keeps its own last records while the other refreshes', async () => {
let clock = 0;
const upstream = fakeFetch();
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
);
const first = await request(handler);
assert.equal((await request(handler)).body.version, first.body.version);
upstream.state.wikidata = 'down';
clock = 25 * HOUR;
const later = await request(handler);
assert.notEqual(later.body.version, first.body.version, 'new EMODnet data');
assert.equal(later.body.records.length, 2);
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
assert.equal(later.body.stale, true);
assert.equal(upstream.callsTo('emodnet'), 2, 'EMODnet was refreshed');
assert.deepEqual(later.body.ages, { emodnet: 0, wikidata: 25 * HOUR });
assert.equal(later.body.maxAgeMs, 7 * 24 * HOUR);
});
test('a failed source waits before it is asked again', async () => {
let clock = 0;
const upstream = fakeFetch();
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
);
await request(handler);
upstream.state.emodnet = 'down';
upstream.state.wikidata = 'down';
clock = 25 * HOUR;
await request(handler);
const asked = upstream.calls.length;
for (let i = 0; i < 10; i += 1) {
clock += 60 * 1000;
const res = await request(handler);
assert.equal(res.status, 200);
assert.equal(res.body.stale, true);
}
assert.equal(upstream.calls.length, asked, 'no upstream call while waiting');
clock = 25 * HOUR + 16 * 60 * 1000;
upstream.state.emodnet = 'ok';
upstream.state.wikidata = 'ok';
const back = await request(handler);
assert.equal(
upstream.calls.length,
asked + 2,
'both are retried after the wait',
);
assert.equal(back.body.stale, undefined);
});
test('with no data yet, failures are retried only after the wait', async () => {
let clock = 0;
const upstream = fakeFetch({ emodnetFails: true, wikidataFails: true });
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
);
assert.equal((await request(handler)).status, 502);
const asked = upstream.calls.length;
clock += 5 * 60 * 1000;
assert.equal((await request(handler)).status, 502);
assert.equal(upstream.calls.length, asked);
clock += 11 * 60 * 1000;
assert.equal((await request(handler)).status, 502);
assert.equal(upstream.calls.length, asked * 2);
});
test('a source older than seven days is no longer served', async () => {
let clock = 0;
const upstream = fakeFetch();
const handler = handlerOf(
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
);
await request(handler);
upstream.state.emodnet = 'down';
upstream.state.wikidata = 'down';
clock = 7 * 24 * HOUR;
assert.equal((await request(handler)).status, 200, 'seven days is served');
clock = 7 * 24 * HOUR + 16 * 60 * 1000;
assert.equal((await request(handler)).status, 502, 'older is dropped');
});
test('an upstream body over its cap is a failed source, read only to the cap', async () => {
const MIB = 1024 * 1024;
const chunk = new Uint8Array(MIB).fill(0x20);
let sent = 0;
let cancelled = false;
const endless = () =>
new ReadableStream({
pull(controller) {
sent += chunk.byteLength;
controller.enqueue(chunk);
},
cancel() {
cancelled = true;
},
});
const handler = handlerOf(
shipwrecksProxy({
fetchImpl: async (url) =>
String(url).includes('query.wikidata.org')
? Response.json(WIKIDATA)
: new Response(endless()),
}),
);
const res = await request(handler);
assert.equal(res.status, 200);
assert.deepEqual(res.body.sources, { emodnet: false, wikidata: true });
assert.ok(cancelled, 'the oversized body is cancelled');
assert.ok(sent <= 34 * MIB, `read ${sent / MIB} MiB of a 32 MiB cap`);
});
test('a refresh far smaller than the last good set is refused until it expires', async () => {
let clock = 0;
const many = {
results: {
bindings: Array.from({ length: 10 }, (_, i) => ({
item: { value: `http://www.wikidata.org/entity/Q${100 + i}` },
itemLabel: { value: `Wreck ${i}` },
coord: { value: `Point(${i} 40)` },
})),
},
};
let wikidata = many;
const handler = handlerOf(
shipwrecksProxy({
fetchImpl: async (url) =>
Response.json(
String(url).includes('query.wikidata.org') ? wikidata : EMODNET,
),
now: () => clock,
}),
);
assert.equal((await request(handler)).body.records.length, 11);
wikidata = WIKIDATA;
clock = 25 * HOUR;
const refused = await request(handler);
assert.equal(refused.body.records.length, 11, 'one wreck is not ten');
assert.equal(refused.body.stale, true);
clock = 8 * 24 * HOUR;
const accepted = await request(handler);
assert.equal(
accepted.body.records.length,
2,
'taken once the old set expired',
);
});
+1
View File
@@ -1,5 +1,6 @@
/** Bottom-to-top order for near-plane-clamped contact sprite collections. */
export const SPRITE_LAYER_ORDER = Object.freeze([
'shipwrecks',
'cctv',
'firms',
'wildlife-sightings',
+607
View File
@@ -0,0 +1,607 @@
/**
* @module trailContinuity
* @description Where a tracked aircraft's trail lifts the pen.
*
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
* was drawn between them. Between two consecutive fixes that are not one path, a new
* segment starts and nothing is drawn between them: no dashes, no arcs.
* Nothing is dropped except the oldest fixes past the layer's point limit.
*
* Two points are not one path when time does not advance, when the gap is
* longer than 48 minutes (a coverage hole or a separate flight), when the
* implied great-circle speed is over 1,500 kt, or when either point has no
* reported altitude. A parked aircraft is the exception: a pair under three
* hours apart within 0.1 nm stays one path.
*
* Civil and military flight tracking share these rules.
*/
import * as Cesium from 'cesium';
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
const METRES_PER_NM = 1852;
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/**
* Whether two consecutive trail points belong to different paths. Time that
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
* parked aircraft: a pair under three hours apart within 0.1 nm.
* @param {number} deltaMs - Later point's time minus the earlier one's.
* @param {number} distanceM - Great-circle distance between the points.
* @returns {boolean}
*/
export function trailPairBreaks(deltaMs, distanceM) {
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
const distanceNm = distanceM / METRES_PER_NM;
if (
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
)
return false;
return (
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
);
}
/**
* Whether the head segment may join the last body point to the aircraft:
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
* @param {number} deltaMs - Current time minus the body point's time.
* @param {number} distanceM - Distance from the body point to the aircraft.
* @returns {boolean}
*/
export function trailHeadJoins(deltaMs, distanceM) {
return (
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
);
}
/**
* Great-circle distance between two trail positions, in metres.
* @param {Cesium.Cartesian3} a
* @param {Cesium.Cartesian3} b
* @returns {number}
*/
export function trailDistanceM(a, b) {
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
}
/**
* Whether the pen lifts between two consecutive fixes.
* @param {object|undefined} prev - Earlier fix, if any.
* @param {object} next - Later fix.
* @returns {boolean}
*/
export function trailFixBreaks(prev, next) {
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
// A recorded point is where the icon was drawn; the speed between it and a
// report it was eased onto is not a flight speed, so only the gap counts.
if (prev.drawn || next.drawn)
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
return trailPairBreaks(
next.timeMs - prev.timeMs,
trailDistanceM(prev.position, next.position),
);
}
/**
* Build backfill fixes from chronological waypoints. Every waypoint with a
* position is kept. A waypoint without one (no altitude and no known ground)
* is skipped, and the line still breaks there.
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
* line breaks after the last one because trailing waypoints were skipped.
*/
export function trailBackfillFixes(waypoints) {
const fixes = [];
let skipped = false;
for (const { position, timeMs, nullAltitude } of waypoints) {
if (!position) {
skipped = true;
continue;
}
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
skipped = false;
fixes.push(fix);
}
return { fixes, breakAfter: skipped };
}
/**
* Put backfill fixes ahead of the live trail. The join follows the same
* rule, so a distant history tail never connects to the first live fix.
* Past `maxPoints` the oldest recorded points go first, then the oldest
* reports.
* @param {{fixes: object[], breakAfter: boolean}} backfill
* @param {object[]} live - Live fixes; the first one's break is recomputed.
* @param {number} maxPoints - Point limit across all segments.
* @returns {object[]} The joined trail.
*/
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
if (!fixes.length) return live;
if (live.length)
live[0].breakBefore =
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
return keepTrailLimit(fixes.concat(live), maxPoints);
}
/**
* How many fixes the drawn body holds: every fix at or before the display
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
* the delayed display) are reached by the head segment instead, so the trail
* never runs past the aircraft, and once the display passes the newest
* report the body ends there.
* @param {object[]} fixes
* @param {number} displayMs - Time the aircraft is drawn at.
* @returns {number}
*/
export function trailBodyCount(fixes, displayMs) {
let count = fixes.length;
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
return count;
}
/**
* Leave out the newest recorded points that lie inside the aircraft's 3D
* model, so the body never runs through the fuselage; the head then runs
* from the newest point outside the model to the plane. Reports are kept.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
* when no model is the visual.
* @param {number|null} envelopeM - The model's radius (m).
* @returns {number}
*/
export function trailBodyCountOutsideModel(
fixes,
count,
modelCenter,
envelopeM,
) {
if (!modelCenter || !(envelopeM > 0)) return count;
while (
count > 0 &&
fixes[count - 1].drawn &&
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
envelopeM
)
count -= 1;
return count;
}
/**
* The drawn trail body, split at breaks: the first `count` fixes.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @returns {Cesium.Cartesian3[][]}
*/
export function trailBodySegments(fixes, count) {
const segments = [];
for (let i = 0; i < count; i++) {
const fix = fixes[i];
// A live report with no altitude keeps no position; it only breaks the line.
if (!fix.position) continue;
if (fix.breakBefore || !segments.length) segments.push([]);
segments[segments.length - 1].push(fix.position);
}
return segments;
}
/**
* Draw body segments. A trail without breaks support draws only the newest
* segment, never a line across a break.
* @param {{setSegments?: Function, setPositions: Function}} trail
* @param {Cesium.Cartesian3[][]} segments
*/
export function drawTrailSegments(trail, segments) {
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
else trail.setPositions(segments[segments.length - 1] ?? []);
}
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
const TRAIL_HEAD_MIN_M = 0.5;
/**
* The body fix the head segment starts from, or null when the head is not
* drawn: no body yet, the aircraft holding at that fix, a body point more
* than 150 km from the aircraft, or one too long ago or too far away to be
* one path.
* @param {object[]} fixes
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
* @param {number} nowMs - Current time.
* @param {number} count - Body fixes, from `trailBodyCount`.
* @returns {object|null}
*/
export function trailHeadStartFix(fixes, head, nowMs, count) {
if (count < 1 || !head) return null;
const last = fixes[count - 1];
if (!last.position) return null;
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
return null;
return trailHeadJoins(
nowMs - last.timeMs,
trailDistanceM(last.position, head),
)
? last
: null;
}
/**
* Epoch ms of a position-history fix (`{epochMs?, time}`).
* @param {object|undefined} fix
* @returns {number} NaN when unknown.
*/
export function historyFixEpochMs(fix) {
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
}
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
export const TRAIL_RECORD_STEP_MS = 1_000;
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
export const TRAIL_RECORD_STEP_M = 150;
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
export const TRAIL_THIN_TOLERANCE_M = 30;
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
const TRAIL_THIN_MAX_ABSORBED = 512;
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
export const TRAIL_SPUR_TURN_DEG = 120;
/** @constant {number} ...over a step shorter than this (m). */
export const TRAIL_SPUR_MAX_STEP_M = 500;
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
const TRAIL_RECORD_MIN_STEP_M = 1;
const _u = new Cesium.Cartesian3();
const _v = new Cesium.Cartesian3();
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
function turnCos(a, b, c) {
Cesium.Cartesian3.subtract(b, a, _u);
Cesium.Cartesian3.subtract(c, b, _v);
const lu = Cesium.Cartesian3.magnitude(_u);
const lv = Cesium.Cartesian3.magnitude(_v);
if (!(lu > 0 && lv > 0)) return NaN;
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
}
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
/** Whether entry `i` and its neighbours are one drawn piece of line. */
function joinedAt(fixes, i) {
const a = fixes[i - 1];
const b = fixes[i];
const c = fixes[i + 1];
return (
a?.position &&
b?.position &&
c?.position &&
!b.breakBefore &&
!c.breakBefore
);
}
/** Whether the line turns back on itself at entry `i` over a short step. */
function spurAt(fixes, i) {
if (!joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const b = fixes[i].position;
const c = fixes[i + 1].position;
if (
Math.min(
Cesium.Cartesian3.distance(a, b),
Cesium.Cartesian3.distance(b, c),
) > TRAIL_SPUR_MAX_STEP_M
)
return false;
return turnCos(a, b, c) < SPUR_COS;
}
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
function removeAt(fixes, i) {
fixes.splice(i, 1);
if (i < fixes.length)
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
}
/**
* Keep the point limit: drop the oldest recorded point first, and the oldest
* report only when no recorded point is left.
* @param {object[]} fixes - The trail, mutated.
* @param {number} maxPoints
* @returns {object[]} `fixes`.
*/
function keepTrailLimit(fixes, maxPoints) {
let excess = fixes.length - maxPoints;
if (excess <= 0) return fixes;
for (let i = 0; i < fixes.length && excess > 0;) {
if (fixes[i].drawn) {
removeAt(fixes, i);
excess -= 1;
} else i += 1;
}
if (excess > 0) fixes.splice(0, excess);
return fixes;
}
const _ab = new Cesium.Cartesian3();
const _ap = new Cesium.Cartesian3();
const _off = new Cesium.Cartesian3();
const _up = new Cesium.Cartesian3();
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
function nearSegment(p, a, b) {
Cesium.Cartesian3.subtract(b, a, _ab);
Cesium.Cartesian3.subtract(p, a, _ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
: 0;
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
Cesium.Cartesian3.subtract(_ap, _off, _off);
Cesium.Cartesian3.normalize(p, _up);
const vertical = Cesium.Cartesian3.dot(_off, _up);
const across = Math.sqrt(
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
);
return (
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
across <= TRAIL_THIN_TOLERANCE_M
);
}
/**
* Drop recorded point `i` when the line without it stays within the
* tolerance of every position it stood for, and the straight piece left is
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
* the next entry. Reports are never dropped.
* @returns {boolean} Whether it was dropped.
*/
function thinAt(fixes, i) {
const entry = fixes[i];
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const next = fixes[i + 1];
if (next.sealed) return false;
const b = next.position;
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
const stood = entry.absorbed ?? [];
const nextStood = next.absorbed ?? [];
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
return false;
if (!nearSegment(entry.position, a, b)) return false;
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
next.absorbed = stood.concat(
[{ position: entry.position, timeMs: entry.timeMs }],
nextStood,
);
removeAt(fixes, i);
return true;
}
/**
* Thin the recorded points either side of entry `i`, reaching past a
* neighbouring report: the point recorded just before a report is only
* judged once the line goes on beyond it.
*/
function thinAround(fixes, i) {
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
thinAt(fixes, i + 2);
if (thinAt(fixes, i - 1)) i -= 1;
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
}
/**
* Drop recorded points that make a spur next to entry `i`. Reports are never
* dropped; at a report the recorded neighbour is dropped instead.
* @returns {number} The index entry `i` ends up at.
*/
function removeSpursAround(fixes, i) {
for (let guard = 0; guard < 64; guard++) {
let changed = false;
for (const j of [i - 1, i, i + 1]) {
if (!spurAt(fixes, j)) continue;
let drop = -1;
if (fixes[j].drawn) drop = j;
else if (fixes[j + 1]?.drawn) drop = j + 1;
else if (fixes[j - 1]?.drawn) drop = j - 1;
if (drop < 0) continue;
removeAt(fixes, drop);
if (drop < i) i -= 1;
changed = true;
break;
}
if (!changed) break;
}
return i;
}
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
function insertionIndex(fixes, timeMs) {
let i = fixes.length;
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
return i;
}
/**
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
* points either side and keep the limit.
*/
function insertAt(fixes, i, entry, maxPoints) {
fixes.splice(i, 0, entry);
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
const after = fixes[i + 1];
if (after) {
after.breakBefore = trailFixBreaks(entry, after);
// Positions the next entry stood for that come before this one are now
// on this entry's side.
if (after.absorbed?.length) {
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
if (mine.length) {
entry.absorbed = mine;
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
}
}
}
i = removeSpursAround(fixes, i);
thinAround(fixes, i);
keepTrailLimit(fixes, maxPoints);
}
/** Index of the newest report before entry `i`, or -1. */
function reportBefore(fixes, i) {
let j = i - 1;
while (j >= 0 && fixes[j].drawn) j -= 1;
return j;
}
/** Index of the oldest report at or after `i`, or `fixes.length`. */
function reportFrom(fixes, i) {
let j = i;
while (j < fixes.length && fixes[j].drawn) j += 1;
return j;
}
/**
* Put a report into the trail in time order. Reports are the record: they
* are kept as anchors among the recorded points.
*
* - A report the display has already passed replaces the guess drawn after
* it: the recorded points timed after it go, and nothing is recorded until
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
* line reads arc, report, corrected path.
* - Recorded points never join two reports that break under
* `trailPairBreaks` (too long or too fast apart to be one flight): the
* points between them go and the line breaks.
* - A live report with no altitude keeps no position and only breaks the
* line.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned position.
* @param {number} timeMs - The report's epoch ms.
* @param {number} maxPoints - Point limit across all segments.
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
* `displayMs`: the time the aircraft is drawn at as the report arrives;
* `settleMs`: how long the icon takes to ease onto a correction.
* @returns {object[]} `fixes`.
*/
export function insertTrailFix(
fixes,
position,
timeMs,
maxPoints,
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
) {
const entry = {
position: nullAltitude ? null : position,
timeMs,
nullAltitude,
drawn: false,
breakBefore: false,
};
let i = insertionIndex(fixes, timeMs);
// The guess drawn after a report the display already passed.
fixes.splice(i, reportFrom(fixes, i) - i);
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
// Recorded points across a pair of reports that are not one flight.
const before = reportBefore(fixes, i);
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
fixes.splice(before + 1, i - before - 1);
i = before + 1;
}
insertAt(fixes, i, entry, maxPoints);
return fixes;
}
/**
* Record where the tracked aircraft was drawn at a display time. Between
* reports this recorded path is the trail, so the line follows every turn
* the icon made instead of a straight chord between two reports. Points are
* thinned by shape: one stays only where dropping it would move the line by
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
* while the aircraft holds.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned drawn position.
* @param {number} timeMs - Display time it was drawn for.
* @param {number} maxPoints - Point limit across all segments.
* @returns {boolean} Whether the trail changed.
*/
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
const i = insertionIndex(fixes, timeMs);
const prev = fixes[i - 1];
if (
prev?.position &&
Cesium.Cartesian3.distance(prev.position, position) <
TRAIL_RECORD_MIN_STEP_M
)
return false;
const entry = {
position,
timeMs,
nullAltitude: false,
drawn: true,
breakBefore: false,
};
insertAt(fixes, i, entry, maxPoints);
// Entries before the second report behind this point can never be thinned
// again, so they let go of the positions they stood for.
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
return true;
}
/** Release the absorbed positions of entry `i` and everything before it. */
function sealThrough(fixes, i) {
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
fixes[j].sealed = true;
delete fixes[j].absorbed;
}
}
/**
* Whether the icon's drawn position must not be recorded at a display time:
* a report on either side of it has no altitude (the icon is drawn at an
* invented height), the reports on either side are not one flight
* (`trailPairBreaks`), or the icon is still easing onto a report that
* arrived after the display had passed it.
* @param {object[]} fixes
* @param {number} displayMs
* @returns {boolean}
*/
export function trailRecordingBlocked(fixes, displayMs) {
const i = insertionIndex(fixes, displayMs);
const before = fixes[reportBefore(fixes, i)];
const after = fixes[reportFrom(fixes, i)];
if (before?.nullAltitude || after?.nullAltitude) return true;
if (before?.settleUntilMs > displayMs) return true;
return !!(before && after && trailFixBreaks(before, after));
}
+269 -58
View File
@@ -14,6 +14,18 @@
* the whole history renders at one readable alpha (dimmer where it passes
* behind/below geometry).
*
* A trail with breaks draws each segment separately and nothing between
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
* adjacent chunks share their boundary point, so the line has no seam. A
* chunk that can no longer change (every chunk except the newest one of the
* newest segment) is FROZEN: an entity with constant positions, built once as
* static geometry, so it costs nothing per frame. Only the newest chunk stays
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
* identified by its position objects, so a chunk survives every update that
* keeps those points: appends, a backfill prepended ahead of it, or the
* oldest points dropping off the front (which rebuilds only the first chunk).
*
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
* assigning a fresh positions array per update is cheap. `allowPicking` has
* no entity equivalent; the polyline entity is excluded from clicks by never
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
const TRAIL_OCCLUDED_ALPHA = 0.4;
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
const NO_POSITIONS = Object.freeze([]);
/** @constant {number} Points per chunk; adjacent chunks share one point. */
export const CHUNK_POINTS = 128;
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
* static geometry before it is dropped anyway. */
const STAND_IN_MAX_FRAMES = 600;
/**
* Create an always-visible polyline trail bound to a viewer.
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
* @param {object} options - Trail options.
* @param {string} options.color - CSS color string for the trail hue.
* @param {number} [options.width=2.5] - Polyline width in pixels.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry, setVisible temporarily
* hides it without discarding history, clear empties it, and destroy removes
* the entity permanently.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry with one line,
* setSegments with one line per segment, setVisible temporarily hides it
* without discarding history, clear empties it, and destroy removes the
* entities permanently.
*/
export function createTrail(viewer, { color, width = 2.5 }) {
const baseColor = Cesium.Color.fromCssColorString(color);
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
let current = [];
const material = baseColor.withAlpha(TRAIL_ALPHA);
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
let destroyed = false;
let visible = true;
/** @type {Cesium.Entity|null} */
let entity = null;
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
let frozen = [];
/** Newest chunk of the newest segment: the only part that changes. */
let tail = NO_POSITIONS;
/** What the dynamic polylines draw: the tail, then frozen chunks whose
* static geometry is not in the scene yet. */
let dynamicRuns = [];
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
const dynamicEntities = [];
let removeReadyListener = null;
const scratchSphere = new Cesium.BoundingSphere();
function ensureEntity() {
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
entity = viewer.entities.add({
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
function polyline(positions) {
return {
positions,
width,
material,
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial,
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
};
}
function addEntity(positions) {
return viewer.entities.add({
id: `gev-trail:${++_trailSeq}`,
show: visible,
polyline: {
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
positions: new Cesium.CallbackProperty(() => current, false),
width,
material: baseColor.withAlpha(TRAIL_ALPHA),
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
},
polyline: polyline(positions),
});
}
function removeEntity(entity) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
}
function syncDynamic() {
dynamicRuns = [];
if (tail.length >= 2) dynamicRuns.push(tail);
for (const chunk of frozen)
if (chunk.standIn) dynamicRuns.push(chunk.positions);
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
const index = dynamicEntities.length;
dynamicEntities.push(
addEntity(
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
new Cesium.CallbackProperty(
() => dynamicRuns[index] ?? NO_POSITIONS,
false,
),
),
);
}
}
/** Whether a frozen chunk's static geometry is drawn in the scene. */
function staticReady(chunk) {
const display = viewer.dataSourceDisplay;
if (typeof display?.getBoundingSphere !== 'function') return true;
if (!visible) return false;
chunk.frames += 1;
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
try {
return (
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
Cesium.BoundingSphereState.DONE
);
} catch {
return false;
}
}
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
function resolveStandIns() {
if (!usable()) return;
let changed = false;
for (const chunk of frozen) {
if (chunk.standIn && staticReady(chunk)) {
chunk.standIn = false;
changed = true;
}
}
if (changed) syncDynamic();
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
}
function stopListening() {
if (removeReadyListener) removeReadyListener();
removeReadyListener = null;
}
function listenForStandIns() {
if (removeReadyListener) return;
const event = viewer.scene?.postRender;
if (typeof event?.addEventListener !== 'function') {
resolveStandIns();
return;
}
removeReadyListener = event.addEventListener(resolveStandIns);
}
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
function dedupe(cartesians) {
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
return positions;
}
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
function sameRun(chunk, seg, start) {
const positions = chunk.positions;
if (start + positions.length > seg.length) return false;
for (let k = 0; k < positions.length; k++)
if (positions[k] !== seg[start + k]) return false;
return true;
}
/**
* Cut segments into chunks, reusing every frozen chunk whose points are
* still drawn in the same order. New chunks end where a reusable one begins,
* so dropping the oldest point rebuilds only the first chunk.
*/
function reconcile(segments) {
const byStart = new Map();
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
const kept = new Set();
const reusable = (seg, j) => {
const chunk = byStart.get(seg[j]);
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
};
const next = [];
let nextTail = NO_POSITIONS;
segments.forEach((seg, s) => {
const newest = s === segments.length - 1;
let i = 0;
while (i < seg.length - 1) {
const existing = reusable(seg, i);
if (existing) {
kept.add(existing);
next.push(existing);
i += existing.positions.length - 1;
continue;
}
if (newest && seg.length - i <= CHUNK_POINTS) {
nextTail = seg.slice(i);
break;
}
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
for (let j = i + 1; j < end; j++) {
if (reusable(seg, j)) {
end = j;
break;
}
}
const positions = seg.slice(i, end + 1);
next.push({
positions,
entity: addEntity(positions),
standIn: true,
frames: 0,
});
i = end;
}
});
for (const chunk of frozen)
if (!kept.has(chunk)) removeEntity(chunk.entity);
frozen = next;
tail = nextTail;
syncDynamic();
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
else stopListening();
}
function release() {
stopListening();
for (const chunk of frozen) removeEntity(chunk.entity);
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
}
return {
/**
* Replace the trail geometry with a chronological position list
* Replace the trail geometry with one chronological position list
* (oldest first). Fewer than 2 distinct positions clears the trail.
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
*/
setPositions(cartesians) {
if (destroyed || !viewer || viewer.isDestroyed()) return;
// Drop consecutive near-duplicates: zero-length segments add nothing.
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
current = positions.length >= 2 ? positions : [];
ensureEntity();
this.setSegments([cartesians]);
},
/**
* Replace the trail geometry with chronological segments (oldest first).
* Nothing is drawn between segments. A segment with fewer than 2 distinct
* positions draws nothing. Pass the same position objects on every
* update: a chunk whose points are unchanged is never rebuilt.
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
*/
setSegments(list) {
if (!usable()) return;
reconcile(
(Array.isArray(list) ? list : [])
.map(dedupe)
.filter((positions) => positions.length >= 2),
);
},
/** Temporarily hide/show the trail without discarding accumulated history. */
setVisible(nextVisible) {
visible = nextVisible !== false;
if (entity) entity.show = visible;
for (const entity of dynamicEntities) entity.show = visible;
for (const chunk of frozen) chunk.entity.show = visible;
},
/** Empty the trail without removing the entity (cheap re-arm). */
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
clear() {
current = [];
if (!usable()) return;
release();
},
/** Remove the trail entity permanently (layer disable/teardown). */
/** Remove every trail entity permanently (layer disable/teardown). */
destroy() {
destroyed = true;
current = [];
if (entity && viewer && !viewer.isDestroyed()) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
if (usable()) {
release();
for (const entity of dynamicEntities) removeEntity(entity);
}
entity = null;
destroyed = true;
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
dynamicEntities.length = 0;
stopListening();
},
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
_chunksForTest() {
return {
frozen: frozen.map((chunk) => ({
positions: chunk.positions,
standIn: chunk.standIn,
entity: chunk.entity,
})),
dynamic: dynamicRuns.slice(),
};
},
};
}
+251 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrail } from './trailRenderer.js';
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
test('trail visibility can change without discarding its accumulated geometry', () => {
const added = [];
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
trail.destroy();
assert.deepEqual(removed, [added[0]]);
});
/**
* A fake viewer. `ready` holds the frozen-chunk entities whose static
* geometry is in the scene; `frame()` runs one post-render pass.
*/
function fakeViewer({ scene = true } = {}) {
const added = [];
const removed = [];
const ready = new Set();
let listeners = [];
const viewer = {
added,
removed,
ready,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove(entity) {
removed.push(entity);
},
},
listenerCount: () => listeners.length,
frame() {
for (const listener of listeners.slice()) listener();
},
};
if (scene) {
viewer.scene = {
postRender: {
addEventListener(listener) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
},
};
viewer.dataSourceDisplay = {
getBoundingSphere: (entity) =>
ready.has(entity)
? Cesium.BoundingSphereState.DONE
: Cesium.BoundingSphereState.PENDING,
};
}
return viewer;
}
const valueOf = (positions) =>
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
/** Every line the scene can draw now: live entities with two or more points. */
function drawnRuns(viewer) {
return viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.map((entity) => valueOf(entity.polyline.positions))
.filter((positions) => positions.length >= 2);
}
/** Distinct position objects across all drawn lines. */
function drawnPoints(viewer) {
return new Set(drawnRuns(viewer).flat());
}
function line(count, offset = 0) {
return Array.from(
{ length: count },
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
);
}
test('a trail with breaks draws each segment and nothing between them', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const first = line(2, 0);
const lone = line(1, 50_000);
const second = line(3, 100_000);
trail.setSegments([first, lone, second]);
assert.deepEqual(
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
[first, second],
);
for (const entity of viewer.added) {
assert.match(String(entity.id), /^gev-trail:/);
assert.equal(entity.polyline.width, 2.5);
}
// The first segment is gone later: its geometry is released.
trail.setSegments([second]);
assert.deepEqual(drawnRuns(viewer), [second]);
trail.setVisible(false);
assert.ok(
viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.every((entity) => entity.show === false),
);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
});
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(300);
trail.setPositions(points);
const { frozen, dynamic } = trail._chunksForTest();
assert.deepEqual(
frozen.map((chunk) => chunk.positions.length),
[CHUNK_POINTS, CHUNK_POINTS],
);
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
assert.equal(dynamic.length, 1);
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
assert.equal(dynamic[0].at(-1), points.at(-1));
assert.equal(drawnPoints(viewer).size, points.length);
// A frozen chunk is constant geometry, not a per-frame callback.
for (const chunk of frozen)
assert.ok(Array.isArray(chunk.entity.polyline.positions));
});
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(CHUNK_POINTS + 20);
for (let n = 2; n <= points.length; n++) {
trail.setPositions(points.slice(0, n));
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
}
const { frozen } = trail._chunksForTest();
assert.equal(frozen.length, 1);
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
assert.ok(
trail._chunksForTest().dynamic.includes(frozen[0].positions),
'the dynamic copy stays until the static geometry is ready',
);
viewer.ready.add(frozen[0].entity);
viewer.frame();
const after = trail._chunksForTest();
assert.equal(after.frozen[0].standIn, false);
assert.ok(!after.dynamic.includes(frozen[0].positions));
assert.equal(after.dynamic.length, 1);
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
// The frozen entity is the same object: freezing built it once.
assert.equal(after.frozen[0].entity, frozen[0].entity);
});
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setVisible(false);
trail.setPositions(line(CHUNK_POINTS + 5));
const [chunk] = trail._chunksForTest().frozen;
viewer.ready.add(chunk.entity);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
trail.setVisible(true);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
});
test('dropping the oldest point rebuilds only the first chunk', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points);
const before = trail._chunksForTest().frozen;
trail.setPositions(points.slice(1));
const after = trail._chunksForTest().frozen;
assert.equal(after.length, before.length);
assert.notEqual(after[0].entity, before[0].entity);
assert.ok(viewer.removed.includes(before[0].entity));
for (let k = 1; k < before.length; k++)
assert.equal(after[k].entity, before[k].entity);
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
assert.equal(drawnPoints(viewer).size, 399);
});
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const live = line(300, 0);
trail.setPositions(live);
const before = trail._chunksForTest().frozen;
// History older than every live point, joined as one path.
const history = Array.from(
{ length: 200 },
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
);
trail.setPositions(history.concat(live));
const after = trail._chunksForTest().frozen;
for (const chunk of before)
assert.ok(after.some((c) => c.entity === chunk.entity));
for (let k = 0; k + 1 < after.length; k++)
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
assert.equal(drawnPoints(viewer).size, 500);
// A backfill that breaks from the live trail is its own segment.
trail.setSegments([history, live]);
assert.equal(drawnPoints(viewer).size, 500);
const runs = drawnRuns(viewer);
assert.ok(
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
'no line across the break',
);
});
test('clearing and re-tracking release every frozen chunk', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setPositions(line(600));
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
assert.ok(frozen.length >= 4);
assert.equal(viewer.listenerCount(), 1);
trail.clear();
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
assert.equal(viewer.listenerCount(), 0);
assert.equal(drawnRuns(viewer).length, 0);
// Another aircraft: nothing of the first one survives.
const other = line(200, 900_000);
trail.setPositions(other);
assert.equal(drawnPoints(viewer).size, 200);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
assert.equal(viewer.listenerCount(), 0);
});
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points.slice());
// A recorded point replaces the newest one; a spur point inside a frozen
// chunk and one at a chunk boundary go.
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
points.splice(200, 1);
points.splice(CHUNK_POINTS - 1, 1);
trail.setPositions(points.slice());
assert.deepEqual(drawnPoints(viewer), new Set(points));
// Every drawn line runs through consecutive trail points only.
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
for (const run of drawnRuns(viewer))
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
});
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
recordTrailPoint,
trailBodySegments,
trailPairBreaks,
trailDistanceM,
trailRecordingBlocked,
} from './trailContinuity.js';
// Reports decide where the line may run: recorded points never join two
// reports too far apart to be one flight, a report the display has already
// passed replaces the guess drawn after it, and old entries let go of the
// positions they stood for.
const SEC = 1000;
const LIMIT = 2_000;
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
);
const at = (e, n, u = 10) =>
Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(e, n, u),
new Cesium.Cartesian3(),
);
/** No two neighbours in one drawn segment are too far apart to be one flight. */
function assertNoBrokenJoin(fixes) {
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
for (const segment of trailBodySegments(fixes, fixes.length)) {
const inSegment = reports.filter((r) =>
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
);
for (let i = 1; i < inSegment.length; i++)
assert.equal(
trailPairBreaks(
inSegment[i].timeMs - inSegment[i - 1].timeMs,
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
),
false,
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
inSegment[i].timeMs / SEC
} s`,
);
}
}
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
for (let t = 1; t < 60; t++) {
// Nothing is recorded across the pair.
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
}
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
// Points recorded before the far report was known are dropped.
const late = [];
insertTrailFix(late, A, 0, LIMIT);
for (let t = 1; t < 60; t++)
recordTrailPoint(
late,
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
t * SEC,
LIMIT,
);
insertTrailFix(late, B, 60 * SEC, LIMIT);
assert.equal(late.filter((fix) => fix.drawn).length, 0);
assert.equal(trailBodySegments(late, late.length).length, 2);
assertNoBrokenJoin(late);
});
test('a coast, a hold and then a far report: nothing is drawn across', () => {
const A = at(0, 0, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
// The icon coasts east for 90 s at 230 m/s, then holds.
for (let t = 1; t <= 200; t++)
recordTrailPoint(
fixes,
at(230 * Math.min(t, 90), 0, 9_000),
t * SEC,
LIMIT,
);
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
// The next report is 150 km away two minutes later: over 1,500 kt.
const B = at(0, 150_000, 9_000);
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
assertNoBrokenJoin(fixes);
const segments = trailBodySegments(fixes, fixes.length);
const segmentOf = (p) =>
segments.findIndex((s) =>
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(segmentOf(A), segmentOf(B));
// No recorded point between the two reports remains.
assert.equal(
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
0,
);
});
for (const off of [200, 400, 600, 1_000]) {
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
const R = 2_000;
const w = 100 / R;
const pos = (t, r = R) =>
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
const fixes = [];
insertTrailFix(fixes, pos(0), 0, LIMIT);
for (let t = 1; t <= 60; t++)
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
const report = pos(50, R - off);
// It arrives with the display at 60 s; the ease takes 900 ms.
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
displayMs: 60 * SEC,
settleMs: 900,
});
// The guess drawn after the report is gone...
assert.deepEqual(
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
[],
);
// ...nothing is recorded while the icon eases onto the correction...
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
// ...and the line reads arc, report, corrected path.
for (let t = 61; t <= 70; t++)
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
const line = trailBodySegments(fixes, fixes.length).flat();
const r = line.findIndex(
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
);
assert.ok(r > 0);
for (const p of line.slice(0, r))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
'a point before the report is off the arc',
);
for (const p of line.slice(r + 1))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
31,
'a point after the report is off the corrected path',
);
});
}
test('entries behind the report before the display let go of the positions they stood for', () => {
const fixes = [];
for (let s = 0; s <= 2 * 3_600; s++) {
const p = at(230 * s, 0, 10_000);
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
}
let held = 0;
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
// Only the newest stretch, up to two reports back, keeps what it stood for.
assert.ok(held <= 30, `${held} positions still held`);
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
TRAIL_THIN_MAX_SPAN_M,
TRAIL_THIN_TOLERANCE_M,
} from './trailContinuity.js';
// Recorded points are thinned by shape: a point stays only where the line
// would move without it. Reports always stay.
const SEC = 1000;
const LIMIT = 2_000;
const LAT0 = 40;
const LON0 = -100;
const M_PER_DEG_LAT = 111_320;
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
/** Straight and level east at `speed` m/s, `tSec` after the start. */
const eastbound = (speed, tSec, heightM = 10_000) =>
Cesium.Cartesian3.fromDegrees(
LON0 + (speed * tSec) / mPerDegLon(LAT0),
LAT0,
heightM,
);
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
function turning(speed, tSec, heightM = 10_000) {
const omega = (3 * Math.PI) / 180;
const radius = speed / omega;
// Centre west of the start; heading north, turning left (towards west).
const angle = omega * tSec;
const east = -radius + radius * Math.cos(angle);
const north = radius * Math.sin(angle);
return Cesium.Cartesian3.fromDegrees(
LON0 + east / mPerDegLon(LAT0),
LAT0 + north / M_PER_DEG_LAT,
heightM,
);
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
const speed = 230;
const fixes = [];
for (const t of [0, 15, 30])
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
// Live order: a report arrives 30 s before the display reaches it, and the
// drawn icon is recorded once a second.
for (let t = 1; t <= 3_600; t++) {
const reportAt = t + 30;
if (reportAt % 15 === 0 && reportAt <= 3_600)
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
}
assert.ok(
drawnCount(fixes) < 60,
`${drawnCount(fixes)} recorded points kept`,
);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
});
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
const speed = 230;
const fixes = [];
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
for (let t = 1; t <= 3_600; t++)
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
const legKm = (speed * 3_600) / 1_000;
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
assert.ok(
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
`${drawnCount(fixes)} points over ${legKm} km`,
);
for (let i = 1; i < fixes.length; i++)
assert.ok(
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
TRAIL_THIN_MAX_SPAN_M + speed,
);
});
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
const speed = 230;
const fixes = [];
const recorded = [];
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
for (let t = 1; t <= 60; t++) {
const position = turning(speed, t);
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
for (const position of recorded) {
const off = distanceToPath(position, line);
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
}
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
});
test('a climb off the straight line keeps its vertices', () => {
const fixes = [];
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
// 30 s level, then a 60 s climb of 20 m/s, then level again.
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
const recorded = [];
for (let t = 1; t <= 150; t++) {
const position = eastbound(200, t, height(t));
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
for (const position of recorded)
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
});
test('at the limit, old recorded points go before any report from the backfill', () => {
const max = 300;
const backfill = [];
for (let i = 0; i < 100; i++)
backfill.push({
position: eastbound(200, i * 60 - 20_000),
timeMs: (i * 60 - 20_000) * SEC,
nullAltitude: false,
});
let fixes = [];
insertTrailFix(fixes, turning(150, 0), 0, max);
// A long series of turns keeps many recorded points.
for (let t = 1; t <= 2_000; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(drawnCount(fixes) > 150);
fixes = joinTrailBackfill(
{
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
breakAfter: false,
},
fixes,
max,
);
assert.ok(fixes.length <= max);
const reports = fixes.filter((fix) => !fix.drawn);
assert.equal(reports.length, 101, 'a report was dropped');
// More recording at the limit drops recorded points, never reports.
for (let t = 2_001; t <= 2_400; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(fixes.length <= max);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
// The recorded points that remain are the newest.
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
});
+83 -24
View File
@@ -41,33 +41,46 @@ const DETAIL =
/**
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
* toward -Y, in the 96-unit space (half-extent up to ~45).
*
* Two sets. `HEADING_BODIES` is what a vehicle with a known course wears: every
* outline has a front that cannot be mistaken for its back at fleet size — a
* nose, a bow or a chamfered cab — because at twenty pixels a bus drawn as a
* near-symmetric box gives no hint which way it is going, and a hairline
* windscreen is far below one pixel. The front is part of the OUTLINE, not
* interior ink, so it survives the tint, the halo (which strokes the outline)
* and the sensor profiles (which keep only the outline).
*
* `NEUTRAL_BODIES` is what a vehicle with NO known course wears. Its billboard
* sits at rotation 0, so a pointed glyph would claim it is heading north; these
* outlines are the same footprint, symmetric end to end, and claim nothing.
*/
const BODIES = {
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
// against, and the one most people will see most often.
const HEADING_BODIES = {
// ── Bus: short, broad, square-shouldered behind an arrow-like nose. The
// shape everything else reads against, and the one most people will see
// most often, so it carries the boldest front.
bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
<path d="M0,-41 L17,-25 L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28
L-17,-25 Z"
fill="white" ${STROKE}/>
<path d="M-11,-24 L11,-24" ${DETAIL}/>
<path d="M-11,-21 L11,-21" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`,
// ── Tram / light rail: longer and slimmer than a bus, with the two
// articulation joints that make a modern low-floor tram unmistakable.
// ── Tram / light rail: longer and slimmer than a bus, a short pointed cab
// and the two articulation joints of a modern low-floor tram.
tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
<path d="M0,-44 L10,-33 L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38
L-10,-33 Z"
fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/>
<path d="M-8,-34 L8,-34" ${DETAIL}/>`,
<path d="M-8,-29 L8,-29" ${DETAIL}/>`,
// ── Metro / subway: squared off at both ends, wider than a tram, a single
// cab line at the front. Reads as rolling stock, not a road vehicle.
// ── Metro / subway: squared off and wider than a tram, the cab end
// chamfered to a narrow flat face; the rear stays square.
subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
<path d="M-4,-40 L4,-40 L11.5,-30 L11.5,40 L-11.5,40 L-11.5,-30 Z"
fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
<path d="M-11.5,-26 L11.5,-26" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
@@ -90,8 +103,49 @@ const BODIES = {
fill="white" ${STROKE}/>
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
// ── Unknown: a plain rounded marker. Deliberately featureless, so an
// ── Unknown: a plain marker with a nose and nothing else, so an
// unclassified vehicle never pretends to be a mode it is not.
unknown: `
<path d="M0,-38 L10,-24 L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26
L-10,-24 Z"
fill="white" ${STROKE}/>`,
};
const NEUTRAL_BODIES = {
bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
fill="white" ${STROKE}/>
<path d="M-11,-20 L11,-20" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`,
tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/>`,
subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
rail: `
<path d="M-10.5,-40 C-10.5,-43 -8.5,-44.5 0,-44.5 C8.5,-44.5 10.5,-43 10.5,-40
L10.5,40 C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-10.5,-6 L10.5,-6" ${DETAIL}/>
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
ferry: `
<path d="M-12.5,-30 C-12.5,-36 -8,-38 0,-38 C8,-38 12.5,-36 12.5,-30
L12.5,30 C12.5,36 8,38 0,38 C-8,38 -12.5,36 -12.5,30 Z"
fill="white" ${STROKE}/>
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
unknown: `
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
@@ -99,7 +153,7 @@ const BODIES = {
};
/** The modes this module can draw. */
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(HEADING_BODIES));
const _iconCache = new Map();
@@ -155,8 +209,8 @@ export function transitIconCacheSize() {
}
/**
* Data URI for one mode's silhouette, lazily built and cached per kind, size
* and halo.
* Data URI for one mode's silhouette, lazily built and cached per kind, size,
* halo and whether the vehicle has a known course.
*
* With a halo, the body is drawn twice: first as a wide dark stroke, then as
* the shipped white fill with its hairline edge on top, so the ring sits
@@ -166,11 +220,14 @@ export function transitIconCacheSize() {
*
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
* @param {number} [px] Raster size.
* @param {{haloScreenPx?: number, screenPx?: number}} [options]
* @param {{haloScreenPx?: number, screenPx?: number, style?: string,
* heading?: boolean}} [options] `heading: false` draws the symmetric outline
* for a vehicle whose course is unknown (its billboard is not rotated, so a
* front would point north for no reason).
* @returns {string} `data:image/svg+xml;base64,...`
*/
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
const k = BODIES[kind] ? kind : 'unknown';
const k = HEADING_BODIES[kind] ? kind : 'unknown';
const selected = px > FLEET_RASTER_PX;
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
const profile = transitStyleProfile(
@@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
const key = `${k}:${selected ? 1 : 0}:${profile}`;
const heading = options.heading !== false;
const bodies = heading ? HEADING_BODIES : NEUTRAL_BODIES;
const key = `${k}:${selected ? 1 : 0}:${profile}:${heading ? 'h' : 'n'}`;
let uri = _iconCache.get(key);
if (!uri) {
const size = VIEW + 2 * frame.pad;
// Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
const silhouette = bodies[k].match(/<path d="([^"]+)"/)[1];
const body =
profile === 'mono'
? `<path d="${silhouette}" fill="white" />`
: BODIES[k];
: bodies[k];
const halo =
frame.units > 0
? profile === 'mono'
+128 -64
View File
@@ -62,39 +62,42 @@ test('the raster cache is bounded by kind, size and halo — never by call count
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
});
test('all 36 variants retain the specified final halo at both display sizes', async () => {
test('all 72 variants retain the specified final halo at both display sizes', async () => {
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) {
for (const selected of [false, true]) {
for (const style of ['normal', 'thermal', 'retro']) {
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
const units = Number(
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
);
assert.ok(
Math.abs(
(units * display) / 192 - presetSpriteOutlinePx(style, selected),
) < 0.001,
);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
assert.match(
svg,
new RegExp(
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
),
);
}
for (const style of ['normal', 'thermal', 'retro'])
for (const heading of [true, false]) {
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const svg = decode(
transitIcon(kind, selected ? 96 : 48, { style, heading }),
);
const units = Number(
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
);
assert.ok(
Math.abs(
(units * display) / 192 - presetSpriteOutlinePx(style, selected),
) < 0.001,
);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
assert.match(
svg,
new RegExp(
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
),
);
}
}
}
for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
assert.equal(transitIconCacheSize(), 36);
assert.equal(transitIconCacheSize(), 72);
});
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -133,47 +136,53 @@ test('every mono raster preserves the normal silhouette mask and aspect at CRT d
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
};
for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) {
const normal = await rasterBody(
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
);
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
for (const heading of [true, false])
for (const kind of TRANSIT_ICON_KINDS) {
const normal = await rasterBody(
decode(
transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }),
),
);
assert.ok(
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
`${kind}/${style}: aspect`,
);
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
assert.equal(display, crt);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
const crtFrame = haloFrame(
presetSpriteOutlinePx('retro', selected),
crt,
);
assert.equal(
(display * frame.ratio) / frame.ratio,
(crt * crtFrame.ratio) / crtFrame.ratio,
'unpadded silhouette matches CRT; only external halo padding grows',
);
assert.equal(
(svg.match(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
const svg = decode(
transitIcon(kind, selected ? 96 : 48, { style, heading }),
);
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
);
assert.ok(
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
`${kind}/${style}: aspect`,
);
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const crt =
(selected ? 30 : 20) * presetSpriteScale('retro', selected);
assert.equal(display, crt);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
const crtFrame = haloFrame(
presetSpriteOutlinePx('retro', selected),
crt,
);
assert.equal(
(display * frame.ratio) / frame.ratio,
(crt * crtFrame.ratio) / crtFrame.ratio,
'unpadded silhouette matches CRT; only external halo padding grows',
);
assert.equal(
(svg.match(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
}
}
}
});
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
@@ -238,3 +247,58 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
assert.ok(black.pass, JSON.stringify({ selected, black }));
}
});
test('a vehicle with a course shows its front; one without claims no direction', async () => {
const sharp = (await import('sharp')).default;
const outline = (svg) => svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
// Fleet-size raster of the outline alone: row widths from front to back.
const rows = async (d) => {
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.resize(20, 20)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const out = [];
for (let y = 0; y < info.height; y++) {
let sum = 0;
for (let x = 0; x < info.width; x++)
sum += data[(y * info.width + x) * 4 + 3] / 255;
out.push(sum);
}
return out;
};
// How much the outline differs from itself turned end for end, in pixels.
const asymmetry = (r) =>
r.reduce((a, v, i) => a + Math.abs(v - r[r.length - 1 - i]), 0);
for (const kind of TRANSIT_ICON_KINDS) {
const pointed = await rows(outline(decode(transitIcon(kind))));
const neutral = await rows(
outline(decode(transitIcon(kind, 48, { heading: false }))),
);
assert.ok(
asymmetry(pointed) >= 2.5,
`${kind}: front reads at 20 px (${asymmetry(pointed).toFixed(2)})`,
);
assert.ok(
asymmetry(neutral) < 0.5,
`${kind}: no implied heading (${asymmetry(neutral).toFixed(2)})`,
);
// The front is the narrow end: the two leading rows of ink carry less
// than the two trailing rows.
const inked = pointed.flatMap((v, i) => (v > 0.05 ? [i] : []));
const first = inked[0];
const last = inked[inked.length - 1];
const lead = pointed[first] + pointed[first + 1];
const trail = pointed[last] + pointed[last - 1];
assert.ok(lead < trail, `${kind}: nose toward -Y`);
}
assert.notEqual(
transitIcon('bus'),
transitIcon('bus', 48, { heading: false }),
);
assert.equal(transitIcon('bus', 48, { heading: true }), transitIcon('bus'));
});
+1 -1
View File
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// Only MMSI-keyed cards can resolve back to one actionable vessel.
// Only keyed cards can resolve back to one actionable vessel.
interactive: card?.actionable === true,
};
}
+6 -1
View File
@@ -251,7 +251,12 @@ test('mint locks canonical action schemas and server instructions with no perman
test('missing key and malformed model fail without an upstream request', async (t) => {
const missing = await fixture(t, { resolveApiKey: () => '' });
assert.equal((await missing.send()).status, 503);
const missingResponse = await missing.send();
assert.equal(missingResponse.status, 503);
assert.deepEqual(await missingResponse.json(), {
error: 'Add a Gemini API key in Provider Settings',
code: 'API_KEY_MISSING',
});
assert.equal(missing.calls.length, 0);
for (const model of [
'',
+1
View File
@@ -143,6 +143,7 @@ for (const [locale, summary] of [
['ja', '東京上空のLive Flights'],
['zh-CN', '北京航班stale数据'],
['ru', 'Остин Live-Flights частично'],
['he', 'תל אביב stale טיסות'],
]) {
test(`English flush against other text is an echo: ${summary}`, () => {
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
+1 -1
View File
@@ -110,7 +110,7 @@ const SUMMARY_LANGUAGES = Object.freeze({
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh|he)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
+4 -1
View File
@@ -150,7 +150,10 @@ test('the installed keyless HUD route stays successful after the voice quota is
const firstToken = await invokeRoute(token);
const secondToken = await invokeRoute(token);
assert.equal(firstToken.statusCode, 503);
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
assert.deepEqual(firstToken.body, {
error: 'OPENAI_API_KEY is not set',
code: 'API_KEY_MISSING',
});
assert.equal(secondToken.statusCode, 429);
for (let attempt = 0; attempt < 2; attempt += 1) {
+6
View File
@@ -41,6 +41,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale('de'), 'de');
assert.equal(normalizeLocale('de-AT'), 'de');
assert.equal(normalizeLocale('de_CH'), 'de');
// Hebrew keeps Western digits like its catalog; regional tags fold to he,
// and the legacy tag iw is still what some browsers and OSes label Hebrew.
assert.equal(normalizeLocale('he'), 'he');
assert.equal(normalizeLocale('he-IL'), 'he');
assert.equal(normalizeLocale('iw'), 'he');
assert.equal(normalizeLocale('iw-IL'), 'he');
assert.equal(normalizeLocale('fr'), 'fr');
assert.equal(normalizeLocale('fr-CA'), 'fr');
assert.equal(normalizeLocale('FR_be'), 'fr');
+2
View File
@@ -22,6 +22,7 @@ import ar from './locales/ar.json' with { type: 'json' };
import de from './locales/de.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import fr from './locales/fr.json' with { type: 'json' };
import he from './locales/he.json' with { type: 'json' };
import it from './locales/it.json' with { type: 'json' };
import ja from './locales/ja.json' with { type: 'json' };
import pt from './locales/pt.json' with { type: 'json' };
@@ -35,6 +36,7 @@ const CATALOGS = Object.freeze({
de,
es,
fr,
he,
it,
ja,
pt,
+6 -2
View File
@@ -14,6 +14,7 @@ export const LOCALES = Object.freeze([
'de',
'es',
'fr',
'he',
'it',
'ja',
'pt',
@@ -30,6 +31,7 @@ export const LOCALE_NAMES = Object.freeze({
de: 'Deutsch',
es: 'Español',
fr: 'Français',
he: 'עברית',
it: 'Italiano',
ja: '日本語',
pt: 'Português (Brasil)',
@@ -40,7 +42,7 @@ export const LOCALE_NAMES = Object.freeze({
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze(['ar']);
const RTL_LOCALES = Object.freeze(['ar', 'he']);
/** Whether a shipped locale reads right to left. */
export function isRtlLocale(locale) {
@@ -62,7 +64,8 @@ export function intlLocale(locale) {
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'ar-EG' and 'ar-SA' → 'ar',
* 'ar-EG' and 'ar-SA' → 'ar', 'he-IL' and 'he' → 'he', and the
* legacy tag 'iw'/'iw-IL' → 'he',
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
@@ -78,6 +81,7 @@ export function normalizeLocale(candidate) {
? 'zh-Hant'
: 'zh-CN';
}
if (primary === 'iw') return 'he';
return LOCALES.includes(primary) ? primary : null;
}
+174 -6
View File
@@ -1,4 +1,103 @@
{
"News feed unavailable": "موجز الأخبار غير متاح",
"Developing: {name}": "تطورات: {name}",
"{count} events today": {
"zero": "{count} حدث اليوم",
"one": "حدث واحد اليوم ({count})",
"two": "حدثان اليوم ({count})",
"few": "{count} أحداث اليوم",
"many": "{count} حدثًا اليوم",
"other": "{count} حدث اليوم"
},
"{count} reports today": {
"zero": "{count} تقرير اليوم",
"one": "تقرير واحد اليوم ({count})",
"two": "تقريران اليوم ({count})",
"few": "{count} تقارير اليوم",
"many": "{count} تقريرًا اليوم",
"other": "{count} تقرير اليوم"
},
"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}",
"Official": "رسمي",
"Reported by both sides": "أورده الطرفان",
"Independently reported": "أُبلغ عنه بشكل مستقل",
"Single source": "مصدر واحد",
"International": "دولية",
"Independent": "مستقلة",
"Unattributed": "غير منسوب",
"Outside party": "طرف خارجي",
"Ukraine–Russia": "أوكرانيا–روسيا",
"Israel–Gaza–Lebanon": "إسرائيل–غزة–لبنان",
"Iran–Hormuz": "إيران–هرمز",
"Yemen–Red Sea": "اليمن–البحر الأحمر",
"Sudan": "السودان",
"Sahel": "الساحل",
"Elsewhere": "أماكن أخرى",
"Strike": "ضربة",
"Alert": "إنذار",
"Air defence": "دفاع جوي",
"Ground fighting": "قتال بري",
"Casualties": "إصابات",
"Humanitarian": "إنساني",
"Disaster": "كارثة",
"Protest": "احتجاج",
"Political": "سياسي",
"No physical event": "لا حدث ميداني",
"Minor incident": "حادث بسيط",
"Significant": "كبير",
"Major": "جسيم",
"age|now": "الآن",
"{minutes} min": "{minutes} د",
"{hours} h": "{hours} س",
"{count} sources": {
"zero": "{count} مصدر",
"one": "مصدر واحد ({count})",
"two": "مصدران ({count})",
"few": "{count} مصادر",
"many": "{count} مصدرًا",
"other": "{count} مصدر"
},
"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": "عنوان إنجليزي من النموذج",
"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} ساعة",
"Time window": "الفترة الزمنية",
"Conflicts": "النزاعات",
"Local": "محلي",
"News sections": "أقسام الأخبار",
"Zoom in to see local news": "قرّب الخريطة لرؤية الأخبار المحلية",
"Hotspots": "بؤر ساخنة",
"Severity": "الخطورة",
"Category": "الفئة",
"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": "تحميل أخبار أقدم",
"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": "اخرج من القمرة للانتقال إلى خبر",
"News items in the feed": "عدد الأخبار في الموجز",
"NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا",
"Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…",
"Configuring viewer...": "جارٍ إعداد العارض…",
@@ -437,11 +536,12 @@
"PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع",
"POSITION UNAVAILABLE": "الموضع غير متاح",
"REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة",
"CACHED · REFRESH FAILED": "مخزنة · فشل التحديث",
"REGION UNAVAILABLE": "المنطقة غير متاحة",
"NO RECENT LOCATION MATCHES": "لا توجد تقارير حديثة مطابقة للموقع",
"NO NEWS IN THE LAST {hours} H": "لا أخبار خلال آخر {hours} س",
"SOURCE": "المصدر",
"{domain} · {age}": "{domain} · {age}",
"{source} · LOCATION QUERY": "{source} · استعلام الموقع",
"{source} · CACHED {age}": "{source} · مخزنة {age}",
"TIME UNKNOWN": "الوقت غير معروف",
"{count}M AGO": "قبل {count} د",
"{count}H AGO": "قبل {count} س",
@@ -615,6 +715,7 @@
"LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
"SIGNAL LOST · {time}": "انقطعت الإشارة · {time}",
"FALLBACK": "بديل",
"PARTIAL": "جزئي",
"UNCERTAIN": "غير مؤكد",
@@ -879,6 +980,7 @@
"KEY REQUIRED": "المفتاح مطلوب",
"STALE · cached {age}": "متقادم · مخزّن {age}",
"LIVE · updated {age}": "مباشر · حُدّث {age}",
"24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC",
"<1m ago": "قبل أقل من دقيقة",
"FIRE · {frp} MW": "حريق · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
@@ -2285,7 +2387,7 @@
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
"{source} · LOCATION QUERY · RECENT": "{source} · استعلام الموقع · حديث",
"{source} · LAST {hours} H": "{source} · آخر {hours} س",
"Rendered globe frames per second · toggle with `": "إطارات الكرة الأرضية المعروضة في الثانية · بدّل بالمفتاح `",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "تعذّر تحديث ارتفاعات الأرض. أعد استيراد الملف بعد تحميل الخريطة.",
"{count} map objects": {
@@ -2575,8 +2677,6 @@
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
"Shared satellite is unavailable": "القمر الصناعي المشترك غير متاح",
"{id} isn't reporting now — showing where it was last seen": "{id}: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"{id} isn't reporting now — showing where it was at {time}": "{id}: لا بيانات واردة الآن — يُعرض الموضع عند {time}",
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
"subject": "الهدف",
@@ -3512,6 +3612,62 @@
"Prohibited": "محظور",
"Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
"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}": "{region}، {country}",
"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} مفتاحه",
"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",
"Worldwide": "العالم",
"Shipwrecks": "حطام السفن",
"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}",
"What happened: {value}": "ما حدث: {value}",
"Depth: {m} m ({quality})": "العمق: {m} م ({quality})",
"Depth: {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": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط",
"EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط",
"Shipwrecks unavailable": "بيانات حطام السفن غير متاحة",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)",
"{count} wrecks": {
"zero": "لا حطام ({count})",
"one": "حطام واحد ({count})",
"two": "حطامان ({count})",
"few": "{count} حطامات",
"many": "{count} حطامًا",
"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، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.",
"All years": "كل السنوات",
"Period": "الفترة",
"From": "من",
"To": "إلى",
"First year of the shipwreck period": "السنة الأولى من فترة الغرق",
"Last year of the shipwreck period": "السنة الأخيرة من فترة الغرق",
"SIGHTING": "مشاهدة",
"Show {name} sighting": "إظهار مشاهدة {name}",
"seen {age} ago": "شوهد قبل {age}",
@@ -3631,5 +3787,17 @@
"Play speed": "سرعة التشغيل",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
"1 min/s": "دقيقة/ث",
"Photo © {photographer}": "صورة © {photographer}"
"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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
}
+158 -6
View File
@@ -1,4 +1,91 @@
{
"News feed unavailable": "Nachrichtenfeed nicht verfügbar",
"Developing: {name}": "In Entwicklung: {name}",
"{count} events today": {
"one": "{count} Ereignis heute",
"other": "{count} Ereignisse heute"
},
"{count} reports today": {
"one": "{count} Meldung heute",
"other": "{count} Meldungen heute"
},
"Disputed: {name}": "Umstritten: {name}",
"Video": "Video",
"Photos": "Fotos",
"Claimed by {who}": "Behauptet von {who}",
"Claimed": "Behauptung",
"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}",
"Official": "Offiziell",
"Reported by both sides": "Von beiden Seiten gemeldet",
"Independently reported": "Unabhängig gemeldet",
"Single source": "Einzelne Quelle",
"International": "International",
"Independent": "Unabhängig",
"Unattributed": "Ohne Zuordnung",
"Outside party": "Außenstehende Partei",
"Ukraine–Russia": "Ukraine–Russland",
"Israel–Gaza–Lebanon": "Israel–Gaza–Libanon",
"Iran–Hormuz": "Iran–Hormus",
"Yemen–Red Sea": "Jemen–Rotes Meer",
"Sudan": "Sudan",
"Sahel": "Sahel",
"Elsewhere": "Anderswo",
"Strike": "Angriff",
"Alert": "Warnung",
"Air defence": "Luftabwehr",
"Ground fighting": "Bodenkämpfe",
"Casualties": "Opfer",
"Humanitarian": "Humanitär",
"Disaster": "Katastrophe",
"Protest": "Protest",
"Political": "Politik",
"No physical event": "Kein physisches Ereignis",
"Minor incident": "Kleiner Vorfall",
"Significant": "Erheblich",
"Major": "Schwer",
"age|now": "jetzt",
"{minutes} min": "{minutes} Min.",
"{hours} h": "{hours} Std.",
"{count} sources": {
"one": "{count} Quelle",
"other": "{count} Quellen"
},
"News feed not configured on this server": "Nachrichtenfeed auf diesem Server nicht eingerichtet",
"Malformed news hotspots response": "Fehlerhafte Antwort zu Nachrichten-Brennpunkten",
"Malformed news feed response": "Fehlerhafte Antwort des Nachrichtenfeeds",
"{place} (placed by model)": "{place} (vom Modell verortet)",
"English headline by the model": "Englische Schlagzeile vom Modell",
"Not placed on the map": "Nicht auf der Karte verortet",
"Close": "Schließen",
"Any severity": "Jeder Schweregrad",
"{severity} and above": "{severity} und höher",
"{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.",
"Time window": "Zeitraum",
"Conflicts": "Konflikte",
"Local": "Lokal",
"News sections": "Nachrichtenbereiche",
"Zoom in to see local news": "Hineinzoomen, um lokale Nachrichten zu sehen",
"Hotspots": "Brennpunkte",
"Severity": "Schweregrad",
"Category": "Kategorie",
"Nothing reported near here in the last {hours} h": "In den letzten {hours} h nichts in der Nähe gemeldet",
"Latest news": "Neueste Nachrichten",
"No news matches these filters in the last {hours} h": "Keine Nachrichten der letzten {hours} h passen zu diesen Filtern",
"Load older news": "Ältere Nachrichten laden",
"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.",
"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...",
"Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...",
"Configuring viewer...": "Viewer wird konfiguriert...",
@@ -72,6 +159,7 @@
"LOADING": "LÄDT",
"DEGRADED": "EINGESCHRÄNKT",
"STALE": "VERALTET",
"SIGNAL LOST · {time}": "SIGNAL VERLOREN · {time}",
"PARTIAL": "TEILWEISE",
"FALLBACK": "FALLBACK",
"SUMMARY": "ÜBERSICHT",
@@ -164,6 +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",
"loading mapped installation context": "Kontext der kartierten Anlage wird geladen",
"MAPPED INSTALLATION": "KARTIERTE ANLAGE",
"LAUNCH SITE · {site}": "STARTPLATZ · {site}",
@@ -431,13 +520,14 @@
"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.": "Briefing-Seiten automatisch alle 9 Sekunden wechseln (Signale → News → Lokal). Pausiert, solange sich der Mauszeiger über dem Bereich befindet oder dieser den Fokus hat. Live-Signaldaten werden in beiden Fällen laufend aktualisiert.",
"ACQUIRING REGIONAL NEWS": "REGIONALE NEWS WERDEN ERFASST",
"REGIONAL NEWS UNAVAILABLE": "REGIONALE NEWS NICHT VERFÜGBAR",
"CACHED · REFRESH FAILED": "CACHE · AKTUALISIERUNG FEHLGESCHLAGEN",
"RESOLVING REGION": "REGION WIRD ERMITTELT",
"REGION UNAVAILABLE": "REGION NICHT VERFÜGBAR",
"NO RECENT LOCATION MATCHES": "KEINE AKTUELLEN ORTSTREFFER",
"NO NEWS IN THE LAST {hours} H": "KEINE NEWS IN DEN LETZTEN {hours} H",
"{domain} · {age}": "{domain} · {age}",
"CLOUD {pct}%": "WOLKEN {pct} %",
"CLOUD UNKNOWN": "WOLKEN UNBEKANNT",
"{source} · LOCATION QUERY": "{source} · ORTSABFRAGE",
"{source} · CACHED {age}": "{source} · CACHE {age}",
"REGIONAL NEWS": "REGIONALE NEWS",
"CONTEXT STANDBY": "KONTEXT STANDBY",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "GLOBALEN KONTEXT FÜR NÄHE-PINGS AKTIVIEREN",
@@ -2181,7 +2271,7 @@
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
"{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL",
"{source} · LAST {hours} H": "{source} · LETZTE {hours} H",
"Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.",
"{count} map objects": {
@@ -2439,8 +2529,6 @@
"Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar",
"Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar",
"Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar",
"{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt",
"{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt",
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
"subject": "Objekt",
@@ -3204,6 +3292,58 @@
"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.",
"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",
"{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} 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",
"Worldwide": "Weltweit",
"Shipwrecks": "Schiffswracks",
"Before 1900": "Vor 1900",
"After 1945": "Nach 1945",
"Date unknown": "Datum unbekannt",
"Position: centre of the protected area": "Position: Mitte des Schutzgebiets",
"Position approximate (within about {km} km)": "Position ungefähr (auf etwa {km} km genau)",
"Position surveyed (within about {m} m)": "Position vermessen (auf etwa {m} m genau)",
"Vessel: {value}": "Schiff: {value}",
"What happened: {value}": "Was geschah: {value}",
"Depth: {m} m ({quality})": "Tiefe: {m} m ({quality})",
"Depth: {m} m": "Tiefe: {m} m",
"Notes: {value}": "Hinweise: {value}",
"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 ↗",
"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",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (europäische Gewässer) · Wikidata (weltweit)",
"{count} wrecks": {
"one": "{count} Wrack",
"other": "{count} Wracks"
},
"1900–1945": "1900–1945",
"Shipwreck": "Schiffbruch",
"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.",
"All years": "Alle Jahre",
"Period": "Zeitraum",
"From": "Von",
"To": "Bis",
"First year of the shipwreck period": "Erstes Jahr des Schiffbruch-Zeitraums",
"Last year of the shipwreck period": "Letztes Jahr des Schiffbruch-Zeitraums",
"SIGHTING": "BEOBACHTUNG",
"Show {name} sighting": "Beobachtung von {name} anzeigen",
"seen {age} ago": "vor {age} gesichtet",
@@ -3323,5 +3463,17 @@
"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",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"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",
"The shared military flight isn't reporting now — showing where it was at {time}": "Der geteilte Militärflug sendet gerade nicht – Position um {time} wird gezeigt",
"The shared vessel isn't reporting now — showing where it was last seen": "Das geteilte Schiff sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared vessel isn't reporting now — showing where it was at {time}": "Das geteilte Schiff sendet gerade nicht – Position um {time} wird gezeigt",
"The shared satellite isn't reporting now — showing where it was last seen": "Der geteilte Satellit sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared satellite isn't reporting now — showing where it was at {time}": "Der geteilte Satellit sendet gerade nicht – Position um {time} wird gezeigt",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
}
+162 -6
View File
@@ -1,4 +1,94 @@
{
"News feed unavailable": "Noticias no disponibles",
"Developing: {name}": "En desarrollo: {name}",
"{count} events today": {
"one": "{count} suceso hoy",
"many": "{count} sucesos hoy",
"other": "{count} sucesos hoy"
},
"{count} reports today": {
"one": "{count} noticia hoy",
"many": "{count} noticias hoy",
"other": "{count} noticias hoy"
},
"Disputed: {name}": "En disputa: {name}",
"Video": "Vídeo",
"Photos": "Fotos",
"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}",
"Official": "Oficial",
"Reported by both sides": "Informado por ambos bandos",
"Independently reported": "Informado de forma independiente",
"Single source": "Fuente única",
"International": "Internacional",
"Independent": "Independiente",
"Unattributed": "Sin atribuir",
"Outside party": "Parte externa",
"Ukraine–Russia": "Ucrania–Rusia",
"Israel–Gaza–Lebanon": "Israel–Gaza–Líbano",
"Iran–Hormuz": "Irán–Ormuz",
"Yemen–Red Sea": "Yemen–Mar Rojo",
"Sudan": "Sudán",
"Sahel": "Sahel",
"Elsewhere": "En otros lugares",
"Strike": "Ataque",
"Alert": "Alerta",
"Air defence": "Defensa aérea",
"Ground fighting": "Combates terrestres",
"Casualties": "Víctimas",
"Humanitarian": "Humanitario",
"Disaster": "Catástrofe",
"Protest": "Protesta",
"Political": "Político",
"No physical event": "Sin hecho físico",
"Minor incident": "Incidente menor",
"Significant": "Significativo",
"Major": "Grave",
"age|now": "ahora",
"{minutes} min": "{minutes} min",
"{hours} h": "{hours} h",
"{count} sources": {
"one": "{count} fuente",
"many": "{count} 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",
"Not placed on the map": "Sin ubicar en el mapa",
"Close": "Cerrar",
"Any severity": "Cualquier gravedad",
"{severity} and above": "{severity} o más",
"{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",
"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",
"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",
"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",
"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",
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Borde continuo: informado por ambos bandos o de forma independiente. Borde discontinuo: una afirmación. Borde fino: una fuente, o la prensa de un bando.",
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Más grande: más fuentes · más tenue: más antiguo. Tarjeta con imagen: un suceso grave o en rápido crecimiento con dos o más fuentes.",
"Side labels describe the source, not whether a report is true.": "Las etiquetas de bando describen la fuente, no si la noticia es cierta.",
"Exit cockpit to fly to a news report": "Sal de la cabina para volar a una noticia",
"News items in the feed": "Noticias en la lista",
"NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR",
"Initializing photorealistic world...": "Inicializando el mundo fotorrealista...",
"Configuring viewer...": "Configurando el visor...",
@@ -320,11 +410,12 @@
"PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIÓN",
"POSITION UNAVAILABLE": "POSICIÓN NO DISPONIBLE",
"REGIONAL NEWS UNAVAILABLE": "NOTICIAS DE ZONA NO DISPONIBLES",
"CACHED · REFRESH FAILED": "EN CACHÉ · ERROR AL ACTUALIZAR",
"REGION UNAVAILABLE": "REGIÓN NO DISPONIBLE",
"NO RECENT LOCATION MATCHES": "SIN COINCIDENCIAS RECIENTES DE UBICACIÓN",
"NO NEWS IN THE LAST {hours} H": "SIN NOTICIAS EN LAS ÚLTIMAS {hours} H",
"SOURCE": "FUENTE",
"{domain} · {age}": "{domain} · {age}",
"{source} · LOCATION QUERY": "{source} · CONSULTA POR UBICACIÓN",
"{source} · CACHED {age}": "{source} · EN CACHÉ {age}",
"TIME UNKNOWN": "HORA DESCONOCIDA",
"{count}M AGO": "HACE {count} MIN",
"{count}H AGO": "HACE {count} H",
@@ -336,8 +427,6 @@
"Link copied!": "¡Enlace copiado!",
"Link shared": "Enlace compartido",
"This share link can't be opened": "Este enlace compartido no se puede abrir",
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
"Shared vessel is unavailable": "El buque compartido no está disponible",
"Copy failed": "No se pudo copiar",
"Location not found": "Ubicación no encontrada",
@@ -500,6 +589,7 @@
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
"FALLBACK": "RESPALDO",
"PARTIAL": "PARCIAL",
"UNCERTAIN": "INCIERTO",
@@ -737,6 +827,7 @@
"KEY REQUIRED": "CLAVE REQUERIDA",
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
"LIVE · updated {age}": "EN VIVO · actualizado {age}",
"24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC",
"<1m ago": "hace <1 min",
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
@@ -2006,7 +2097,7 @@
"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",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
"ft": "ft",
"kts": "kn",
"PAUSE": "PAUSA",
@@ -3281,6 +3372,59 @@
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
"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 noticias. Cada noticia enlaza a su medio; los lugares y categorías se leen del texto y los datos automáticamente y pueden ser erróneos. Las etiquetas de bando, donde aparecen, describen la fuente, no si la noticia es cierta.",
"Economy": "Economía",
"News from {area}": "Noticias: {area}",
"Within {km} km of the view centre": "A menos de {km} km del centro de la vista",
"{region}, {country}": "{region}, {country}",
"via {source}": "vía {source}",
"Local in the News layer: the day’s news from the outlets of the country in view": "Local en la capa de noticias: las noticias del día de los medios del país a la vista",
"{provider} refused its key": "{provider} rechazó su clave",
"Check {envVars} in Provider Settings": "Revisa {envVars} en Ajustes de proveedores",
"{provider} key required": "Se necesita una clave de {provider}",
"{provider} limit reached for today": "{provider} alcanzó su límite de hoy",
"News resumes tomorrow, or with a paid plan": "Las noticias vuelven mañana, o con un plan de pago",
"News key required": "Se necesita una clave de noticias",
"Precise conflict points in the News layer, beside GDELT’s articles": "Puntos de conflicto precisos en la capa de noticias, junto a los artículos de GDELT",
"Worldwide": "Mundial",
"Shipwrecks": "Naufragios",
"Before 1900": "Antes de 1900",
"After 1945": "Después de 1945",
"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)",
"Vessel: {value}": "Embarcación: {value}",
"What happened: {value}": "Qué ocurrió: {value}",
"Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})",
"Depth: {m} m": "Profundidad: {m} m",
"Notes: {value}": "Notas: {value}",
"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 ↗",
"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",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (aguas europeas) · Wikidata (todo el mundo)",
"{count} wrecks": {
"one": "{count} naufragio",
"many": "{count} de naufragios",
"other": "{count} naufragios"
},
"1900–1945": "1900–1945",
"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.",
"All years": "Todos los años",
"Period": "Periodo",
"From": "Desde",
"To": "Hasta",
"First year of the shipwreck period": "Primer año del periodo de naufragios",
"Last year of the shipwreck period": "Último año del periodo de naufragios",
"SIGHTING": "AVISTAMIENTO",
"Show {name} sighting": "Mostrar avistamiento de {name}",
"seen {age} ago": "visto hace {age}",
@@ -3400,5 +3544,17 @@
"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",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"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 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 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 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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID del buque: {id}"
}
+162 -6
View File
@@ -1,4 +1,94 @@
{
"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"
},
"{count} reports today": {
"one": "{count} article aujourd’hui",
"many": "{count} articles aujourd’hui",
"other": "{count} articles aujourd’hui"
},
"Disputed: {name}": "Contesté : {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}",
"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é",
"Outside party": "Partie extérieure",
"Ukraine–Russia": "Ukraine–Russie",
"Israel–Gaza–Lebanon": "Israël–Gaza–Liban",
"Iran–Hormuz": "Iran–Ormuz",
"Yemen–Red Sea": "Yémen–mer Rouge",
"Sudan": "Soudan",
"Sahel": "Sahel",
"Elsewhere": "Ailleurs",
"Strike": "Frappe",
"Alert": "Alerte",
"Air defence": "Défense aérienne",
"Ground fighting": "Combats au sol",
"Casualties": "Victimes",
"Humanitarian": "Humanitaire",
"Disaster": "Catastrophe",
"Protest": "Manifestation",
"Political": "Politique",
"No physical event": "Aucun événement physique",
"Minor incident": "Incident mineur",
"Significant": "Important",
"Major": "Majeur",
"age|now": "maintenant",
"{minutes} min": "{minutes} min",
"{hours} h": "{hours} h",
"{count} sources": {
"one": "{count} source",
"many": "{count} 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",
"{place} (placed by model)": "{place} (placé par le modèle)",
"English headline by the model": "Titre anglais du modèle",
"Not placed on the map": "Non placé sur la carte",
"Close": "Fermer",
"Any severity": "Toute gravité",
"{severity} and above": "{severity} et plus",
"{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",
"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",
"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",
"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",
"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",
"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.",
"Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.",
@@ -329,6 +419,7 @@
"LOADING": "CHARGEMENT",
"DEGRADED": "DÉGRADÉ",
"STALE": "OBSOLÈTE",
"SIGNAL LOST · {time}": "SIGNAL PERDU · {time}",
"PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS",
"view band|STREET": "RUE",
@@ -734,6 +825,7 @@
"loading...": "chargement…",
"KEY REQUIRED": "CLÉ REQUISE",
"LIVE · updated {age}": "DIRECT · mis à jour {age}",
"24 h to {day} {time} UTC": "24 h jusqu'au {day} {time} UTC",
"Source snapshot time unavailable": "Heure de l'instantané source indisponible",
"Live data unavailable": "Données en direct indisponibles",
"Intel Picture": "Situation renseignement",
@@ -1690,14 +1782,15 @@
"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",
"RESOLVING REGION": "IDENTIFICATION DE LA RÉGION",
"REGION UNAVAILABLE": "RÉGION INDISPONIBLE",
"NO RECENT LOCATION MATCHES": "AUCUNE ACTU RÉCENTE POUR CE LIEU",
"NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NÉBULOSITÉ {pct} %",
"CLOUD UNKNOWN": "NÉBULOSITÉ INCONNUE",
"{source} · LOCATION QUERY": "{source} · REQUÊTE DE LIEU",
"{source} · CACHED {age}": "{source} · EN CACHE {age}",
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
@@ -1763,7 +1856,7 @@
"OBSERVED / MAPPED PINGS": "PINGS OBSERVÉS / CARTOGRAPHIÉS",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ÉVÉNEMENTS SOURCÉS · AUCUNE ACTU SYNTHÉTIQUE",
"LATEST LOCATION-MATCHED REPORTING": "DERNIÈRES ACTUS LIÉES AU LIEU",
"{source} · LOCATION QUERY · RECENT": "{source} · REQUÊTE DE LIEU · RÉCENT",
"{source} · LAST {hours} H": "{source} · {hours} DERNIÈRES H",
"LOCAL INFO": "INFOS LOCALES",
"PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION",
"TIME UNKNOWN": "HEURE INCONNUE",
@@ -2195,8 +2288,6 @@
"Shared {subject} follow expired": "Suivi partagé expiré : {subject}",
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
"{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue",
"{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}",
"This share link can't be opened": "Ce lien de partage ne peut pas être ouvert",
"Shared vessel is unavailable": "Le navire partagé est indisponible",
"ACQUIRING": "ACQUISITION",
@@ -3281,6 +3372,59 @@
"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.",
"Economy": "Économie",
"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é",
"{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",
"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 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}",
"Protected wreck": "Épave protégée",
"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é",
"Shipwrecks unavailable": "Épaves indisponibles",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)",
"{count} wrecks": {
"one": "{count} épave",
"many": "{count} d'épaves",
"other": "{count} épaves"
},
"1900–1945": "1900–1945",
"Shipwreck": "Naufrage",
"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.",
"All years": "Toutes les années",
"Period": "Période",
"From": "Du",
"To": "Au",
"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",
"Show {name} sighting": "Afficher l'observation : {name}",
"seen {age} ago": "vu il y a {age}",
@@ -3400,5 +3544,17 @@
"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",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Photo © {photographer}"
"Photo © {photographer}": "Photo © {photographer}",
"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 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 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 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}"
}
File diff suppressed because it is too large. Load diff
+162 -6
View File
@@ -1,4 +1,94 @@
{
"News feed unavailable": "Feed delle notizie non disponibile",
"Developing: {name}": "In sviluppo: {name}",
"{count} events today": {
"one": "{count} evento oggi",
"many": "{count} eventi oggi",
"other": "{count} eventi oggi"
},
"{count} reports today": {
"one": "{count} notizia oggi",
"many": "{count} notizie oggi",
"other": "{count} notizie oggi"
},
"Disputed: {name}": "Conteso: {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}",
"Official": "Ufficiale",
"Reported by both sides": "Riportato da entrambe le parti",
"Independently reported": "Riportato in modo indipendente",
"Single source": "Fonte unica",
"International": "Internazionale",
"Independent": "Indipendente",
"Unattributed": "Non attribuito",
"Outside party": "Parte esterna",
"Ukraine–Russia": "Ucraina–Russia",
"Israel–Gaza–Lebanon": "Israele–Gaza–Libano",
"Iran–Hormuz": "Iran–Hormuz",
"Yemen–Red Sea": "Yemen–Mar Rosso",
"Sudan": "Sudan",
"Sahel": "Sahel",
"Elsewhere": "Altrove",
"Strike": "Attacco",
"Alert": "Allerta",
"Air defence": "Difesa aerea",
"Ground fighting": "Combattimenti a terra",
"Casualties": "Vittime",
"Humanitarian": "Umanitario",
"Disaster": "Disastro",
"Protest": "Protesta",
"Political": "Politica",
"No physical event": "Nessun evento fisico",
"Minor incident": "Incidente minore",
"Significant": "Rilevante",
"Major": "Grave",
"age|now": "ora",
"{minutes} min": "{minutes} min",
"{hours} h": "{hours} h",
"{count} sources": {
"one": "{count} fonte",
"many": "{count} 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",
"Not placed on the map": "Non collocato sulla mappa",
"Close": "Chiudi",
"Any severity": "Qualsiasi gravità",
"{severity} and above": "{severity} e oltre",
"{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",
"Time window": "Periodo",
"Conflicts": "Conflitti",
"Local": "Locale",
"News sections": "Sezioni notizie",
"Zoom in to see local news": "Ingrandisci per vedere le notizie locali",
"Hotspots": "Focolai",
"Severity": "Gravità",
"Category": "Categoria",
"Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {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",
"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.",
"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",
"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.",
"Click where the pin goes.": "Fai clic dove va il segnaposto.",
@@ -329,6 +419,7 @@
"LOADING": "CARICAMENTO",
"DEGRADED": "DEGRADATO",
"STALE": "OBSOLETO",
"SIGNAL LOST · {time}": "SEGNALE PERSO · {time}",
"PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA",
"view band|STREET": "STRADA",
@@ -734,6 +825,7 @@
"loading...": "caricamento...",
"KEY REQUIRED": "CHIAVE RICHIESTA",
"LIVE · updated {age}": "LIVE · aggiornato {age}",
"24 h to {day} {time} UTC": "24 h fino al {day} {time} UTC",
"Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile",
"Live data unavailable": "Dati live non disponibili",
"Intel Picture": "Quadro intelligence",
@@ -1690,14 +1782,15 @@
"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.": "Scorri automaticamente le pagine del briefing ogni 9 secondi (Segnali → Notizie → Locale). Lo scorrimento si interrompe quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.",
"ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI",
"REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI",
"CACHED · REFRESH FAILED": "IN CACHE · AGGIORNAMENTO NON RIUSCITO",
"RESOLVING REGION": "IDENTIFICAZIONE REGIONE",
"REGION UNAVAILABLE": "REGIONE NON DISPONIBILE",
"NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE",
"NO NEWS IN THE LAST {hours} H": "NESSUNA NOTIZIA NELLE ULTIME {hours} H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NUVOLE {pct}%",
"CLOUD UNKNOWN": "NUVOLE N/D",
"{source} · LOCATION QUERY": "{source} · QUERY PER POSIZIONE",
"{source} · CACHED {age}": "{source} · IN CACHE {age}",
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
@@ -1763,7 +1856,7 @@
"OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTI DA FONTI · NESSUNA NOTIZIA SINTETICA",
"LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE",
"{source} · LOCATION QUERY · RECENT": "{source} · QUERY PER POSIZIONE · RECENTI",
"{source} · LAST {hours} H": "{source} · ULTIME {hours} H",
"LOCAL INFO": "INFO LOCALI",
"PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE",
"TIME UNKNOWN": "ORA SCONOSCIUTA",
@@ -2195,8 +2288,6 @@
"Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto",
"Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject}: feed non disponibile",
"Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile",
"{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota ({time})",
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
"ACQUIRING": "ACQUISIZIONE",
@@ -3281,6 +3372,59 @@
"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.",
"Economy": "Economia",
"News from {area}": "Notizie: {area}",
"Within {km} km of the view centre": "Entro {km} km dal centro della vista",
"{region}, {country}": "{region}, {country}",
"via {source}": "tramite {source}",
"Local in the News layer: the day’s news from the outlets of the country in view": "Locale nel livello Notizie: le notizie del giorno dalle testate del paese inquadrato",
"{provider} refused its key": "{provider} ha rifiutato la chiave",
"Check {envVars} in Provider Settings": "Controlla {envVars} in Impostazioni provider",
"{provider} key required": "Chiave {provider} richiesta",
"{provider} limit reached for today": "{provider} ha raggiunto il limite di oggi",
"News resumes tomorrow, or with a paid plan": "Le notizie riprendono domani, o con un piano a pagamento",
"News key required": "Chiave per le notizie richiesta",
"Precise conflict points in the News layer, beside GDELT’s articles": "Punti di conflitto precisi nel livello Notizie, accanto agli articoli di GDELT",
"Worldwide": "Mondo",
"Shipwrecks": "Relitti",
"Before 1900": "Prima del 1900",
"After 1945": "Dopo il 1945",
"Date unknown": "Data sconosciuta",
"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}",
"What happened: {value}": "Cosa è successo: {value}",
"Depth: {m} m ({quality})": "Profondità: {m} m ({quality})",
"Depth: {m} m": "Profondità: {m} m",
"Notes: {value}": "Note: {value}",
"Cargo or finds: {value}": "Carico o reperti: {value}",
"Protected wreck": "Relitto protetto",
"Source: {value}": "Fonte: {value}",
"Click the card to open EMODnet ↗": "Fai clic sulla scheda per aprire EMODnet ↗",
"Click the card to open the source ↗": "Fai clic sulla scheda per aprire la fonte ↗",
"Wikidata unavailable; showing EMODnet only": "Wikidata non disponibile; solo EMODnet",
"EMODnet unavailable; showing Wikidata only": "EMODnet non disponibile; solo Wikidata",
"Shipwrecks unavailable": "Relitti non disponibili",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (acque europee) · Wikidata (mondo)",
"{count} wrecks": {
"one": "{count} relitto",
"many": "{count} di relitti",
"other": "{count} relitti"
},
"1900–1945": "1900–1945",
"Shipwreck": "Naufragio",
"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.",
"All years": "Tutti gli anni",
"Period": "Periodo",
"From": "Dal",
"To": "Al",
"First year of the shipwreck period": "Primo anno del periodo dei naufragi",
"Last year of the shipwreck period": "Ultimo anno del periodo dei naufragi",
"SIGHTING": "AVVISTAMENTO",
"Show {name} sighting": "Mostra avvistamento di {name}",
"seen {age} ago": "avvistato {age} fa",
@@ -3400,5 +3544,17 @@
"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",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"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 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 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 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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID della nave: {id}"
}
+154 -6
View File
@@ -1,4 +1,88 @@
{
"News feed unavailable": "ニュースフィードを利用できません",
"Developing: {name}": "進行中: {name}",
"{count} events today": {
"other": "本日の出来事 {count}件"
},
"{count} reports today": {
"other": "本日の報道 {count}件"
},
"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}側の情報源",
"Official": "公式",
"Reported by both sides": "双方が報道",
"Independently reported": "独立した報道あり",
"Single source": "単一の情報源",
"International": "国際",
"Independent": "独立系",
"Unattributed": "出所不明",
"Outside party": "第三者",
"Ukraine–Russia": "ウクライナ–ロシア",
"Israel–Gaza–Lebanon": "イスラエル–ガザ–レバノン",
"Iran–Hormuz": "イラン–ホルムズ",
"Yemen–Red Sea": "イエメン–紅海",
"Sudan": "スーダン",
"Sahel": "サヘル",
"Elsewhere": "その他の地域",
"Strike": "攻撃",
"Alert": "警報",
"Air defence": "防空",
"Ground fighting": "地上戦",
"Casualties": "死傷者",
"Humanitarian": "人道",
"Disaster": "災害",
"Protest": "抗議",
"Political": "政治",
"No physical event": "物理的な事象なし",
"Minor incident": "小規模な事案",
"Significant": "重大",
"Major": "深刻",
"age|now": "今",
"{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": "ニュースフィードの応答の形式が不正です",
"{place} (placed by model)": "{place}(モデルが推定)",
"English headline by the model": "モデルによる英語の見出し",
"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}時間",
"Time window": "期間",
"Conflicts": "紛争",
"Local": "ローカル",
"News sections": "ニュースの区分",
"Zoom in to see local news": "拡大するとローカルニュースが表示されます",
"Hotspots": "ホットスポット",
"Severity": "深刻度",
"Category": "カテゴリ",
"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": "以前のニュースを読み込む",
"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.": "陣営ラベルは情報源を示すもので、報道の真偽ではありません。",
"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でキャンセル。",
"Click where the pin goes.": "ピンを置く場所をクリックしてください。",
@@ -323,6 +407,7 @@
"LOADING": "読込中",
"DEGRADED": "性能低下",
"STALE": "更新遅延",
"SIGNAL LOST · {time}": "信号途絶 · {time}",
"PARTIAL": "部分的",
"FALLBACK": "フォールバック",
"view band|STREET": "街路",
@@ -716,6 +801,7 @@
"loading...": "読込中…",
"KEY REQUIRED": "キーが必要",
"LIVE · updated {age}": "ライブ · 更新 {age}",
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
"Live data unavailable": "ライブデータを利用できません",
"Intel Picture": "情勢図",
@@ -1642,14 +1728,15 @@
"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": "キャッシュ · 更新失敗",
"RESOLVING REGION": "地域を特定中",
"REGION UNAVAILABLE": "地域を取得できません",
"NO RECENT LOCATION MATCHES": "この地域の最近のニュースなし",
"NO NEWS IN THE LAST {hours} H": "過去{hours}時間のニュースなし",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "雲量 {pct}%",
"CLOUD UNKNOWN": "雲量不明",
"{source} · LOCATION QUERY": "{source} · 位置クエリ",
"{source} · CACHED {age}": "{source} · キャッシュ {age}",
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
@@ -1713,7 +1800,7 @@
"OBSERVED / MAPPED PINGS": "観測 / マップ済みピング",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "出典付きイベントのみ · 合成ニュースなし",
"LATEST LOCATION-MATCHED REPORTING": "位置に一致する最新の報道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置クエリ · 最近",
"{source} · LAST {hours} H": "{source} · 過去{hours}時間",
"LOCAL INFO": "ローカル情報",
"PLACE / CONDITIONS / POSITION": "場所 / 天候 / 位置",
"TIME UNKNOWN": "時刻不明",
@@ -2123,8 +2210,6 @@
"Shared {subject} follow expired": "共有された{subject}の追従が期限切れになりました",
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
"Shared {subject} is unavailable": "共有された{subject}は利用できません",
"{id} isn't reporting now — showing where it was last seen": "{id}は現在報告していません。最後に確認された位置を表示しています",
"{id} isn't reporting now — showing where it was at {time}": "{id}は現在報告していません。{time}時点の位置を表示しています",
"This share link can't be opened": "この共有リンクは開けません",
"Shared vessel is unavailable": "共有された船舶は利用できません",
"ACQUIRING": "取得中",
@@ -3127,6 +3212,57 @@
"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時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。",
"Economy": "経済",
"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}がキーを拒否しました",
"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の記事に加えて)",
"Worldwide": "世界",
"Shipwrecks": "難破船",
"Before 1900": "1900年以前",
"After 1945": "1945年以降",
"Date unknown": "日付不明",
"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}",
"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": "難破船データを利用できません",
"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によります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。",
"All years": "全期間",
"Period": "期間",
"From": "開始",
"To": "終了",
"First year of the shipwreck period": "難破の期間の開始年",
"Last year of the shipwreck period": "難破の期間の終了年",
"SIGHTING": "目撃情報",
"Show {name} sighting": "{name}の目撃情報を表示",
"seen {age} ago": "{age}前に目撃",
@@ -3246,5 +3382,17 @@
"Play speed": "再生速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
"1 min/s": "1分/秒",
"Photo © {photographer}": "写真 © {photographer}"
"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}時点の位置を表示しています",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "船舶UUID: {id}"
}
+162 -6
View File
@@ -1,4 +1,94 @@
{
"News feed unavailable": "Feed de notícias indisponível",
"Developing: {name}": "Em desenvolvimento: {name}",
"{count} events today": {
"one": "{count} evento hoje",
"many": "{count} eventos hoje",
"other": "{count} eventos hoje"
},
"{count} reports today": {
"one": "{count} notícia hoje",
"many": "{count} notícias hoje",
"other": "{count} notícias hoje"
},
"Disputed: {name}": "Em disputa: {name}",
"Video": "Vídeo",
"Photos": "Fotos",
"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 {sides}": "Noticiado por {sides}",
"{side} sources": "fontes de {side}",
"Official": "Oficial",
"Reported by both sides": "Noticiado por ambos os lados",
"Independently reported": "Noticiado de forma independente",
"Single source": "Fonte única",
"International": "Internacional",
"Independent": "Independente",
"Unattributed": "Sem atribuição",
"Outside party": "Parte externa",
"Ukraine–Russia": "Ucrânia–Rússia",
"Israel–Gaza–Lebanon": "Israel–Gaza–Líbano",
"Iran–Hormuz": "Irã–Ormuz",
"Yemen–Red Sea": "Iêmen–Mar Vermelho",
"Sudan": "Sudão",
"Sahel": "Sahel",
"Elsewhere": "Outros lugares",
"Strike": "Ataque",
"Alert": "Alerta",
"Air defence": "Defesa aérea",
"Ground fighting": "Combates terrestres",
"Casualties": "Vítimas",
"Humanitarian": "Humanitário",
"Disaster": "Desastre",
"Protest": "Protesto",
"Political": "Político",
"No physical event": "Sem evento físico",
"Minor incident": "Incidente menor",
"Significant": "Significativo",
"Major": "Grave",
"age|now": "agora",
"{minutes} min": "{minutes} min",
"{hours} h": "{hours} h",
"{count} sources": {
"one": "{count} fonte",
"many": "{count} fontes",
"other": "{count} fontes"
},
"News feed not configured on this server": "Feed de notícias não configurado neste servidor",
"Malformed news hotspots response": "Resposta de focos de notícias malformada",
"Malformed news feed response": "Resposta do feed de notícias malformada",
"{place} (placed by model)": "{place} (localizado pelo modelo)",
"English headline by the model": "Manchete em inglês do modelo",
"Not placed on the map": "Não localizado no mapa",
"Close": "Fechar",
"Any severity": "Qualquer gravidade",
"{severity} and above": "{severity} ou mais",
"{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",
"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",
"Hotspots": "Focos",
"Severity": "Gravidade",
"Category": "Categoria",
"Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h",
"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",
"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",
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Borda contínua: noticiado por ambos os lados ou de forma independente. Borda tracejada: uma alegação. Borda fina: uma fonte, ou a imprensa de um lado.",
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Maior: mais fontes · mais tênue: mais antigo. Cartão com imagem: um evento grave ou em rápido crescimento com duas ou mais fontes.",
"Side labels describe the source, not whether a report is true.": "Os rótulos de lado descrevem a fonte, não se a notícia é verdadeira.",
"Exit cockpit to fly to a news report": "Saia da cabine para voar até uma notícia",
"News items in the feed": "Notícias no feed",
"Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.",
"Click where the pin goes.": "Clique onde o marcador deve ficar.",
@@ -329,6 +419,7 @@
"LOADING": "CARREGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESATUALIZADO",
"SIGNAL LOST · {time}": "SINAL PERDIDO · {time}",
"PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO",
"view band|STREET": "RUA",
@@ -732,6 +823,7 @@
"loading...": "carregando...",
"KEY REQUIRED": "CHAVE NECESSÁRIA",
"LIVE · updated {age}": "AO VIVO · atualizado {age}",
"24 h to {day} {time} UTC": "24 h até {day} {time} UTC",
"Source snapshot time unavailable": "Horário do snapshot da fonte indisponível",
"Live data unavailable": "Dados ao vivo indisponíveis",
"Intel Picture": "Panorama de inteligência",
@@ -1690,14 +1782,15 @@
"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.": "Alternar as páginas do briefing automaticamente a cada 9 segundos (Sinais → Notícias → Local). A alternância pausa enquanto você passa o mouse ou foca o painel. Os dados de sinais ao vivo continuam sendo atualizados de qualquer forma.",
"ACQUIRING REGIONAL NEWS": "OBTENDO NOTÍCIAS DA ÁREA",
"REGIONAL NEWS UNAVAILABLE": "NOTÍCIAS DA ÁREA INDISPONÍVEIS",
"CACHED · REFRESH FAILED": "EM CACHE · FALHA AO ATUALIZAR",
"RESOLVING REGION": "IDENTIFICANDO REGIÃO",
"REGION UNAVAILABLE": "REGIÃO INDISPONÍVEL",
"NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDÊNCIA RECENTE COM O LOCAL",
"NO NEWS IN THE LAST {hours} H": "SEM NOTÍCIAS NAS ÚLTIMAS {hours} H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NUVENS {pct}%",
"CLOUD UNKNOWN": "NEBULOSIDADE DESCONHECIDA",
"{source} · LOCATION QUERY": "{source} · CONSULTA POR LOCAL",
"{source} · CACHED {age}": "{source} · EM CACHE {age}",
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
@@ -1763,7 +1856,7 @@
"OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTOS COM FONTE · SEM NOTÍCIAS SINTÉTICAS",
"LATEST LOCATION-MATCHED REPORTING": "ÚLTIMAS NOTÍCIAS DO LOCAL",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR LOCAL · RECENTE",
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
"LOCAL INFO": "INFO LOCAL",
"PLACE / CONDITIONS / POSITION": "LUGAR / CONDIÇÕES / POSIÇÃO",
"TIME UNKNOWN": "HORA DESCONHECIDA",
@@ -2195,8 +2288,6 @@
"Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou",
"Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível",
"Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível",
"{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez",
"{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava em {time}",
"This share link can't be opened": "Não é possível abrir este link de compartilhamento",
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
"ACQUIRING": "ADQUIRINDO",
@@ -3281,6 +3372,59 @@
"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.",
"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",
"{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",
"{provider} limit reached for today": "{provider} atingiu o limite de hoje",
"News resumes tomorrow, or with a paid plan": "As notícias voltam amanhã, ou com um plano pago",
"News key required": "Chave de notícias necessária",
"Precise conflict points in the News layer, beside GDELT’s articles": "Pontos de conflito precisos na camada de notícias, ao lado dos artigos do GDELT",
"Worldwide": "Mundo",
"Shipwrecks": "Naufrágios",
"Before 1900": "Antes de 1900",
"After 1945": "Depois de 1945",
"Date unknown": "Data desconhecida",
"Position: centre of the protected area": "Posição: centro da área protegida",
"Position approximate (within about {km} km)": "Posição aproximada (margem de cerca de {km} km)",
"Position surveyed (within about {m} m)": "Posição levantada (margem de cerca de {m} m)",
"Vessel: {value}": "Embarcação: {value}",
"What happened: {value}": "O que aconteceu: {value}",
"Depth: {m} m ({quality})": "Profundidade: {m} m ({quality})",
"Depth: {m} m": "Profundidade: {m} m",
"Notes: {value}": "Notas: {value}",
"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 ↗",
"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",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (águas europeias) · Wikidata (mundo)",
"{count} wrecks": {
"one": "{count} naufrágio",
"many": "{count} de naufrágios",
"other": "{count} naufrágios"
},
"1900–1945": "1900–1945",
"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.",
"All years": "Todos os anos",
"Period": "Período",
"From": "Início",
"To": "Fim",
"First year of the shipwreck period": "Primeiro ano do período dos naufrágios",
"Last year of the shipwreck period": "Último ano do período dos naufrágios",
"SIGHTING": "AVISTAMENTO",
"Show {name} sighting": "Mostrar avistamento de {name}",
"seen {age} ago": "visto há {age}",
@@ -3400,5 +3544,17 @@
"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",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"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 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 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 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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID da embarcação: {id}"
}
+166 -6
View File
@@ -1,4 +1,97 @@
{
"News feed unavailable": "Лента новостей недоступна",
"Developing: {name}": "Развивается: {name}",
"{count} events today": {
"one": "{count} событие сегодня",
"few": "{count} события сегодня",
"many": "{count} событий сегодня",
"other": "{count} события сегодня"
},
"{count} reports today": {
"one": "{count} сообщение сегодня",
"few": "{count} сообщения сегодня",
"many": "{count} сообщений сегодня",
"other": "{count} сообщения сегодня"
},
"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}",
"Official": "Официально",
"Reported by both sides": "Сообщают обе стороны",
"Independently reported": "Подтверждено независимыми источниками",
"Single source": "Один источник",
"International": "Международные",
"Independent": "Независимые",
"Unattributed": "Без атрибуции",
"Outside party": "Внешняя сторона",
"Ukraine–Russia": "Украина–Россия",
"Israel–Gaza–Lebanon": "Израиль–Газа–Ливан",
"Iran–Hormuz": "Иран–Ормуз",
"Yemen–Red Sea": "Йемен–Красное море",
"Sudan": "Судан",
"Sahel": "Сахель",
"Elsewhere": "Другие места",
"Strike": "Удар",
"Alert": "Тревога",
"Air defence": "ПВО",
"Ground fighting": "Наземные бои",
"Casualties": "Жертвы",
"Humanitarian": "Гуманитарное",
"Disaster": "Бедствие",
"Protest": "Протест",
"Political": "Политика",
"No physical event": "Без физического события",
"Minor incident": "Незначительный инцидент",
"Significant": "Значительный",
"Major": "Крупный",
"age|now": "сейчас",
"{minutes} min": "{minutes} мин",
"{hours} h": "{hours} ч",
"{count} sources": {
"one": "{count} источник",
"few": "{count} источника",
"many": "{count} источников",
"other": "{count} источника"
},
"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": "Английский заголовок от модели",
"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} ч",
"Time window": "Период",
"Conflicts": "Конфликты",
"Local": "Местные",
"News sections": "Разделы новостей",
"Zoom in to see local news": "Приблизьте карту, чтобы увидеть местные новости",
"Hotspots": "Горячие точки",
"Severity": "Тяжесть",
"Category": "Категория",
"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": "Загрузить более ранние новости",
"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": "Выйдите из кабины, чтобы перелететь к сообщению",
"News items in the feed": "Новостей в ленте",
"Flying to Austin, TX...": "Перелёт в Остин, Техас...",
"Restoring shared view...": "Восстановление общего вида...",
"Initializing systems...": "Инициализация систем...",
@@ -143,10 +236,12 @@
"{count}h ago": "{count} ч назад",
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
"STALE": "УСТАРЕЛ",
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
"refreshing...": "обновление...",
"loading...": "загрузка...",
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
"Retrying mapped sites…": "Повторный запрос объектов…",
"Fetching mapped sites…": "Загрузка объектов…",
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
@@ -330,8 +425,6 @@
"Link copied!": "Ссылка скопирована!",
"Link shared": "Ссылка отправлена",
"This share link can't be opened": "Не удаётся открыть эту ссылку",
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
"Shared vessel is unavailable": "Судно по ссылке недоступно",
"Copy failed": "Не удалось скопировать",
"VISUAL PRESETS": "ПРЕСЕТЫ",
@@ -398,10 +491,11 @@
"OVERCAST": "ПАСМУРНО",
"ACQUIRING REGIONAL NEWS": "ЗАГРУЗКА РЕГИОНАЛЬНЫХ НОВОСТЕЙ",
"REGIONAL NEWS UNAVAILABLE": "РЕГИОНАЛЬНЫЕ НОВОСТИ НЕДОСТУПНЫ",
"CACHED · REFRESH FAILED": "КЭШ · ОБНОВЛЕНИЕ НЕ УДАЛОСЬ",
"RESOLVING REGION": "ОПРЕДЕЛЕНИЕ РЕГИОНА",
"REGION UNAVAILABLE": "РЕГИОН НЕДОСТУПЕН",
"FOG": "ТУМАН",
"NO RECENT LOCATION MATCHES": "НЕТ СВЕЖИХ СОВПАДЕНИЙ ПО МЕСТУ",
"NO NEWS IN THE LAST {hours} H": "НЕТ НОВОСТЕЙ ЗА {hours} Ч",
"DRIZZLE": "МОРОСЬ",
"RAIN": "ДОЖДЬ",
"{domain} · {age}": "{domain} · {age}",
@@ -413,7 +507,7 @@
"CLOUD {pct}%": "ОБЛАЧНОСТЬ {pct}%",
"CLOUD UNKNOWN": "ОБЛАЧНОСТЬ НЕИЗВЕСТНА",
"MIXED CONDITIONS": "СМЕШАННЫЕ УСЛОВИЯ",
"{source} · LOCATION QUERY": "{source} · ЗАПРОС ПО МЕСТУ",
"{source} · CACHED {age}": "{source} · КЭШ {age}",
"REGIONAL NEWS": "НОВОСТИ РЕГИОНА",
"CYCLE ON": "АВТО: ВКЛ",
"CYCLE OFF": "АВТО: ВЫКЛ",
@@ -2040,7 +2134,7 @@
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"{source} · LAST {hours} H": "{source} · ЗА {hours} Ч",
"ft": "фт",
"kts": "уз",
"PAUSE": "ПАУЗА",
@@ -3358,6 +3452,60 @@
"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.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
"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}": "{region}, {country}",
"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} отклонил ключ",
"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",
"Worldwide": "Весь мир",
"Shipwrecks": "Кораблекрушения",
"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}",
"What happened: {value}": "Что произошло: {value}",
"Depth: {m} m ({quality})": "Глубина: {m} м ({quality})",
"Depth: {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": "Данные о кораблекрушениях недоступны",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (воды Европы) · Wikidata (весь мир)",
"{count} wrecks": {
"one": "{count} затонувшее судно",
"few": "{count} затонувших судна",
"many": "{count} затонувших судов",
"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. Цвет показывает, когда судно затонуло; маркер растёт с числом погибших. Нажмите на маркер, чтобы открыть карточку.",
"All years": "Все годы",
"Period": "Период",
"From": "С",
"To": "По",
"First year of the shipwreck period": "Первый год периода кораблекрушений",
"Last year of the shipwreck period": "Последний год периода кораблекрушений",
"SIGHTING": "НАБЛЮДЕНИЕ",
"Show {name} sighting": "Показать наблюдение: {name}",
"seen {age} ago": "замечено {age} назад",
@@ -3477,5 +3625,17 @@
"Play speed": "Скорость воспроизведения",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
"1 min/s": "1 мин/с",
"Photo © {photographer}": "Фото © {photographer}"
"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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
}
+166 -6
View File
@@ -1,4 +1,97 @@
{
"News feed unavailable": "Стрічка новин недоступна",
"Developing: {name}": "Розвивається: {name}",
"{count} events today": {
"one": "{count} подія сьогодні",
"few": "{count} події сьогодні",
"many": "{count} подій сьогодні",
"other": "{count} події сьогодні"
},
"{count} reports today": {
"one": "{count} повідомлення сьогодні",
"few": "{count} повідомлення сьогодні",
"many": "{count} повідомлень сьогодні",
"other": "{count} повідомлення сьогодні"
},
"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}",
"Official": "Офіційно",
"Reported by both sides": "Повідомляють обидві сторони",
"Independently reported": "Підтверджено незалежними джерелами",
"Single source": "Одне джерело",
"International": "Міжнародні",
"Independent": "Незалежні",
"Unattributed": "Без атрибуції",
"Outside party": "Зовнішня сторона",
"Ukraine–Russia": "Україна–Росія",
"Israel–Gaza–Lebanon": "Ізраїль–Газа–Ліван",
"Iran–Hormuz": "Іран–Ормуз",
"Yemen–Red Sea": "Ємен–Червоне море",
"Sudan": "Судан",
"Sahel": "Сахель",
"Elsewhere": "Інші місця",
"Strike": "Удар",
"Alert": "Тривога",
"Air defence": "ППО",
"Ground fighting": "Наземні бої",
"Casualties": "Жертви",
"Humanitarian": "Гуманітарне",
"Disaster": "Лихо",
"Protest": "Протест",
"Political": "Політика",
"No physical event": "Без фізичної події",
"Minor incident": "Незначний інцидент",
"Significant": "Значний",
"Major": "Великий",
"age|now": "зараз",
"{minutes} min": "{minutes} хв",
"{hours} h": "{hours} год",
"{count} sources": {
"one": "{count} джерело",
"few": "{count} джерела",
"many": "{count} джерел",
"other": "{count} джерела"
},
"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": "Англійський заголовок від моделі",
"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} год",
"Time window": "Період",
"Conflicts": "Конфлікти",
"Local": "Місцеві",
"News sections": "Розділи новин",
"Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини",
"Hotspots": "Гарячі точки",
"Severity": "Тяжкість",
"Category": "Категорія",
"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": "Завантажити давніші новини",
"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": "Вийдіть із кабіни, щоб перелетіти до повідомлення",
"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.": "Клацніть там, де має бути мітка.",
@@ -332,6 +425,7 @@
"LOADING": "ЗАВАНТАЖЕННЯ",
"DEGRADED": "ПЕРЕБОЇ",
"STALE": "ЗАСТАРІЛО",
"SIGNAL LOST · {time}": "СИГНАЛ ВТРАЧЕНО · {time}",
"PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ",
"view band|STREET": "ВУЛИЦЯ",
@@ -742,6 +836,7 @@
"loading...": "завантаження...",
"KEY REQUIRED": "ПОТРІБЕН КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · оновлено {age}",
"24 h to {day} {time} UTC": "24 год до {day} {time} UTC",
"Source snapshot time unavailable": "Час знімка джерела невідомий",
"Live data unavailable": "Дані в реальному часі недоступні",
"Intel Picture": "Розвідувальна обстановка",
@@ -1713,14 +1808,15 @@
"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": "КЕШ · ОНОВЛЕННЯ НЕ ВДАЛОСЯ",
"RESOLVING REGION": "ВИЗНАЧЕННЯ РЕГІОНУ",
"REGION UNAVAILABLE": "РЕГІОН НЕДОСТУПНИЙ",
"NO RECENT LOCATION MATCHES": "НЕМАЄ СВІЖИХ ЗБІГІВ ЗА РОЗТАШУВАННЯМ",
"NO NEWS IN THE LAST {hours} H": "НЕМАЄ НОВИН ЗА {hours} ГОД",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} КМ/ГОД",
"CLOUD {pct}%": "ХМАРНІСТЬ {pct}%",
"CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА",
"{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ",
"{source} · CACHED {age}": "{source} · КЕШ {age}",
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
@@ -1787,7 +1883,7 @@
"OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ КОНТАКТИ",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ІМІТОВАНИХ НОВИН",
"LATEST LOCATION-MATCHED REPORTING": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ · НЕЩОДАВНІ",
"{source} · LAST {hours} H": "{source} · ЗА {hours} ГОД",
"LOCAL INFO": "МІСЦЕВІ ДАНІ",
"PLACE / CONDITIONS / POSITION": "МІСЦЕ / УМОВИ / ПОЗИЦІЯ",
"TIME UNKNOWN": "ЧАС НЕВІДОМИЙ",
@@ -2230,8 +2326,6 @@
"Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}",
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
"Shared {subject} is unavailable": "Недоступно за посиланням: {subject}",
"{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце",
"{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})",
"This share link can't be opened": "Не вдається відкрити це посилання",
"Shared vessel is unavailable": "Судно за посиланням недоступне",
"ACQUIRING": "ОТРИМАННЯ",
@@ -3358,6 +3452,60 @@
"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.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
"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}": "{region}, {country}",
"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} відхилив ключ",
"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",
"Worldwide": "Увесь світ",
"Shipwrecks": "Корабельні аварії",
"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}",
"What happened: {value}": "Що сталося: {value}",
"Depth: {m} m ({quality})": "Глибина: {m} м ({quality})",
"Depth: {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": "Дані про корабельні аварії недоступні",
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)",
"{count} wrecks": {
"one": "{count} затонуле судно",
"few": "{count} затонулі судна",
"many": "{count} затонулих суден",
"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. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.",
"All years": "Усі роки",
"Period": "Період",
"From": "З",
"To": "По",
"First year of the shipwreck period": "Перший рік періоду корабельних аварій",
"Last year of the shipwreck period": "Останній рік періоду корабельних аварій",
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
"Show {name} sighting": "Показати спостереження: {name}",
"seen {age} ago": "помічено {age} тому",
@@ -3477,5 +3625,17 @@
"Play speed": "Швидкість відтворення",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
"1 min/s": "1 хв/с",
"Photo © {photographer}": "Фото © {photographer}"
"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}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
}
+154 -6
View File
@@ -1,4 +1,88 @@
{
"News feed unavailable": "新闻源不可用",
"Developing: {name}": "事态发展:{name}",
"{count} events today": {
"other": "今日 {count} 起事件"
},
"{count} reports today": {
"other": "今日 {count} 条报道"
},
"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}方来源",
"Official": "官方",
"Reported by both sides": "双方均有报道",
"Independently reported": "有独立来源报道",
"Single source": "单一来源",
"International": "国际",
"Independent": "独立",
"Unattributed": "未注明来源",
"Outside party": "外部方",
"Ukraine–Russia": "乌克兰–俄罗斯",
"Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩",
"Iran–Hormuz": "伊朗–霍尔木兹",
"Yemen–Red Sea": "也门–红海",
"Sudan": "苏丹",
"Sahel": "萨赫勒",
"Elsewhere": "其他地区",
"Strike": "打击",
"Alert": "警报",
"Air defence": "防空",
"Ground fighting": "地面战斗",
"Casualties": "伤亡",
"Humanitarian": "人道",
"Disaster": "灾害",
"Protest": "抗议",
"Political": "政治",
"No physical event": "无实际事件",
"Minor incident": "轻微事件",
"Significant": "重大",
"Major": "严重",
"age|now": "刚刚",
"{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": "新闻源响应格式错误",
"{place} (placed by model)": "{place}(由模型定位)",
"English headline by the model": "模型生成的英文标题",
"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} 小时",
"Time window": "时间范围",
"Conflicts": "冲突",
"Local": "本地",
"News sections": "新闻分区",
"Zoom in to see local news": "放大地图以查看本地新闻",
"Hotspots": "热点",
"Severity": "严重程度",
"Category": "类别",
"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": "加载更早的新闻",
"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": "退出驾驶舱以飞往新闻报道",
"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.": "点击图钉的放置位置。",
@@ -323,6 +407,7 @@
"LOADING": "加载中",
"DEGRADED": "降级",
"STALE": "过期",
"SIGNAL LOST · {time}": "信号丢失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "备用",
"view band|STREET": "街道",
@@ -716,6 +801,7 @@
"loading...": "加载中…",
"KEY REQUIRED": "需要密钥",
"LIVE · updated {age}": "实时 · 更新于 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时",
"Source snapshot time unavailable": "来源快照时间不可用",
"Live data unavailable": "实时数据不可用",
"Low-accuracy score < 2%": "低精度评分 < 2%",
@@ -1520,8 +1606,6 @@
"Link copied!": "链接已复制!",
"Link shared": "链接已分享",
"This share link can't be opened": "无法打开此分享链接",
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
"Shared vessel is unavailable": "分享的船只不可用",
"Copy failed": "复制失败",
"Return map to straight-down view": "将地图恢复为垂直俯视",
@@ -1618,14 +1702,15 @@
"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": "缓存 · 刷新失败",
"RESOLVING REGION": "正在解析区域",
"REGION UNAVAILABLE": "区域不可用",
"NO RECENT LOCATION MATCHES": "暂无近期位置匹配结果",
"NO NEWS IN THE LAST {hours} H": "过去 {hours} 小时无新闻",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "云量 {pct}%",
"CLOUD UNKNOWN": "云量未知",
"{source} · LOCATION QUERY": "{source} · 位置查询",
"{source} · CACHED {age}": "{source} · 缓存 {age}",
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
@@ -1689,7 +1774,7 @@
"OBSERVED / MAPPED PINGS": "观测 / 地图标注目标",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻",
"LATEST LOCATION-MATCHED REPORTING": "当前地区最新报道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期",
"{source} · LAST {hours} H": "{source} · 过去 {hours} 小时",
"LOCAL INFO": "本地信息",
"PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置",
"TIME UNKNOWN": "时间未知",
@@ -3127,6 +3212,57 @@
"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 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。",
"Economy": "经济",
"News from {area}": "{area}新闻",
"Within {km} km of the view centre": "视图中心 {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} 拒绝了密钥",
"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 的文章并列",
"Worldwide": "全球",
"Shipwrecks": "沉船",
"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}",
"What happened: {value}": "经过:{value}",
"Depth: {m} m ({quality})": "深度:{m} 米({quality})",
"Depth: {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": "沉船数据不可用",
"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。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。",
"All years": "所有年份",
"Period": "时段",
"From": "起",
"To": "止",
"First year of the shipwreck period": "沉船时段的起始年份",
"Last year of the shipwreck period": "沉船时段的结束年份",
"SIGHTING": "目击",
"Show {name} sighting": "显示{name}目击",
"seen {age} ago": "{age}前目击",
@@ -3246,5 +3382,17 @@
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
"1 min/s": "1 分钟/秒",
"Photo © {photographer}": "照片 © {photographer}"
"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} 的位置",
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
}
+154 -6
View File
@@ -1,4 +1,88 @@
{
"News feed unavailable": "新聞來源無法使用",
"Developing: {name}": "事態發展:{name}",
"{count} events today": {
"other": "今日 {count} 起事件"
},
"{count} reports today": {
"other": "今日 {count} 則報導"
},
"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}方來源",
"Official": "官方",
"Reported by both sides": "雙方均有報導",
"Independently reported": "有獨立來源報導",
"Single source": "單一來源",
"International": "國際",
"Independent": "獨立",
"Unattributed": "未註明來源",
"Outside party": "外部方",
"Ukraine–Russia": "烏克蘭–俄羅斯",
"Israel–Gaza–Lebanon": "以色列–加薩–黎巴嫩",
"Iran–Hormuz": "伊朗–荷莫茲",
"Yemen–Red Sea": "葉門–紅海",
"Sudan": "蘇丹",
"Sahel": "薩赫爾",
"Elsewhere": "其他地區",
"Strike": "打擊",
"Alert": "警報",
"Air defence": "防空",
"Ground fighting": "地面戰鬥",
"Casualties": "傷亡",
"Humanitarian": "人道",
"Disaster": "災害",
"Protest": "抗議",
"Political": "政治",
"No physical event": "無實際事件",
"Minor incident": "輕微事件",
"Significant": "重大",
"Major": "嚴重",
"age|now": "剛剛",
"{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": "新聞來源回應格式錯誤",
"{place} (placed by model)": "{place}(由模型定位)",
"English headline by the model": "模型產生的英文標題",
"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} 小時",
"Time window": "時間範圍",
"Conflicts": "衝突",
"Local": "本地",
"News sections": "新聞分區",
"Zoom in to see local news": "放大地圖以查看本地新聞",
"Hotspots": "熱點",
"Severity": "嚴重程度",
"Category": "類別",
"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": "載入更早的新聞",
"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": "退出駕駛艙以飛往新聞報導",
"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.": "點擊圖釘的放置位置。",
@@ -323,6 +407,7 @@
"LOADING": "加載中",
"DEGRADED": "降級",
"STALE": "過期",
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "備用",
"view band|STREET": "街道",
@@ -711,6 +796,7 @@
"loading...": "加載中…",
"KEY REQUIRED": "需要密鑰",
"LIVE · updated {age}": "實時 · 更新於 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時",
"Source snapshot time unavailable": "來源快照時間不可用",
"Live data unavailable": "實時數據不可用",
"Low-accuracy score < 2%": "低精度評分 < 2%",
@@ -1608,14 +1694,15 @@
"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": "快取 · 重新整理失敗",
"RESOLVING REGION": "正在解析區域",
"REGION UNAVAILABLE": "區域不可用",
"NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果",
"NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "雲量 {pct}%",
"CLOUD UNKNOWN": "雲量未知",
"{source} · LOCATION QUERY": "{source} · 位置查詢",
"{source} · CACHED {age}": "{source} · 快取 {age}",
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
@@ -1679,7 +1766,7 @@
"OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞",
"LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期",
"{source} · LAST {hours} H": "{source} · 過去 {hours} 小時",
"LOCAL INFO": "本地信息",
"PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置",
"TIME UNKNOWN": "時間未知",
@@ -2897,8 +2984,6 @@
"Take the exit {direction}": "從出口{direction}駛出",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置",
"{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置",
"Shared vessel is unavailable": "分享的船隻不可用",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
@@ -3127,6 +3212,57 @@
"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 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。",
"Economy": "經濟",
"News from {area}": "{area}新聞",
"Within {km} km of the view centre": "視圖中心 {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} 拒絕了金鑰",
"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 的文章並列",
"Worldwide": "全球",
"Shipwrecks": "沉船",
"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}",
"What happened: {value}": "經過:{value}",
"Depth: {m} m ({quality})": "深度:{m} 公尺({quality})",
"Depth: {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": "沉船資料無法使用",
"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。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。",
"All years": "所有年份",
"Period": "時段",
"From": "起",
"To": "迄",
"First year of the shipwreck period": "沉船時段的起始年份",
"Last year of the shipwreck period": "沉船時段的結束年份",
"SIGHTING": "目擊",
"Show {name} sighting": "顯示{name}目擊",
"seen {age} ago": "{age}前目擊",
@@ -3246,5 +3382,17 @@
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
"1 min/s": "1 分鐘/秒",
"Photo © {photographer}": "照片 © {photographer}"
"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} 的位置",
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
}
+177 -8
View File
@@ -1,6 +1,7 @@
{
"ar": [
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -43,10 +44,14 @@
"{direction} {angle}°",
"{direction} · {elevation}",
"{source} HTTP {status}",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"de": [
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -99,6 +104,7 @@
"House",
"Hybrid",
"Indie",
"International",
"Ironbow",
"Jazz",
"LIVE",
@@ -145,6 +151,7 @@
"Pin",
"Plan",
"Pop",
"Protest",
"Punk",
"RADAR · {units}",
"RADIO",
@@ -172,6 +179,7 @@
"STD",
"SW",
"SYNC",
"Sahel",
"San Francisco",
"Scanlines",
"Scope",
@@ -180,6 +188,7 @@
"Soul",
"Station",
"Station {id}",
"Sudan",
"T minus {seconds}",
"TAGS · {tags}",
"TANKER",
@@ -192,6 +201,7 @@
"Turboprop",
"UPSTREAM",
"Upstream HTTP {status}",
"Video",
"Vignette",
"WHOT/BHOT",
"WIND",
@@ -217,6 +227,7 @@
"view band|GLOBAL",
"{city} CCTV",
"{county} County",
"{region}, {country}",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
@@ -230,11 +241,15 @@
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"es": [
"1 min/s",
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -286,6 +301,7 @@
"Jazz",
"LOCAL",
"Landsat 8/9",
"Local",
"MIC",
"MIL",
"MINI",
@@ -329,6 +345,7 @@
"STARLINK ···",
"STARLINK ✕",
"STD",
"Sahel",
"San Francisco",
"Sector",
"Sonar",
@@ -362,10 +379,13 @@
"panel",
"view band|GLOBAL",
"voice|ERROR",
"{hours} h",
"{minutes} min",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{region}, {country}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
@@ -381,11 +401,15 @@
"Reptiles",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
"iNaturalist",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"fr": [
"1 min/s",
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"ALTITUDE",
"ALTITUDE · FT",
@@ -462,6 +486,7 @@
"LOCAL",
"Landsat 8/9",
"Landsat 8/9 via HLS",
"Local",
"Los Angeles",
"MIC",
"MIL",
@@ -497,6 +522,7 @@
"PROPAGATION",
"Paris",
"Pause",
"Photos",
"Plan",
"Pop",
"Punk",
@@ -523,6 +549,7 @@
"STATION",
"STATIONS",
"STD",
"Sahel",
"San Francisco",
"Satellites",
"Saturation",
@@ -567,17 +594,22 @@
"satellite",
"standard",
"station",
"via {source}",
"voice|OFF",
"{count} CONTACTS",
"{count} MSGS",
"{count} SOURCES",
"{count} messages",
"{count} sources",
"{hours} h",
"{minutes} min",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{n} cyclones",
"{n} satellites",
"{rate} msg/s",
"{region}, {country}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
@@ -592,6 +624,7 @@
"it": [
"1 min/s",
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AFRICA",
"AIS: --",
@@ -651,6 +684,7 @@
"Hip-Hop",
"House",
"Indie",
"Iran–Hormuz",
"Ironbow",
"Jazz",
"KTS",
@@ -709,11 +743,13 @@
"STD",
"STOP",
"SYNC",
"Sahel",
"San Francisco",
"Scope",
"Sentinel-2",
"Sonar",
"Soul",
"Sudan",
"TELECOM",
"TEMP",
"TIMEOUT",
@@ -732,6 +768,7 @@
"VIEWSHED ON",
"VOLUME",
"VS",
"Video",
"WHOT/BHOT",
"Washington DC",
"World",
@@ -757,10 +794,13 @@
"{count} MSGS",
"{count} feeds live",
"{count} scenes",
"{hours} h",
"{minutes} min",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{region}, {country}",
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
"{source} HTTP {status}",
"{speed} kn",
@@ -779,10 +819,14 @@
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist",
"SATELLITE"
"SATELLITE",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"ja": [
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -841,11 +885,15 @@
"ΔE",
"ΔN",
"≥ {value} MW",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"pt": [
"1 min/s",
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -901,6 +949,7 @@
"Landsat 8/9",
"Landsat 8/9 via HLS",
"Layout",
"Local",
"Los Angeles",
"MIC",
"MIL",
@@ -943,6 +992,7 @@
"STARLINK ✕",
"STATUS",
"STATUS · ",
"Sahel",
"Sentinel-2",
"Sentinel-2 via HLS",
"Sonar",
@@ -975,11 +1025,15 @@
"mini",
"mph",
"nominal",
"via {source}",
"view band|GLOBAL",
"{hours} h",
"{minutes} min",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{region}, {country}",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
@@ -992,9 +1046,13 @@
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
"iNaturalist",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"ru": [
"1900–1945",
"AIS: --",
"AIS: {name}",
"Acartia",
@@ -1018,14 +1076,19 @@
"TLE",
"VS",
"WHOT/BHOT",
"{region}, {country}",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗",
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
"{division}, {country}",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"uk": [
"1900–1945",
"AIS: --",
"AIS: {name}",
"Acartia",
@@ -1044,15 +1107,20 @@
"STARLINK ✕",
"TLE",
"WHOT/BHOT",
"{region}, {country}",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗",
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
"{division}, {country}",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"zh-CN": [
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -1123,6 +1191,7 @@
],
"zh-Hant": [
"1090 MHz",
"1900–1945",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
@@ -1190,5 +1259,105 @@
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
],
"he": [
"{speed} kn",
"GEO",
"SAR",
"EARTH WATCH",
"CRT",
"NVG",
"FLIR",
"AIS: --",
"REC",
"HDG {value}°",
"BRG {value}°",
"BRG —",
"CRS {value}°",
"FOV {value}°",
"CAL {value}",
"SRC {value}",
"▲ {frp} MW",
"≥ {value} MW",
"Intel Picture",
"Kinetic 30d",
"Border Crisis",
"Iran Picture",
"Iran Kinetic",
"Yemen FLOT",
"Latest Drop",
"1090 MHz",
"978 MHz UAT",
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
"V/S {rate} FPM",
"Sentinel-2",
"Landsat 8/9",
"Google Street View",
"GOOGLE STREET VIEW",
"AIS: {name}",
"{type} SPD: {speed} HDG: {heading}",
"POS: {time}Z",
"{source} HTTP {status}",
"NNE",
"NE",
"ENE",
"ESE",
"SE",
"SSE",
"SSW",
"SW",
"WSW",
"WNW",
"NW",
"NNW",
"WHOT/BHOT",
"HDG",
"FOV",
"HGT",
"ΔN",
"ΔE",
"CAL · {badge}",
"DEST ---°",
"SQUAWK {code} · {status}",
"DEST {bearing}",
"CCTV",
"S2 GhostMaps",
"Street Level",
"STREET LEVEL",
"km/h",
"mph",
"hPa",
"kt",
"kts",
"ft",
"km",
"min",
"dB",
"KTS",
"FT",
"FLT",
"MIL",
"WX",
"MSG/S",
"FM MHZ",
"Acartia",
"CC BY 4.0",
"Salish Sea",
"iNaturalist",
"Be Whale Wise",
"MIC",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"{range} NM",
"NORAD {id}",
"EPOCH {time}",
"TLE",
"{value} KM",
"{value} KM/S",
"Ironbow",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
]
}
+20
View File
@@ -89,6 +89,26 @@ export const KEY_SETUP_KEYS = Object.freeze([
envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'liveuamap',
title: 'LIVEUAMAP', // i18n-ignore: provider product name
unlocks: msg(
'Precise conflict points in the News layer, beside GDELT’s articles',
),
getUrl: 'https://liveuamap.com/promo/api',
envVars: Object.freeze(['LIVEUAMAP_API_KEY']),
tier: 'metered',
}),
Object.freeze({
id: 'worldnewsapi',
title: 'WORLD NEWS API', // i18n-ignore: provider product name
unlocks: msg(
'Local in the News layer: the day’s news from the outlets of the country in view',
),
getUrl: 'https://worldnewsapi.com/console/',
envVars: Object.freeze(['WORLDNEWSAPI_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'tomtom',
title: 'TOMTOM', // i18n-ignore: provider product name
+1 -1
View File
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
return (
items.find(
(item) =>
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
) || subject
);
}
+5 -4
View File
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item, index) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
const targetId = item.icao24 || item.id || item.mmsi;
const distance = presentAwarenessDistance(item.distanceM);
if (!targetId)
return {
@@ -198,9 +198,10 @@ export function createPanel({ state: layerState, parts }) {
width: photo.width ?? null,
height: photo.height ?? null,
link: photo.link,
credit: t('Photo © {photographer}', {
photographer: photo.photographer,
}),
// A photo whose answer names no photographer shows no credit line.
credit: photo.photographer
? t('Photo © {photographer}', { photographer: photo.photographer })
: '',
};
}
+11
View File
@@ -87,6 +87,17 @@ test('the Contacts view carries the tracked flight’s photo with its credit', (
});
});
test('the Contacts view shows an uncredited photo with no credit line', () => {
const view = photoView(FLIGHT, {
layerId: 'flights',
id: 'abc123',
photo: { ...PHOTO, photographer: '' },
});
assert.equal(view.photo.src, PHOTO.src);
assert.equal(view.photo.link, PHOTO.link);
assert.equal(view.photo.credit, '');
});
test('the Contacts view has no photo without one, or with another subject’s', () => {
assert.equal(photoView(FLIGHT, null).photo, null);
assert.equal(
+2 -2
View File
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'id'),
);
const coverage = installationsState.stats?.coverage;
const installationRadius =
@@ -519,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
id: record.properties?.vesselId || record.id,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
+8
View File
@@ -3,6 +3,10 @@ import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js';
/** A time a Date can print: epoch ms within JavaScript's date range. */
const isSnapshotEnd = (value) =>
Number.isFinite(value) && Math.abs(value) <= 8_640_000_000_000_000;
export function createIngestion({
layerState,
services,
@@ -32,6 +36,7 @@ export function createIngestion({
return;
if (payload.keyRequired) {
layerState._keyRequired = true;
layerState._at = null;
layerState._error = null;
layerState._stale = false;
return;
@@ -39,6 +44,9 @@ export function createIngestion({
layerState._keyRequired = false;
layerState._error = null;
layerState._stale = Boolean(payload?.stale);
// A snapshot of the 24 hours to a set time names it; one of the
// trailing 24 hours to now does not.
layerState._at = isSnapshotEnd(payload?.at) ? payload.at : null;
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._allFires = adaptFirmsRecords(payload?.fires);
+1
View File
@@ -127,6 +127,7 @@ export function createLifecycle({
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._at = null;
layerState._keyRequired = false;
layerState._stale = false;
layerState._error = null;
+14 -3
View File
@@ -8,7 +8,7 @@ import {
rampTrackGradient,
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
@@ -190,8 +190,9 @@ export function createQueries({
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
* served past-TTL cache, 'KEY REQUIRED' keyless.
* 'LIVE · updated Xm ago' fresh, '24 h to MM-DD HH:MM UTC' for a
* snapshot that ends at a set time (`at`), 'STALE · cached Xh' when the
* proxy served past-TTL cache, 'KEY REQUIRED' keyless.
*/
getStats() {
const now = Date.now();
@@ -213,6 +214,11 @@ export function createQueries({
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (Number.isFinite(layerState._at)) {
loadingLabel = t('24 h to {day} {time} UTC', {
day: formatUtcDayMonth(layerState._at),
time: new Date(layerState._at).toISOString().slice(11, 16),
});
} else if (layerState._lastUpdate) {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
@@ -226,6 +232,11 @@ export function createQueries({
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
// The end of the 24 hours shown when the snapshot ends at a set time;
// null for the trailing 24 hours to now. Such a snapshot is not live,
// so the row names the provider without the catalog's "LIVE".
at: Number.isFinite(layerState._at) ? layerState._at : null,
...(Number.isFinite(layerState._at) ? { source: 'NASA FIRMS' } : {}),
// The machine-readable half of the keyless state, ahead of the human
// strings below: without it "no key configured" is indistinguishable
// from a broken feed, and the row reads as a fault instead of a step
+2
View File
@@ -77,6 +77,8 @@ export function createFirmsState({ services, config }) {
layerState._lastUpdate = null;
layerState._at = null;
layerState._currentLodId = null;
layerState._currentLodIndex = -1;
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js';
import { createQueries } from './queries.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const at = Date.UTC(2026, 9, 21, 15);
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - 120000,
_at: at,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, '24 h to 10-21 15:00 UTC');
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',
);
layerState._at = null;
setLocale('en');
assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago');
assert.equal(methods.getStats().at, null);
assert.equal(methods.getStats().source, undefined);
} finally {
setLocale(previousLocale);
}
});
+41
View File
@@ -0,0 +1,41 @@
/**
* What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js).
*/
export function createClockFollow({ flightState, parts }) {
/**
* Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses, floors and the tracked
* trail belong to the time the clock left.
*/
function resetMotion() {
flightState.records.reset();
flightState._positionHistory.clear();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
flightState._displayFloorState.clear();
flightState._drReconcileValid = false;
flightState._cachedDRFrame = -1;
flightState._cachedDRPosition = null;
parts.tracking._clearTrail();
flightState._lastCamPoseSig = '';
}
/** Drop every contact: the time has none to show, or the clock went live. */
function clear() {
if (flightState._trackedIcao)
parts.tracking._clearTracking(false, { evicted: true });
resetMotion();
for (const [icao24, billboard] of flightState._billboards) {
flightState._billboardCollection?.remove(billboard);
parts.rendering._releaseModel(icao24);
}
flightState._billboards.clear();
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
flightState.feed._count = 0;
flightState.feed._lastUpdate = null;
}
return { resetMotion, clear };
}
+230
View File
@@ -0,0 +1,230 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FlightRecords } from './records.js';
import { createIngestion } from './ingestion.js';
import { createClockFollow } from './clockFollow.js';
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
// Flights follow the world clock only while their source declares a timeline
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
const T = 1_800_000_000_000;
const following = { timeline: { active: () => true } };
const live = { timeline: { active: () => false } };
function records(now = () => T) {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
now,
});
}
const view = () => ({
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
const observation = (fields = {}) => ({
id: 'abc123',
reference: 'abc123',
longitude: -77,
latitude: 39,
callsign: 'TEST1',
baroAltitudeM: 1000,
onGround: false,
speedMps: 120,
courseDeg: 90,
positionTimeMs: T - 5_000,
...fields,
});
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs(following), 0);
assert.equal(followsClock({}), false);
assert.equal(followsClock(following), true);
});
test('civil records judge absence and receipt at the layer clock', () => {
let now = T;
const store = records(() => now);
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
assert.equal(meta.observedReceiptMs, T);
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
assert.equal(
store.absence('abc123', { complete: true, tracked: true }),
'stale',
);
assert.equal(
store.absence('abc123', {
complete: true,
tracked: true,
nowMs: T + 11 * 60_000,
}),
'remove',
'a given time judges retention',
);
store.receive(observation(), view());
now = T + 11 * 60_000;
assert.equal(
store.absence('abc123', { complete: true, tracked: true }),
'remove',
'the clock judges retention when no time is given',
);
});
test('a records reset forgets where airframes were and keeps what they are', () => {
const store = records();
const first = store.receive(observation(), view());
first.meta.typeCode = 'B738';
first.meta.registration = 'N123';
first.meta.photo = { src: 'photo.jpg' };
store.absence('abc123', { complete: true });
store.beginSnapshot(T);
store.reset();
assert.equal(store.data.size, 0);
assert.equal(store.missingPolls.size, 0);
assert.equal(store.lastSnapshotMs, null);
const again = store.receive(
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
view(),
);
assert.equal(again.prevMeta, undefined, 'no motion carries across');
assert.equal(again.groundFlipped, false);
assert.equal(again.meta.typeCode, 'B738');
assert.equal(again.meta.registration, 'N123');
assert.equal(again.meta.photo.src, 'photo.jpg');
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
store.forget('abc123');
assert.equal(store.airframes.size, 0);
});
function ingestion(source, { follows = false } = {}) {
const feed = {
_source: source,
_activeUpdateControllers: new Set(),
_trackingRefreshEpoch: 0,
_lastSource: 'Fixture',
_retryAt: 0,
};
const applied = [];
const state = { follows };
const { methods } = createIngestion({
feed,
getQuery: () => ({ latitude: 39 }),
applySnapshot: (snapshot, _viewer, options) => {
applied.push({ snapshot, options });
return { count: 1, ids: new Set() };
},
setSourceLabel: () => {},
applyPendingTrackingRestore: () => {},
followsClock: () => state.follows,
});
return { feed, applied, state, methods };
}
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
const queries = [];
const probe = ingestion({
async getSnapshot(query) {
queries.push(query);
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
},
});
await probe.methods.update(null);
assert.deepEqual(queries[0], { latitude: 39 });
assert.equal(probe.applied[0].options, undefined);
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
assert.deepEqual(probe.applied[1].options, { replace: true });
assert.equal(probe.feed._lastUpdate, T - 60_000);
});
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
let release;
const probe = ingestion({
getSnapshot: () =>
new Promise((resolve) => {
release = resolve;
}),
});
const out = probe.methods.update(null);
probe.state.follows = true;
release({ records: [], observedAtMs: T, freshness: 'current' });
await out;
assert.equal(probe.applied.length, 0);
await probe.methods.update(null);
assert.equal(probe.applied.length, 0, 'no poll while following');
});
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
const probe = ingestion({
async getSnapshot() {
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
},
});
assert.equal(await probe.methods.loadAt(T), false);
assert.equal(probe.feed._retryAt, 0);
assert.equal(probe.feed._lastError, 'Live data unavailable');
});
test('a jump forgets how contacts moved; clearing drops every contact', () => {
const calls = [];
const flightState = {
records: records(),
_positionHistory: new Map([['abc123', []]]),
_displayCourse: new Map([['abc123', 90]]),
_groundSnap: { clear: () => calls.push('ground') },
_displayFloorState: new Map([['abc123', {}]]),
_drReconcileValid: true,
_cachedDRFrame: 7,
_billboards: new Map([['abc123', { id: 'abc123' }]]),
_billboardCollection: { remove: () => calls.push('remove') },
_cullPositions: new Map([['abc123', null]]),
_detectionObjects: new Map([['abc123', {}]]),
_trackedIcao: 'abc123',
feed: { _count: 1, _lastUpdate: T },
};
const parts = {
tracking: {
_clearTrail: () => calls.push('trail'),
_clearTracking: (_skip, { evicted }) => {
calls.push(`untrack:${evicted}`);
flightState._trackedIcao = null;
},
},
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
};
const follow = createClockFollow({ flightState, parts });
flightState.records.receive(observation(), view());
follow.resetMotion();
assert.equal(flightState.records.data.size, 0);
assert.equal(flightState._positionHistory.size, 0);
assert.equal(flightState._displayCourse.size, 0);
assert.equal(flightState._displayFloorState.size, 0);
assert.equal(flightState._drReconcileValid, false);
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
follow.clear();
assert.equal(flightState._billboards.size, 0);
assert.equal(flightState._detectionObjects.size, 0);
assert.equal(flightState.feed._count, 0);
assert.equal(flightState.feed._lastUpdate, null);
assert.ok(calls.includes('untrack:true'));
assert.ok(calls.includes('model:abc123'));
});
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
const probe = ingestion({
async getSnapshot() {
throw new DOMException('Superseded', 'AbortError');
},
});
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
assert.equal(probe.feed._lastError, undefined);
assert.equal(probe.feed._backoff, undefined);
assert.equal(probe.feed._retryAt, 0);
});
+22 -1
View File
@@ -10,6 +10,9 @@ import { createLifecycle } from './lifecycle.js';
import { createEvidence } from './evidence.js';
import { createTesting } from './testing.js';
import { createQueries } from './queries.js';
import { createClockFollow } from './clockFollow.js';
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
import { worldClock } from '../../time/worldClock.js';
/** Compose one civil-flight layer with application-owned scene services. */
export function createCivilFlightLayer({
source,
@@ -29,6 +32,7 @@ export function createCivilFlightLayer({
parts.evidence = createEvidence(context);
parts.testing = createTesting(context);
parts.queries = createQueries(context);
parts.clockFollow = createClockFollow(context);
const applySnapshot = createFlightSnapshotRenderer({
flightState,
records: flightState.records,
@@ -45,7 +49,11 @@ export function createCivilFlightLayer({
feed: flightState.feed,
getQuery: (viewer) =>
parts.controller._flightQuery(viewer || flightState._viewer),
applySnapshot,
applySnapshot: (snapshot, viewer, options) => {
if (options?.replace) parts.clockFollow.resetMotion();
return applySnapshot(snapshot, viewer, options);
},
followsClock: () => flightState.timeline.active(),
setSourceLabel: (source) => {
layer.source = source;
},
@@ -53,6 +61,19 @@ export function createCivilFlightLayer({
parts.tracking._applyPendingTrackingRestore(),
});
// Contacts follow the world clock while the source declares a timeline.
flightState.timeline = createSnapshotTimeline({
id: 'flights',
clock: worldClock,
source: () => flightState.feed._source,
load: (atMs, { signal, replace }) =>
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
clear: () => parts.clockFollow.clear(),
resumeLive: () => {
layer.update(flightState._viewer).catch(() => {});
},
});
Object.assign(
layer,
parts.queries.methods,
+101 -67
View File
@@ -11,85 +11,119 @@ export function createIngestion({
applySnapshot,
setSourceLabel,
applyPendingTrackingRestore,
followsClock = () => false,
}) {
const methods = {
async update(viewer, { signal = null } = {}) {
const nowMs = Date.now();
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
// Following the world clock, the layer shows the snapshots loadAt
// applies; polling the live feed would put the live edge among them.
if (followsClock()) return;
await acquire(viewer, { signal });
},
/**
* Apply the source's snapshot at world time `atMs` (its timeline says it
* can answer one), in place of every contact when `replace`. Resolves
* whether it was applied.
* @param {number} atMs
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
* @returns {Promise<boolean>}
*/
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
// Whatever polls or loads for another time no longer applies.
if (replace)
for (const controller of feed._activeUpdateControllers)
controller.abort();
return acquire(viewer, { signal, atMs, replace });
},
};
async function acquire(viewer, { signal = null, atMs, replace = false }) {
const nowMs = Date.now();
const timed = atMs !== undefined;
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
feed._lastTrackingRefreshOutcome = {
epoch: trackingRefreshEpoch,
status: 'source-unavailable',
ids: new Set(),
source: feed._lastSource,
coverage: feed._lastCoverage,
};
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
feed._backoff = true;
return;
}
const resourceController = new AbortController();
feed._activeUpdateControllers.add(resourceController);
const updateSignal = signal
? AbortSignal.any([signal, resourceController.signal])
: resourceController.signal;
try {
updateSignal.throwIfAborted();
const query = timed ? { ...getQuery(viewer), atMs } : getQuery(viewer);
const snapshot = await feed._source.getSnapshot(query, {
signal: updateSignal,
});
updateSignal.throwIfAborted();
// The clock left the live edge while this poll was out.
if (!timed && followsClock()) return;
feed._lastStatus = snapshot.status ?? 200;
const sourceEpochMs = snapshot.observedAtMs;
const sourceAgeMs = snapshot.ageMs;
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
feed._backoff = sourceStale;
feed._retryAt = 0;
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
feed._lastError = sourceStale
? sourceAgeMs == null
? msg('Source snapshot time unavailable')
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
feed._lastSource = snapshot.source;
feed._lastCoverage = snapshot.coverage;
setSourceLabel(feed._lastSource);
const accepted = timed
? applySnapshot(snapshot, viewer, { replace })
: applySnapshot(snapshot, viewer);
feed._count = accepted.count;
// Freshness belongs to the source snapshot, not the moment this browser
// received a cached 200 response.
feed._lastUpdate = sourceEpochMs;
feed._lastTrackingRefreshOutcome = {
epoch: trackingRefreshEpoch,
status: 'source-unavailable',
ids: new Set(),
status: 'accepted',
ids: accepted.ids,
source: feed._lastSource,
coverage: feed._lastCoverage,
};
if (feed._retryAt && nowMs < feed._retryAt) {
feed._backoff = true;
return;
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
applyPendingTrackingRestore();
return true;
} catch (e) {
if (updateSignal.aborted || e?.name === 'AbortError') {
throw new DOMException('Flights update aborted', 'AbortError');
}
const resourceController = new AbortController();
feed._activeUpdateControllers.add(resourceController);
const updateSignal = signal
? AbortSignal.any([signal, resourceController.signal])
: resourceController.signal;
try {
updateSignal.throwIfAborted();
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
signal: updateSignal,
});
updateSignal.throwIfAborted();
feed._lastStatus = snapshot.status ?? 200;
const sourceEpochMs = snapshot.observedAtMs;
const sourceAgeMs = snapshot.ageMs;
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
feed._backoff = sourceStale;
feed._retryAt = 0;
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
feed._lastError = sourceStale
? sourceAgeMs == null
? msg('Source snapshot time unavailable')
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
feed._lastSource = snapshot.source;
feed._lastCoverage = snapshot.coverage;
setSourceLabel(feed._lastSource);
const accepted = applySnapshot(snapshot, viewer);
feed._count = accepted.count;
// Freshness belongs to the source snapshot, not the moment this browser
// received a cached 200 response.
feed._lastUpdate = sourceEpochMs;
feed._lastTrackingRefreshOutcome = {
epoch: trackingRefreshEpoch,
status: 'accepted',
ids: accepted.ids,
source: feed._lastSource,
coverage: feed._lastCoverage,
};
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
applyPendingTrackingRestore();
} catch (e) {
if (updateSignal.aborted || e?.name === 'AbortError') {
throw new DOMException('Flights update aborted', 'AbortError');
}
console.warn('[Data:Flights] Fetch error:', e);
feed._backoff = true;
console.warn('[Data:Flights] Fetch error:', e);
feed._backoff = true;
// A snapshot at a time is asked again by the clock's follower.
if (!timed)
feed._retryAt =
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
feed._lastStatus = e?.status ?? null;
if (e?.source) {
feed._lastSource = e.source;
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
feed._lastStatus = e?.status ?? null;
if (e?.source) {
feed._lastSource = e.source;
setSourceLabel(feed._lastSource);
}
},
};
feed._lastError =
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
return false;
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
}
return { methods };
}
+3
View File
@@ -190,6 +190,7 @@ export function createLifecycle({
);
}
restoreSpriteOrderOnEnable('flights', viewer);
flightState.timeline?.start();
},
/**
@@ -198,6 +199,7 @@ export function createLifecycle({
* @param {Cesium.Viewer} viewer
*/
disable(viewer) {
flightState.timeline?.stop();
parts.controller._abortActiveUpdates();
parts.tracking._cancelPendingTrackingRestore();
if (flightState._billboardCollection)
@@ -240,6 +242,7 @@ export function createLifecycle({
* @param {Cesium.Viewer} viewer
*/
destroy(viewer) {
flightState.timeline?.stop();
flightState.lifetime.abort();
flightState._enrichActive = 0;
flightState._enrichLastDispatchMs = 0;
+95 -21
View File
@@ -5,11 +5,15 @@ import {
lerpAngleDeg,
speedRamp,
courseBetweenCartesians,
floorCoastedPosition,
staleCoastLimitSeconds,
staleCoastSecondsFrom,
staleHoldsForFix,
arcOffsetEnu,
courseSlewCapDps,
limitCourseStep,
corridorPathLatLon,
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
@@ -18,7 +22,6 @@ import {
} from '../../data/modelVisualAnchor.js';
import {
FOCUS_EVIDENCE_DEV,
RENDER_DELAY_SEC,
TRACKED_MODEL_MAX_PX,
TRACKED_MODEL_MIN_PX,
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
@@ -35,6 +38,8 @@ import {
DISPLAY_CORRIDOR_LOOKAHEAD_SEC,
DISPLAY_CORRIDOR_CELL_BUDGET,
DISPLAY_CORRIDOR_FAIR_SHARE,
displayDelayMs,
followsClock,
} from './policy.js';
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
@@ -174,7 +179,7 @@ export function createMotion({
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
julianAt(flightState.now(), flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
-displayDelayMs(flightState) / 1000,
flightState._scratchRenderTime,
);
@@ -248,30 +253,88 @@ export function createMotion({
);
}
// Newest POSITION is older than renderTime. OpenSky can still be receiving
// fresh contact/kinematic messages for that aircraft; freezing at a hard
// 60 s-after-position boundary produced the visible stop → catch-up → stop
// cadence. Coast through the latest real contact plus a bounded grace
// window, with an absolute cap so a stale cached feed cannot drift forever.
// Newest POSITION is older than renderTime. Coast on the last speed and
// heading for five minutes past it, then stop, dimmed as SIGNAL LOST.
// A contact last heard on or near the ground holds there instead, and a
// coast never goes below the ground.
const { coastSec } = _coastPlan(newest, info, elapsedSec);
_extrapolateFix(
newest,
info,
coastSec,
out,
(info && info.turnRateDps) || 0,
);
return coastSec > 0 ? floorCoastedPosition(out, cachedGroundFloor) : out;
}
/**
* How far a contact coasts past its newest fix at a display time
* `elapsedSec` after it: up to five minutes, or not at all for a contact
* that holds at a report on or near the ground. The hold decision is made once
* per report and kept on it.
* @param {{epochMs?: number, time: Cesium.JulianDate}} newest
* @param {object|undefined} info - The contact's record.
* @param {number} elapsedSec
* @returns {{coastSec: number, holds: boolean}}
*/
function _coastPlan(newest, info, elapsedSec) {
const coastLimitSec = staleCoastLimitSeconds({
// `epochMs` is captured once when the poll is normalized. Avoid allocating
// a Date per aircraft on every 12 Hz fleet tick.
// a Date per aircraft on every fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Permit one minute of contact grace but cap any cached-feed drift at
// five minutes. Source backoff is exposed separately as a STALE cue.
minimumSec: 60,
// A plane that stops reporting keeps flying for five minutes, whatever
// its contact time, then stops dimmed as SIGNAL LOST. A missed update
// shows STALE throughout.
minimumSec: 300,
maximumSec: 300,
});
return _extrapolateFix(
const holds = staleHoldsForFix(newest, {
onGround: info?.onGround,
cachedGroundFloor,
});
return {
coastSec: staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}),
holds,
};
}
/**
* Whether a contact has stopped where its coast ended (or held at its last
* report) with no newer report, and since when it has had none.
* @param {string} icao24
* @returns {{reportEpochMs: number, holds: boolean}|null} Null while a
* report is ahead of the display time or the contact is still coasting.
*/
function _signalLost(icao24) {
const newest = flightState._positionHistory.get(icao24)?.at(-1);
if (!newest) return null;
const newestMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
const elapsedSec =
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
if (!(elapsedSec > 0)) return null;
const { coastSec, holds } = _coastPlan(
newest,
info,
Math.min(elapsedSec, coastLimitSec),
out,
(info && info.turnRateDps) || 0,
flightState.records.data.get(icao24),
elapsedSec,
);
if (!(elapsedSec > coastSec)) return null;
return {
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: newestMs,
holds,
};
}
/**
@@ -286,9 +349,12 @@ export function createMotion({
*/
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0;
const speed = Math.min(
Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0,
COAST_MAX_SPEED_MPS,
);
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.true_track) || 0;
@@ -350,7 +416,7 @@ export function createMotion({
if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
-displayDelayMs(flightState) / 1000,
flightState._scratchWarmupTime,
);
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
@@ -434,9 +500,15 @@ export function createMotion({
}
const nowMs = Date.now();
// How far the contact may move between frames goes by the world's time,
// which plays faster than the wall clock when following it at a rate.
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
const info = flightState.records.data.get(icao24);
if (sameTrack) {
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
const dtSec = Math.max(
0.001,
(worldMs - flightState._drPrevWorldMs) / 1000,
);
const speed = (info && info.velocity) || 0;
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
const plausible = speed * dtSec * 4 + 25;
@@ -484,6 +556,7 @@ export function createMotion({
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
flightState._drPrevMs = nowMs;
flightState._drPrevWorldMs = worldMs;
flightState._drReconcileValid = true;
flightState._cachedDRPosition = display;
const focusSizePx = _trackedFocusSizePx(
@@ -1131,6 +1204,7 @@ export function createMotion({
return {
_refloorStaleGroundedContacts,
_deadReckon,
_signalLost,
_extrapolateFix,
_isTrackWarmingUp,
_trackedFocusSizePx,
+13 -1
View File
@@ -132,7 +132,7 @@ export const TRAIL_COLOR = '#00d4ff';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MAX_POINTS = 2000;
// ---------------------------------------------------------------------------
// Render-behind smoothing (PRD WS-C C2 — approved product decision):
@@ -147,6 +147,15 @@ export const TRAIL_MAX_POINTS = 400;
export const RENDER_DELAY_SEC = 30;
/** Whether the layer shows the world clock's time instead of the live feed:
* its source declares a timeline and the clock is away from live. */
export const followsClock = (state) => state.timeline?.active() === true;
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
* following the clock, whose snapshots are loaded ahead of the time. */
export const displayDelayMs = (state) =>
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
@@ -471,3 +480,6 @@ export const FLOOR_SEED_GRACE_MS = 90_000;
* reloads synchronously. */
export const IR_RELOAD_BATCH = 40;
/** Alpha of a contact that has missed an update or lost its signal: the stale fade, for the fleet and the tracked aircraft alike. */
export const STALE_ALPHA = 0.45;
+3
View File
@@ -14,6 +14,7 @@ import {
LANDED_SPEED_MAX_MPS,
FOCUS_EVIDENCE_DEV,
FLEET_DR_INTERVAL_MS,
followsClock,
} from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
@@ -986,6 +987,8 @@ export function createQueries({
retryInSec,
source: flightState.feed._lastSource,
coverage: flightState.feed._lastCoverage,
// Following the world clock, the snapshot's age is told from its time.
...(followsClock(flightState) && { atMs: flightState.now() }),
};
},
};
+4
View File
@@ -9,6 +9,10 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
export const MISSING_POLL_LIMIT = 3;
/** The tracked contact is kept this long after its newest fix: five minutes
* coasting, then five stopped as SIGNAL LOST. */
export const TRACKED_RETENTION_MS = 10 * 60_000;
/** Cooldown in milliseconds after a transient source error. */
export const ERROR_BACKOFF_INTERVAL = 20000;
+119 -20
View File
@@ -11,22 +11,67 @@ import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
TRACKED_RETENTION_MS,
} from './recordPolicy.js';
/** Source-independent aircraft metadata and bounded missing-poll retention. */
export class FlightRecords {
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
constructor({
geoidHeight,
cachedGroundFloor,
floorAltitudeM,
now = () => Date.now(),
}) {
this.services = {
geoidHeight,
cachedGroundFloor,
floorAltitudeM,
};
/** The time records are judged at: the layer's clock. */
this.now = now;
this.data = new Map();
// Facts about an airframe, not a moment, kept across a reset.
this.airframes = new Map();
this.missingPolls = new Map();
// Contacts whose last poll re-sent a fix no newer than the one before.
this.fixRepeated = new Set();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
this.geoidNCache = new Map();
this.geoidReady = false;
}
/**
* Start reading a snapshot taken at `observedAtMs`. A fix re-sent in a
* newer snapshot marks its contact as not updated; the same snapshot read
* again does not.
* @param {number} observedAtMs
*/
beginSnapshot(observedAtMs) {
this.snapshotAdvanced =
!Number.isFinite(observedAtMs) ||
!Number.isFinite(this.lastSnapshotMs) ||
observedAtMs > this.lastSnapshotMs;
if (Number.isFinite(observedAtMs))
this.lastSnapshotMs = Math.max(
this.lastSnapshotMs ?? -Infinity,
observedAtMs,
);
}
/** Note a received fix: the newest fix time, and whether it advanced. */
_noteFix(id, prevMeta, fixEpochMs) {
const prevFixMs = prevMeta?.newestFixMs;
// A fix counts as new only when it moves on by a second: feeds that
// derive fix time from receipt (readsb `now − seen_pos`, 0.1 s) jitter.
if (!Number.isFinite(prevFixMs) || fixEpochMs >= prevFixMs + 1000) {
this.fixRepeated.delete(id);
return fixEpochMs;
}
if (this.snapshotAdvanced) this.fixRepeated.add(id);
return prevFixMs;
}
receive(
observation,
{ viewerLatDeg, viewerLonDeg, trackedId, floorWarmPoints },
@@ -54,6 +99,9 @@ export class FlightRecords {
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
const prevMeta = this.data.get(icao24);
// The airframe's facts: this record's, or those kept across a reset.
const airframe = prevMeta ?? this.airframes.get(icao24);
this.airframes.delete(icao24);
// Grounded planes with no baro reading sit at 0 m, not the 10 km
// airborne default (a parked plane must never float).
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
@@ -158,6 +206,12 @@ export class FlightRecords {
// (2) otherwise the SAME default policy `alt` already uses, so the two
// never disagree on the genuine "no data yet" case (a never-reported
// aircraft has no prior render height, so it lands here unchanged).
// Neither altitude this poll nor a held render height: the height below
// is the airborne default, invented. The tracked trail keeps no point there.
const renderAltitudeInvented =
pickedAltM == null &&
!onGround &&
!Number.isFinite(prevMeta?.renderAltitudeM);
let renderAltitudeM;
if (pickedAltM != null) {
renderAltitudeM = pickedAltM;
@@ -202,11 +256,17 @@ export class FlightRecords {
// A route (and its airline) belongs to the callsign it was looked up for;
// a new callsign drops it, so an old flight's route never shows under it.
const routeHeld =
Boolean(prevMeta?.routeCallsign) &&
prevMeta.routeCallsign === nextCallsign.toUpperCase();
Boolean(airframe?.routeCallsign) &&
airframe.routeCallsign === nextCallsign.toUpperCase();
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: this.now();
const meta = {
sourceReference: observation.reference,
observedReceiptMs: Date.now(),
observedReceiptMs: this.now(),
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
callsign: nextCallsign,
altitude: alt,
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
@@ -215,6 +275,7 @@ export class FlightRecords {
// still read `altitude`/baro).
geoAltitudeM,
renderAltitudeM,
renderAltitudeInvented,
onGround,
// Round 7: sticky airborne history — the landed fast-cull only
// applies to contacts that actually flew this session.
@@ -224,7 +285,7 @@ export class FlightRecords {
category: cat,
// An adsbdb-enriched type code outranks the coarse OpenSky category.
klass: classifyAircraft({
typeCode: prevMeta?.typeCode ?? null,
typeCode: airframe?.typeCode ?? null,
category: cat,
}),
turnRateDps: prevMeta?.turnRateDps || 0,
@@ -259,14 +320,14 @@ export class FlightRecords {
null,
),
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
typeCode: prevMeta?.typeCode ?? null,
typeName: prevMeta?.typeName ?? null,
registration: prevMeta?.registration ?? null,
airline: routeHeld ? (prevMeta.airline ?? null) : null,
route: routeHeld ? (prevMeta.route ?? null) : null,
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
typeCode: airframe?.typeCode ?? null,
typeName: airframe?.typeName ?? null,
registration: airframe?.registration ?? null,
airline: routeHeld ? (airframe.airline ?? null) : null,
route: routeHeld ? (airframe.route ?? null) : null,
routeCallsign: routeHeld ? airframe.routeCallsign : null,
// Planespotters photo of the tracked airframe (Core asks it directly).
photo: prevMeta?.photo ?? null,
photo: airframe?.photo ?? null,
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
// coords, pre-dead-reckon) — kept distinct from the continuously
// dead-reckoned billboard position for any consumer that needs the
@@ -276,24 +337,36 @@ export class FlightRecords {
};
this.data.set(icao24, meta);
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, retainIds, likelyLanded }) {
absence(
id,
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
) {
const misses = (this.missingPolls.get(id) || 0) + 1;
// The tracked contact has missed an update: it is stale from the first
// one, and kept (so the camera keeps its target) until ten minutes after
// its newest fix, whatever the snapshot says. With no fix time known it
// takes the ordinary limits below.
const meta = this.data.get(id);
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
if (tracked && Number.isFinite(fixMs)) {
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
this.missingPolls.set(id, misses);
return 'stale';
}
this.missingPolls.delete(id);
return 'remove';
}
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
const misses = (this.missingPolls.get(id) || 0) + 1;
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
this.missingPolls.set(id, misses);
@@ -303,9 +376,35 @@ export class FlightRecords {
return 'remove';
}
/**
* Forget every observation, as when the world clock jumps to another time:
* what an airframe is (its type, registration, route and photo) is kept for
* when it is seen again; where it was and when is not.
*/
reset() {
for (const [id, meta] of this.data) {
this.airframes.set(id, {
typeCode: meta.typeCode,
typeName: meta.typeName,
registration: meta.registration,
airline: meta.airline,
route: meta.route,
routeCallsign: meta.routeCallsign,
photo: meta.photo,
});
}
this.data.clear();
this.missingPolls.clear();
this.fixRepeated.clear();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
}
forget(id) {
this.data.delete(id);
this.airframes.delete(id);
this.missingPolls.delete(id);
this.fixRepeated.delete(id);
this.geoidNCache.delete(id);
}
}
+96
View File
@@ -164,3 +164,99 @@ test('a route belongs to its callsign: a new callsign drops it, a blank one keep
assert.equal(next.meta.airline, null);
assert.equal(next.meta.routeCallsign, null);
});
test('a contact with no altitude yet is marked as drawn at an invented height; a held one is not', () => {
const store = records();
const none = store.receive(
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
view(),
);
assert.equal(none.meta.renderAltitudeInvented, true);
const known = store.receive(
observation({ positionTimeMs: 1700000005000 }),
view(),
);
assert.equal(known.meta.renderAltitudeInvented, false);
const held = store.receive(
observation({
baroAltitudeM: null,
ellipsoidAltitudeM: null,
positionTimeMs: 1700000010000,
}),
view(),
);
assert.equal(held.meta.renderAltitudeInvented, false);
const parked = records().receive(
observation({ id: 'abc124', baroAltitudeM: null, onGround: true }),
view(),
);
assert.equal(parked.meta.renderAltitudeInvented, false);
});
test('the tracked contact is stale from its first missed update and kept until ten minutes after its newest fix', () => {
const realNow = Date.now;
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
let clock = fixMs + 5_000;
Date.now = () => clock;
try {
for (const snapshot of [
{ complete: true },
{ complete: true, retainIds: new Set(['abc123']) },
{ complete: false },
]) {
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
const absent = (tracked) =>
store.absence('abc123', { ...snapshot, likelyLanded: false, tracked });
clock = fixMs + 15_000;
assert.equal(absent(true), 'stale');
assert.equal(store.missingPolls.get('abc123'), 1);
// Kept long after the untracked limits, measured from the fix time.
clock = fixMs + 9 * 60_000;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000 - 1;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000;
assert.equal(absent(true), 'remove');
assert.equal(store.missingPolls.has('abc123'), false);
}
// Untracked contacts keep their own limits.
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
clock = fixMs + 15_000;
const untracked = (snapshot) =>
store.absence('abc123', { ...snapshot, likelyLanded: false });
assert.equal(untracked({ complete: false }), 'retain');
assert.deepEqual(
[1, 2, 3].map(() => untracked({ complete: true })),
['stale', 'stale', 'remove'],
);
} finally {
Date.now = realNow;
}
});
test('a repeated fix time marks the contact as not updated; removal is unchanged', () => {
const store = records();
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
store.beginSnapshot(fixMs + 2_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
// The next poll re-sends the same position.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
assert.equal(store.missingPolls.has('abc123'), false);
// The same snapshot read again is not a missed update.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
// A newer fix clears it.
store.beginSnapshot(fixMs + 22_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
store.beginSnapshot(fixMs + 32_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
store.forget('abc123');
assert.equal(store.fixRepeated.has('abc123'), false);
});
+27 -3
View File
@@ -33,12 +33,13 @@ import {
TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
STALE_ALPHA,
displayDelayMs,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -680,6 +681,19 @@ export function createRendering({
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
/** Fade the tracked 3D model like the billboard while the plane is stale. */
function _applyTrackedModelFade(model) {
const alpha = parts.tracking._trackedStaleCue?.(flightState._trackedIcao)
?.dimmed
? STALE_ALPHA
: 1;
if (model.color?.alpha === alpha) return;
model.color = (
flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN
).withAlpha(alpha);
}
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
@@ -823,6 +837,7 @@ export function createRendering({
parts.motion._trackedDisplayCourse(),
flightState._trackedModel.modelMatrix,
);
_applyTrackedModelFade(flightState._trackedModel);
if (!flightState._trackedModel.ready) return;
const spec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
@@ -849,6 +864,13 @@ export function createRendering({
}
function _fleetTick() {
// Every frame, shown or not: the tracked trail records where its icon is
// drawn and moves its body on (the head callback only reads the result),
// and the tracked label's stale cue follows the clock.
if (flightState._viewer) {
parts.tracking._trailTick?.();
parts.tracking._staleCueTick?.();
}
if (
!flightState._viewer ||
!flightState._billboardCollection ||
@@ -873,7 +895,7 @@ export function createRendering({
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
-displayDelayMs(flightState) / 1000,
flightState._scratchFleetRenderTime,
);
@@ -1081,7 +1103,9 @@ export function createRendering({
flightState._cockpitContactMode && !isCockpitNear
? 1
: _fleetBillboardScale(icao24, info?.klass),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseAlpha: flightState.records.missingPolls.get(icao24)
? STALE_ALPHA
: 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
+47 -12
View File
@@ -3,10 +3,12 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
staleHoldsForFix,
synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import { followsClock } from './policy.js';
/** Apply reconciled aircraft records to Cesium resources and follow state. */
export function createFlightSnapshotRenderer({
@@ -22,9 +24,13 @@ export function createFlightSnapshotRenderer({
queries,
}) {
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
const { warmGroundFloor } = groundFloor;
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
const { sampleMeshFloorCells } = meshFloor;
return function applySnapshot(snapshot, viewer) {
/**
* `replace`: the snapshot is the world at another time (the world clock
* jumped), so it stands in for every contact: one it lacks is gone at once.
*/
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
const currentIcaos = new Set();
const acceptedSnapshotIcaos = new Set();
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
@@ -39,6 +45,7 @@ export function createFlightSnapshotRenderer({
? Cesium.Math.toDegrees(viewerCarto.longitude)
: null;
const floorWarmPoints = [];
records.beginSnapshot(snapshot.observedAtMs);
// Classification and display policy consume source-independent observations.
refreshMilitaryRegistryIfStale();
@@ -112,6 +119,9 @@ export function createFlightSnapshotRenderer({
position: position.clone(),
velocity: meta.velocity,
track: meta.true_track,
// Drawn at an invented height: a trail seeded from history keeps no
// position for it.
inventedHeight: meta.renderAltitudeInvented === true,
});
if (history.length > POSITION_HISTORY_LIMIT) {
history.shift();
@@ -125,7 +135,10 @@ export function createFlightSnapshotRenderer({
// Round 2 (owner): ground traffic appends too — taxi history stays
// live after touchdown (grounded flights positions are already
// surface-clamped via the surfaceM chain, so the ground leg drapes).
if (isTracked) tracking._appendTrailFix(position.clone());
if (isTracked)
tracking._appendTrailFix(position.clone(), fixEpochMs, {
nullAltitude: meta.renderAltitudeInvented === true,
});
} else {
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
if (!modelOwnsGroundVisual) {
@@ -133,21 +146,31 @@ export function createFlightSnapshotRenderer({
}
// Apply fresh kinematics only from a forward synthetic fix. Mutating
// the historical fix reprojects the entire stale interval and snaps
// the rendered aircraft when course or speed changes late.
// the rendered aircraft when course or speed changes late. The stale
// rules apply: none for a contact that holds at its report, a coast
// capped from the real report, never below the ground. It is a motion
// aid, not a report, so it never joins the trail.
const kinematicsChanged =
newest.velocity !== meta.velocity || newest.track !== meta.true_track;
if (kinematicsChanged) {
const holds = staleHoldsForFix(newest, {
onGround: meta.onGround,
cachedGroundFloor,
});
if (kinematicsChanged && !holds) {
const synthetic = synthesizeForwardKinematicsFix(newest, {
epochMs: Date.now(),
// Following the clock, the snapshot's time is the moment it moves on from.
epochMs: followsClock(flightState)
? snapshot.observedAtMs
: Date.now(),
velocity: meta.velocity,
track: meta.true_track,
turnRateDps: meta.turnRateDps,
cachedGroundFloor,
});
if (synthetic) {
history.push(synthetic);
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
meta.turnRateDps = turnRateFromFixHistory(history);
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
}
}
}
@@ -248,11 +271,15 @@ export function createFlightSnapshotRenderer({
// feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of flightState._billboards) {
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
const absence = replace
? 'remove'
: records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
tracked: icao24 === flightState._trackedIcao,
nowMs: flightState.now?.(),
});
if (absence === 'retain') continue;
if (absence === 'stale') {
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
@@ -286,6 +313,14 @@ export function createFlightSnapshotRenderer({
flightState._displayFloorState.delete(icao24);
}
// The tracked contact at the new time: its trail starts there.
if (
replace &&
flightState._trackedIcao &&
currentIcaos.has(flightState._trackedIcao)
)
tracking._startTrail(flightState._trackedIcao);
// Fresh courses arrived — force a rotation pass on the next fleet tick
flightState._lastCamPoseSig = '';
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
// A poll that repeats a contact's last position with new speed or course
// must not move it past where the stale rules allow, and never adds a
// report to the tracked trail.
const ICAO = '00a002';
const SEC = 1000;
/** Any property is a no-op function unless given. */
function stub(overrides = {}) {
return new Proxy(overrides, {
get: (target, key) => (key in target ? target[key] : () => undefined),
});
}
function harness({ ground = () => null }) {
const appended = [];
const history = [];
const flightState = {
_trackedIcao: ICAO,
_trackedEntity: null,
_positionHistory: new Map([[ICAO, history]]),
_billboards: new Map([[ICAO, { position: new Cesium.Cartesian3() }]]),
_billboardCollection: stub(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: stub(),
_displayFloorState: new Map(),
_models: new Map(),
_viewer: null,
_cockpitContactMode: false,
records: { data: new Map() },
};
let meta = null;
let fixEpochMs = 0;
const records = stub({
receive: () => {
flightState.records.data.set(ICAO, meta);
return { prevMeta: meta, meta, groundFlipped: false, fixEpochMs };
},
absence: () => 'retain',
});
const apply = createFlightSnapshotRenderer({
flightState,
records,
militaryRegistry: stub({ isMilitaryIcao: () => false }),
groundFloor: stub({ cachedGroundFloor: ground }),
meshFloor: stub(),
rendering: stub({
_modelOwnsVisual: () => false,
_fleetBillboardColor: () => Cesium.Color.WHITE,
}),
tracking: stub({
_militaryLayerSuppresses: () => false,
_appendTrailFix: (position, epochMs) =>
appended.push({ position, epochMs }),
}),
motion: stub(),
enrichment: stub(),
queries: stub({ _likelyLanded: () => false }),
});
return {
history,
appended,
poll(nextMeta, epochMs) {
meta = nextMeta;
fixEpochMs = epochMs;
apply({ records: [{ id: ICAO }], complete: true }, null);
},
};
}
const lightAircraft = (velocity, track) => ({
rawLat: 38.1436,
rawLon: -122.5561,
renderAltitudeM: 10,
altitude: 40,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'light',
});
test('a low contact whose position repeats while its speed changes is not moved and adds nothing to the trail', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({ ground: () => -30 });
h.poll(lightAircraft(36.4, 63), t0);
const first = h.history[0].position;
for (const [i, velocity] of [30, 22, 14, 9].entries()) {
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(velocity, 63 + i * 20), t0);
}
assert.equal(h.history.length, 1, 'a synthetic fix moved a holding contact');
assert.equal(h.history[0].position, first);
assert.equal(h.appended.length, 1, 'only the real report joins the trail');
});
test('a cruising contact whose position repeats coasts no further than the stale cap, and the synthetic fix is not a report', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
const cruise = (velocity, track) => ({
rawLat: 36,
rawLon: -115,
renderAltitudeM: 9_000,
altitude: 9_000,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'jet',
});
h.poll(cruise(220, 90), t0);
const report = h.history[0].position;
t.mock.timers.tick(300 * SEC);
h.poll(cruise(200, 100), t0);
assert.equal(h.history.length, 2);
const moved = Cesium.Cartesian3.distance(report, h.history[1].position);
assert.ok(
moved <= 220 * 300 + 1,
`the synthetic fix coasted ${moved.toFixed(0)} m`,
);
assert.equal(h.appended.length, 1, 'the synthetic fix joined the trail');
});
test('a report drawn at an invented height says so on its history fix', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll({ ...lightAircraft(60, 90), renderAltitudeInvented: true }, t0);
h.poll({ ...lightAircraft(60, 90), rawLon: -122.55 }, t0 + 10 * SEC);
assert.equal(h.history[0].inventedHeight, true);
assert.equal(h.history[1].inventedHeight, false);
});
test('with the ground unknown, a low barometric altitude alone does not hold a contact', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll(lightAircraft(36.4, 63), t0);
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(30, 80), t0);
// It coasts under the stale cap instead.
assert.equal(h.history.length, 2);
assert.equal(h.history[0].staleHolds, false);
assert.equal(h.appended.length, 1);
});
+421
View File
@@ -0,0 +1,421 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import {
staleCoastSeconds,
staleHoldsAtLastReport,
} from '../../data/motionModel.js';
import { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
import { RENDER_DELAY_SEC } from './policy.js';
const SEC = 1000;
// Ellipsoidal ground at a sea-level airfield (geoid about -32 m).
const FIELD_GROUND_M = -30;
/** A motion module over one contact's history, with a fixed clock. */
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
const flightState = {
records: { data: new Map([['00a001', info]]) },
_positionHistory: new Map([['00a001', fixes]]),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
_scratchCoastCarto: new Cesium.Cartographic(),
now: () => nowMs,
};
const motion = createMotion({
flightState,
services: {
groundFloor: {
cachedGroundFloor: ground,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) ? Math.max(alt, g + 1.5) : alt,
},
camera: {},
focus: {},
},
parts: {},
});
return { flightState, motion };
}
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
const height = (position) => Cesium.Cartographic.fromCartesian(position).height;
test('a stale contact holds at its last report when it reported on the ground or low', () => {
assert.equal(staleHoldsAtLastReport({ onGround: true }), true);
// A light aircraft on final: -30 m barometric, its render height 1.5 m above the known ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: FIELD_GROUND_M + 1.5,
groundM: FIELD_GROUND_M,
}),
true,
);
// Ground unknown: a low barometric altitude alone does not hold it; it
// coasts under the cap.
assert.equal(
staleHoldsAtLastReport({ onGround: false, heightM: 120 }),
false,
);
// A high airfield: 1,600 m barometric but 60 m above its ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 1_660,
groundM: 1_600,
}),
true,
);
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 10_000,
groundM: 20,
}),
false,
);
});
test('a stale contact coasts up to its source limit at any speed, and a holding one not at all', () => {
const coast = (over) =>
staleCoastSeconds({
elapsedSec: 400,
coastLimitSec: 300,
speedMps: 230,
holds: false,
...over,
});
assert.equal(coast(), 300);
assert.equal(coast({ elapsedSec: 200 }), 200);
assert.equal(coast({ coastLimitSec: 60 }), 60);
assert.equal(coast({ speedMps: 1_000 }), 300);
assert.equal(coast({ holds: true }), 0);
assert.equal(coast({ elapsedSec: -5 }), 0);
});
test('a light aircraft on short final goes stale after a low, slow, descending report, the icon holds at that report, not below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 30 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 60 * SEC, 38.1436, -122.5561, FIELD_GROUND_M + 1.5, 36.4, 63),
];
const info = {
altitude: -30,
renderAltitudeM: FIELD_GROUND_M + 1.5,
rawLat: 38.1436,
rawLon: -122.5561,
velocity: 36.4,
true_track: 63,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 60 * SEC,
};
for (const afterSec of [5, 45, 90, 240]) {
const nowMs = t0 + 60 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: () => FIELD_GROUND_M,
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(
Cesium.Cartesian3.distance(drawn, fixes[2].position) < 0.01,
`${afterSec} s after the report the icon is at the report`,
);
assert.ok(height(drawn) >= FIELD_GROUND_M);
}
});
function cruiser(lastContactAfterSec) {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 40, -100, 10_000, 230, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 230, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 230,
true_track: 90,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + lastContactAfterSec * SEC,
};
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
return { at, d };
}
test('a cruising contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
// Contact kept arriving for four minutes after the last position.
const { at, d } = cruiser(240);
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
assert.ok(near(d(at(90)), 230 * 90));
assert.ok(near(d(at(200)), 230 * 200), 'still moving at 200 s');
assert.ok(near(d(at(300)), 230 * 300));
assert.ok(near(d(at(400)), 230 * 300), 'stopped at 300 s');
assert.ok(Cesium.Cartesian3.distance(at(400), at(600)) < 0.01);
});
test('a cruising contact that stops reporting coasts five minutes, then stops', () => {
const { at, d } = cruiser(0);
assert.ok(Math.abs(d(at(60)) - 230 * 60) < 120);
assert.ok(Math.abs(d(at(300)) - 230 * 300) < 600);
assert.ok(Cesium.Cartesian3.distance(at(301), at(420)) < 0.01);
});
test('a bad reported speed coasts no faster than the 1,500 kt continuity limit', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// 2,000 m/s (about 3,900 kt): a corrupt velocity, not a real aircraft.
const fixes = [
fix(t0, 40, -100, 10_000, 2_000, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 2_000, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 2_000,
true_track: 90,
onGround: false,
wasAirborne: true,
};
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
const limitMps = (TRAIL_CONTINUITY_MAX_SPEED_KT * 1852) / 3600;
const d = Cesium.Cartesian3.distance(drawn, fixes[1].position);
assert.ok(Math.abs(d - limitMps * 300) < 1_500, `${d.toFixed(0)} m`);
});
test('coasting never takes a contact below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -105.5, 3_400, 80, 270),
fix(t0 + 15 * SEC, 39.7, -105.514, 3_400, 80, 270),
];
const info = {
altitude: 3_400,
renderAltitudeM: 3_400,
rawLat: 39.7,
rawLon: -105.514,
velocity: 80,
true_track: 270,
onGround: false,
wasAirborne: true,
};
// 3,000 m under the report, rising to 3,600 m along the coast west.
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 60 * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => (lon < -105.52 ? 3_600 : 3_000),
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(height(drawn) >= 3_600, `height ${height(drawn)}`);
});
// ---------------------------------------------------------------------------
// The trail and head across a stale gap.
function services(trail) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: () => null,
floorAltitudeM: (alt) => alt,
},
};
}
function trailHarness({ fixes, nowMs, head }) {
const calls = [];
const trail = {
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
flightState._trackedIcao = '00a001';
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set('00a001', fixes);
let headPosition = head;
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => headPosition,
_trackedDisplayPosition: () => headPosition,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return {
tracking,
flightState,
drawn: () => (calls.at(-1) ?? []).flat(),
head: () => headEntity().polyline.positions.getValue(),
setClock: (ms) => {
clock = ms;
},
setHead: (position) => {
headPosition = position;
},
};
}
test('a holding contact draws no head, and the trail ends at the last report, never past the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1678, -122.527, 303, 45, 200),
fix(t0 + 30 * SEC, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 60 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 90 * SEC, 38.1436, -122.5561, 0, 36.4, 63),
];
const last = fixes.at(-1).position;
// The display time is long past the last report; the icon holds there.
const h = trailHarness({
fixes,
nowMs: t0 + 90 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC,
head: Cesium.Cartesian3.clone(last),
});
h.tracking._startTrail('00a001');
// Every report, including the last, is in the trail: the head no longer
// cuts from the one before it to the icon.
assert.equal(h.drawn().length, 4);
assert.equal(h.drawn().at(-1), h.flightState._trailFixes.at(-1).position);
assert.deepEqual(h.head(), []);
});
test('while the display is between reports, the trail stops at the report behind the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// Reports every 10 s; the icon is drawn 30 s behind, between the 2nd and 3rd.
const fixes = [0, 1, 2, 3, 4, 5].map((i) =>
fix(t0 + i * 10 * SEC, 40, -100 + i * 0.03, 10_000, 230, 90),
);
const nowMs = t0 + 25 * SEC + RENDER_DELAY_SEC * SEC;
const icon = Cesium.Cartesian3.fromDegrees(-100 + 0.075, 40, 10_000);
const h = trailHarness({ fixes, nowMs, head: icon });
h.tracking._startTrail('00a001');
// Reports at 0, 10 and 20 s are behind the icon (display time 25 s).
assert.equal(h.drawn().length, 3);
const head = h.head();
assert.equal(head.length, 2);
assert.ok(Cesium.Cartesian3.distance(head[0], fixes[2].position) < 0.01);
});
test('the hold-or-coast decision is made once per report: a floor that resolves mid-coast does not snap the icon back', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -104.6, 1_900, 70, 270),
fix(t0 + 15 * SEC, 39.7, -104.6123, 1_900, 70, 270),
];
const info = {
altitude: 1_950,
renderAltitudeM: 1_900,
rawLat: 39.7,
rawLon: -104.6123,
velocity: 70,
true_track: 270,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + 120 * SEC,
};
// No floor known when the contact goes stale: baro says well clear of the
// ground, so it coasts. Mid-coast the floor resolves 100 m under the report.
let ground = () => null;
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => ground(lat, lon),
});
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
const before = d(at(30));
assert.ok(Math.abs(before - 70 * 30) < 50, `coasted ${before.toFixed(0)} m`);
ground = () => 1_800;
const after = d(at(31));
assert.ok(
after > before,
`the icon went from ${before.toFixed(0)} m to ${after.toFixed(0)} m`,
);
});
+32 -2
View File
@@ -11,10 +11,14 @@ export function createFlightState({ source, services }) {
flightState.records = new FlightRecords({
...services.geoid,
...services.groundFloor,
now: () => flightState.now(),
});
flightState._cullPositions = new Map();
/** The time contacts are drawn behind by the render delay, in epoch ms. */
flightState.now = worldTimeFor('flights');
/** Follows the world clock while the source declares a timeline (set by the
* layer); see followsClock and displayDelayMs in policy.js. */
flightState.timeline = null;
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
@@ -195,9 +199,32 @@ export function createFlightState({ source, services }) {
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
/** @type {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean, breakBefore: boolean, drawn?: boolean}[]}
* Chronological tracked-aircraft trail (oldest first): reports, and
* (`drawn`) where the icon was drawn between them; `breakBefore` starts a
* new segment. */
flightState._trailPositions = [];
flightState._trailFixes = [];
/** @type {number} Body fixes drawn: those at or before the display time. */
flightState._trailBodyCount = 0;
/** @type {Cesium.Cartesian3|null} Where the tracked icon was last recorded into the trail. */
flightState._trailRecordPosition = null;
/** @type {number} Display time (epoch ms) of that recording. */
flightState._trailRecordMs = -Infinity;
/** @type {string|undefined} The stale cue last written into the tracked label (see `_staleCueTick`). */
flightState._trackedStaleCueText = undefined;
/** @type {{key: string, cue: {text: string, dimmed: boolean}}|null} This frame's tracked stale cue (see `_trackedStaleCue`). */
flightState._trackedStaleCueCache = null;
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
@@ -362,6 +389,9 @@ export function createFlightState({ source, services }) {
flightState._drPrevMs = 0;
/** World time of that frame: the wall clock live, the cursor following the clock. */
flightState._drPrevWorldMs = 0;
flightState._drReconcileValid = false;
/** @type {string|null} icao the reconciliation state currently belongs to */
+283 -92
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
@@ -21,12 +20,32 @@ import {
TRACKED_MODEL_MAX_LOAD_FAILS,
TRACKED_MODEL_RETRY_BACKOFF_MS,
TRAIL_MAX_POINTS,
RENDER_DELAY_SEC,
DR_CORRECTION_MS,
TRAIL_COLOR,
CYAN_TRANSPARENT,
STALE_ALPHA,
displayDelayMs,
followsClock,
} from './policy.js';
import {
drawTrailSegments,
historyFixEpochMs,
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
trailBackfillFixes,
trailBodyCount,
trailBodyCountOutsideModel,
trailBodySegments,
trailHeadStartFix,
trailRecordingBlocked,
TRAIL_RECORD_STEP_M,
TRAIL_RECORD_STEP_MS,
} from '../../data/trailContinuity.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const STALE_CYAN = Cesium.Color.CYAN.withAlpha(STALE_ALPHA);
export function createTracking({
flightState,
services,
@@ -333,18 +352,81 @@ export function createTracking({
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
* Put one report into the tracked aircraft's trail, in time order, and
* refresh the rendered trail. Caller passes an owned (cloned) Cartesian3. A
* report that breaks from its neighbour starts a new segment (see
* trailContinuity).
* @param {Cesium.Cartesian3} position - The report's position.
* @param {number} [epochMs] - The report's time; defaults to the tracked
* aircraft's newest position-history fix, which every caller has just pushed.
* @param {{nullAltitude?: boolean}} [options] - `nullAltitude`: the report
* had no altitude, so its position is not kept and the line breaks there.
* A report for a time the display has already passed replaces what was
* drawn after it (see `insertTrailFix`).
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(position);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS)
flightState._trailPositions.shift();
function _appendTrailFix(position, epochMs, { nullAltitude = false } = {}) {
const history = flightState._positionHistory.get(flightState._trackedIcao);
const timeMs = Number.isFinite(epochMs)
? epochMs
: historyFixEpochMs(history?.[history.length - 1]);
insertTrailFix(
flightState._trailFixes,
position,
timeMs,
TRAIL_MAX_POINTS,
{
nullAltitude,
displayMs: flightState.now() - displayDelayMs(flightState),
settleMs: DR_CORRECTION_MS,
},
);
_refreshTrailDisplay();
}
/**
* Per-frame trail work, run from the layer's preRender pass whether or not
* the head is visible: record where the tracked icon is drawn (at most once
* a second or every 150 m) and move the body on as the display time passes
* reports. The head callback only reads the result.
*/
function _trailTick() {
if (!flightState._trackedIcao || !flightState._trail) return;
const displayMs = flightState.now() - displayDelayMs(flightState);
let changed = false;
if (
flightState._trailFixes.length &&
!parts.motion._isTrackWarmingUp() &&
!trailRecordingBlocked(flightState._trailFixes, displayMs)
) {
const drawn = parts.motion._trackedDisplayPosition(
flightState._trackedIcao,
);
const last = flightState._trailRecordPosition;
if (
drawn &&
(!last ||
displayMs - flightState._trailRecordMs >= TRAIL_RECORD_STEP_MS ||
Cesium.Cartesian3.distance(last, drawn) >= TRAIL_RECORD_STEP_M)
) {
flightState._trailRecordMs = displayMs;
flightState._trailRecordPosition = Cesium.Cartesian3.clone(
drawn,
flightState._trailRecordPosition ?? undefined,
);
changed = recordTrailPoint(
flightState._trailFixes,
Cesium.Cartesian3.clone(drawn),
displayMs,
TRAIL_MAX_POINTS,
);
}
}
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
_refreshTrailDisplay();
}
/**
* Renders the trail with its head clamped to the render-behind display
* position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the
@@ -354,17 +436,33 @@ export function createTracking({
*/
function _refreshTrailDisplay() {
// The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest
// fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
// now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane.
// The cheap per-frame _trailHeadEntity segment bridges the last body point to the
// delayed dead-reckoned head, so the body primitive only rebuilds on a real fix
// (poll cadence), never at motion cadence.
// The trail BODY is the accumulated fixes at or before the delayed display time
// (now − RENDER_DELAY_SEC); newer fixes are AHEAD of the icon, so drawing them
// would push the trail in front of the plane. The cheap per-frame
// _trailHeadEntity segment bridges the last body point to the delayed
// dead-reckoned head, so the body rebuilds only on a real fix or when the
// display time passes one, never at motion cadence.
// A break lifts the pen: each segment is its own line, nothing between them.
if (!flightState._trail) return;
flightState._trail.setPositions(
flightState._trailPositions.length > 1
? flightState._trailPositions.slice(0, -1)
: flightState._trailPositions,
flightState._trailBodyCount = _trailBodyCountNow();
drawTrailSegments(
flightState._trail,
trailBodySegments(flightState._trailFixes, flightState._trailBodyCount),
);
}
/** Fixes at or before the time the aircraft is drawn (now − RENDER_DELAY_SEC),
* less the newest recorded points inside its 3D model. */
function _trailBodyCountNow() {
return trailBodyCountOutsideModel(
flightState._trailFixes,
trailBodyCount(
flightState._trailFixes,
flightState.now() - displayDelayMs(flightState),
),
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
);
}
@@ -377,21 +475,24 @@ export function createTracking({
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
// Seed every report. The body only draws those at or before the delayed
// display time (now − RENDER_DELAY_SEC); newer ones join it as the display
// passes them. A synthetic forward fix (it carries `reportEpochMs`) is a
// motion aid, never a report. A report drawn at an invented height keeps
// no position and breaks the line.
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position));
}
if (Number.isFinite(fix.reportEpochMs)) continue;
insertTrailFix(
flightState._trailFixes,
Cesium.Cartesian3.clone(fix.position),
historyFixEpochMs(fix),
TRAIL_MAX_POINTS,
{ nullAltitude: fix.inventedHeight === true },
);
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, {
@@ -412,14 +513,13 @@ export function createTracking({
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — which is ~now, AHEAD of the delayed icon —
// so the segment would draw IN FRONT of the plane. Likewise during warm-up the
// icon predates all real history, so there is no valid body point behind it.
// The head starts at the last DISPLAYED body point, the newest fix at or
// before the delayed display time; a fix ahead of the icon would draw the
// segment IN FRONT of the plane. During warm-up the icon predates all real
// history, so there is no valid body point behind it.
if (
!flightState._trackedIcao ||
flightState._trailPositions.length < 2 ||
!flightState._trailFixes.length ||
parts.motion._isTrackWarmingUp()
)
return [];
@@ -427,12 +527,19 @@ export function createTracking({
parts.motion._trackedTrailCached() ||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start =
flightState._trailPositions[
flightState._trailPositions.length - 2
];
// The last body point (a report, or where the icon was drawn a
// moment ago) → the icon. The body is moved on by _trailTick, never
// here: a callback must not add or remove entities.
// No head across a break: a body point more than 150 km away, or
// too long ago or too far to be one path, stays detached; none while
// the aircraft holds at its last point.
const last = trailHeadStartFix(
flightState._trailFixes,
head,
flightState.now(),
flightState._trailBodyCount,
);
if (!last) return [];
// On a contact that has not moved this segment runs from inside the
// model out to its own anchor — a line through the fuselage. The END
// never gives, so a moving trail still terminates on the tail; the
@@ -441,7 +548,7 @@ export function createTracking({
// See trailHeadStart. Read `head` in place and clone only on the draw
// path — a suppressed parked contact runs this every frame.
const from = trailHeadStart(
start,
last.position,
head,
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
@@ -481,15 +588,16 @@ export function createTracking({
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let path = null;
// Following the world clock, the track ends at the world's time.
const atMs = followsClock(flightState) ? flightState.now() : undefined;
try {
const signal = AbortSignal.any([
flightState.lifetime.signal,
AbortSignal.timeout(8000),
]);
const track = await flightState.feed._source.getTrack?.(
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
{
signal: AbortSignal.any([
flightState.lifetime.signal,
AbortSignal.timeout(8000),
]),
},
atMs === undefined ? { signal } : { signal, atMs },
);
path = track?.records ?? null;
} catch {
@@ -519,8 +627,15 @@ export function createTracking({
} = waypoint;
const time = observedAtMs / 1000;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (atMs !== undefined && !(observedAtMs <= atMs)) continue;
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAlt });
parsed.push({
lat,
lon,
baroAlt,
onGround: waypoint.onGround === true,
timeMs: observedAtMs,
});
}
if (!parsed.length) return;
@@ -543,27 +658,30 @@ export function createTracking({
)
return;
const older = [];
let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet
for (const { lat, lon, baroAlt } of parsed) {
const baroM = Number.isFinite(baroAlt)
? baroAlt + geoidHeight(lat, lon)
: null;
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// No baro + unresolved floor: hold the previous waypoint's altitude
// (continuity — never a dive/spike to a made-up height). Leading points
// with nothing to carry keep the old 10 km airborne default.
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(
flightState._trailPositions.length - TRAIL_MAX_POINTS,
);
}
// Every waypoint is kept, each leg its own segment. A ground waypoint is
// at altitude 0, floored at the ground. A waypoint with no altitude is a
// break on both sides: it sits on the ground where the floor is known and
// is skipped otherwise; a skipped point still breaks the line.
const backfill = trailBackfillFixes(
parsed.map(({ lat, lon, baroAlt, onGround, timeMs }) => {
const known = Number.isFinite(baroAlt) ? baroAlt : onGround ? 0 : null;
const altM = floorAltitudeM(
known == null ? null : known + geoidHeight(lat, lon),
cachedGroundFloor(lat, lon),
);
return {
position:
altM == null ? null : Cesium.Cartesian3.fromDegrees(lon, lat, altM),
timeMs,
nullAltitude: known == null,
};
}),
);
flightState._trailFixes = joinTrailBackfill(
backfill,
flightState._trailFixes,
TRAIL_MAX_POINTS,
);
_refreshTrailDisplay();
}
@@ -573,7 +691,9 @@ export function createTracking({
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
if (flightState._trail) flightState._trail.clear();
if (
flightState._trailHeadEntity &&
@@ -737,14 +857,90 @@ export function createTracking({
flightState._pendingTrackingRestore = null;
}
/**
* The tracked plane's stale cue and whether it is drawn with the stale fade.
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer
* report has come.
* - STALE, dimmed: it has missed an update and still coasts, or the whole
* feed is backing off.
* @param {string} icao24
* @returns {{text: string, dimmed: boolean}}
*/
function _trackedStaleCue(icao24) {
const records = flightState.records;
const misses = records.missingPolls.get(icao24) || 0;
const repeated = records.fixRepeated?.has(icao24) === true;
const backoff = flightState.feed._backoff === true;
// Computed once per frame for the label, the icon colour and the model
// fade; a poll between frames changes the key.
const frame = flightState._viewer?.scene?.frameState?.frameNumber;
const key = Number.isFinite(frame)
? `${frame}|${icao24}|${misses}|${repeated}|${backoff}`
: null;
const cached = flightState._trackedStaleCueCache;
if (key !== null && cached?.key === key) return cached.cue;
const cue = _computeTrackedStaleCue(
icao24,
misses > 0 || repeated,
backoff,
);
flightState._trackedStaleCueCache = key === null ? null : { key, cue };
return cue;
}
/** `_trackedStaleCue` without the per-frame cache. A repeated fix counts as missed. */
function _computeTrackedStaleCue(icao24, missed, backoff) {
const lost = parts.motion._signalLost(icao24);
if (lost && (missed || !lost.holds)) {
const sec = Math.max(
0,
Math.floor((flightState.now() - lost.reportEpochMs) / 1000),
);
const time =
sec < 60
? `${formatNumber(sec, { useGrouping: false })} ${unitLabel('s')}`
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
}
// A missed update or a stalled feed: no newer position is coming in.
if (missed || backoff) return { text: t('STALE'), dimmed: true };
return { text: '', dimmed: false };
}
/** The tracked billboard's colour: cyan, faded while stale, clear while the 3D model is the visual. */
function _trackedIconColor() {
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
return CYAN_TRANSPARENT;
return _trackedStaleCue(flightState._trackedIcao).dimmed
? STALE_CYAN
: Cesium.Color.CYAN;
}
/**
* Re-word the tracked label when its stale cue changes (STALE, SIGNAL LOST,
* each new second or minute of it). Run from the layer's per-frame pass.
*/
function _staleCueTick() {
const icao24 = flightState._trackedIcao;
if (!icao24 || !flightState._trackedEntity) return;
const text = _trackedStaleCue(icao24).text;
if (text === flightState._trackedStaleCueText) return;
flightState._trackedStaleCueText = text;
_updateTrackedLabelModel(icao24);
}
/** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" +
* "ORIG → DEST". The route line is gated by
* routePlausible so a wrong-leg adsbdb route is hidden, not displayed.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata), the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* velocity/altitude as live. */
* The first line carries the stale cue (`_trackedStaleCue`): STALE while
* the plane coasts on dead reckoning after a missed update, SIGNAL LOST
* once the coast has ended, so last-known velocity/altitude are never
* presented as live. */
function _trackedLabelText(icao24) {
const info = flightState.records.data.get(icao24);
@@ -765,10 +961,7 @@ export function createTracking({
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('STALE')
: '';
const stale = _trackedStaleCue(icao24).text;
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
// operator/type identity is exactly what the Easter egg is replacing.
@@ -967,15 +1160,9 @@ export function createTracking({
CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1,
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
() =>
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
? CYAN_TRANSPARENT
: Cesium.Color.CYAN,
false,
),
// Solid cyan (faded while stale) when the billboard is the visual (zoomed out, 3D off,
// or model still loading); transparent once the STANDALONE tracked model is actually up.
color: new Cesium.CallbackProperty(_trackedIconColor, false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
@@ -1257,6 +1444,7 @@ export function createTracking({
_trackedModelRegimeActive,
_syncTracked2dRotation,
_appendTrailFix,
_trailTick,
_refreshTrailDisplay,
_startTrail,
_backfillTrail,
@@ -1267,6 +1455,9 @@ export function createTracking({
_applyPendingTrackingRestore,
_cancelPendingTrackingRestore,
_trackedLabelText,
_trackedStaleCue,
_trackedIconColor,
_staleCueTick,
_updateTrackedLabelModel,
_syncTrackedBillboardImage,
_refreshTr3bContact,
+414
View File
@@ -0,0 +1,414 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_MAX_POINTS } from './policy.js';
import { trailHeadJoins, trailPairBreaks } from '../../data/trailContinuity.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const NM = 1852;
function services({ trails, floor = () => null } = {}) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: floor,
floorAltitudeM: (alt, ground) =>
Number.isFinite(alt)
? alt
: Number.isFinite(ground)
? ground + 1
: null,
},
};
}
/** A fake trail that records every segment list it is given. */
function recordingTrail() {
const calls = [];
return {
calls,
// What draws: a lone point is a segment with no line.
last: () =>
(calls[calls.length - 1] ?? []).filter((segment) => segment.length >= 2),
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {
calls.push([]);
},
destroy() {},
}),
};
}
function viewer() {
const added = [];
return {
added,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
}
/**
* A tracked aircraft whose live history holds `liveFixes` ({timeMs, lat, lon}),
* with a track endpoint answering `waypoints`.
*/
function trackedHarness({
liveFixes = [],
waypoints = null,
nowMs,
floor,
head = null,
} = {}) {
const trail = recordingTrail();
const svc = services({ trails: trail, floor });
const flightState = createFlightState({
services: svc,
source: {
label: 'Fixture aircraft',
async getTrack() {
if (!waypoints) throw new Error('404');
return { records: waypoints };
},
},
});
const v = viewer();
flightState._viewer = v;
flightState._trackedIcao = 'abc123';
flightState.now = () => nowMs;
flightState._positionHistory.set(
'abc123',
liveFixes.map((fix) => ({
time: Cesium.JulianDate.fromDate(new Date(fix.timeMs)),
epochMs: fix.timeMs,
position: Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 10_000),
})),
);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => head,
_trackedDisplayPosition: () => head,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
return { trail, tracking, flightState, viewer: v };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function waypoint(timeMs, lat, lon, baroAltitudeM = 10_000) {
return { observedAtMs: timeMs, latitude: lat, longitude: lon, baroAltitudeM };
}
/** `count` waypoints one minute apart flying east at ~420 kt from (lat, lon). */
function leg(startMs, lat, lon, count) {
return Array.from({ length: count }, (_, i) =>
waypoint(startMs + i * MINUTE, lat, lon + i * 0.12),
);
}
function headPositions(v) {
const entity = v.added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return entity.polyline.positions.getValue();
}
test('a pair 40 minutes and 300 km apart stays joined', () => {
assert.equal(trailPairBreaks(40 * MINUTE, 300_000), false);
});
test('a pair 1 minute and 200 km apart breaks: faster than 1,500 kt', () => {
assert.equal(trailPairBreaks(MINUTE, 200_000), true);
});
test('a gap longer than 48 minutes breaks, and time that does not advance breaks', () => {
assert.equal(trailPairBreaks(49 * MINUTE, 10_000), true);
assert.equal(trailPairBreaks(48 * MINUTE, 10_000), false);
assert.equal(trailPairBreaks(0, 0), true);
assert.equal(trailPairBreaks(-MINUTE, 100), true);
});
test('a parked aircraft two hours apart within 0.1 nm stays joined', () => {
assert.equal(trailPairBreaks(2 * HOUR, 0.09 * NM), false);
assert.equal(trailPairBreaks(3 * HOUR + MINUTE, 0.09 * NM), true);
assert.equal(trailPairBreaks(2 * HOUR, 0.2 * NM), true);
});
test('the head joins within 150 km under the time and speed test', () => {
assert.equal(trailHeadJoins(30_000, 5_000), true);
assert.equal(trailHeadJoins(30 * MINUTE, 151_000), false);
assert.equal(trailHeadJoins(HOUR, 5_000), false);
});
test('a backfill with gaps of 10 h, 2 h, 1.2 h and 6.5 h draws five segments and no line across a gap', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const legs = [];
let start = t0;
const gaps = [10 * HOUR, 2 * HOUR, 1.2 * HOUR, 6.5 * HOUR];
const anchors = [
[30, 31],
[12, 0],
[14, -15],
[20, -40],
[35, -75],
];
for (let i = 0; i < anchors.length; i++) {
const points = leg(start, anchors[i][0], anchors[i][1], 6);
legs.push(points);
start = points[points.length - 1].observedAtMs + (gaps[i] ?? 0);
}
const waypoints = legs.flat();
const lastLeg = legs[legs.length - 1];
const liveStart = lastLeg[lastLeg.length - 1].observedAtMs + MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 35,
lon: -75 + 0.72 + i * 0.12,
}));
const nowMs = liveStart + 3 * MINUTE;
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
waypoints,
nowMs,
});
tracking._startTrail('abc123');
await settle();
assert.equal(flightState._trailFixes.length, waypoints.length + 3);
const segments = trail.last();
assert.equal(segments.length, 5);
const bodyCount = segments.reduce((sum, segment) => sum + segment.length, 0);
// The body draws every fix at or before the display time: all of them here.
assert.equal(bodyCount, waypoints.length + 3);
for (const [index, segment] of segments.entries()) {
const expected = legs[index].map((point) =>
Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, 10_000),
);
for (const position of segment.slice(0, expected.length)) {
assert.ok(
expected.some((e) => Cesium.Cartesian3.distance(e, position) < 1),
`segment ${index} holds a point from another leg`,
);
}
}
});
test('a null-altitude waypoint breaks the line, and nothing is drawn at 10 km', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const waypoints = leg(t0, 40, 10, 6);
waypoints[3] = waypoint(waypoints[3].observedAtMs, 40, 10.36, null);
const nowMs = t0 + 8 * MINUTE;
const liveFixes = [
{ timeMs: t0 + 6 * MINUTE, lat: 40, lon: 10.72 },
{ timeMs: t0 + 7 * MINUTE, lat: 40, lon: 10.84 },
];
// Unknown floor: the null point is skipped, and the line still breaks there.
const skipped = trackedHarness({ liveFixes, waypoints, nowMs });
skipped.tracking._startTrail('abc123');
await settle();
assert.equal(
skipped.flightState._trailFixes.length,
waypoints.length - 1 + 2,
);
const segments = skipped.trail.last();
assert.deepEqual(
segments.map((segment) => segment.length),
[3, 4],
);
for (const segment of segments) {
for (const position of segment) {
const height = Cesium.Cartographic.fromCartesian(position).height;
assert.ok(Math.abs(height - 10_000) < 1, 'only real altitudes drawn');
}
}
// Known floor: the point is kept on the ground, alone, joined to nothing.
const grounded = trackedHarness({
liveFixes,
waypoints,
nowMs,
floor: () => 120,
});
grounded.tracking._startTrail('abc123');
await settle();
assert.equal(grounded.flightState._trailFixes.length, waypoints.length + 2);
assert.deepEqual(
grounded.trail.last().map((segment) => segment.length),
[3, 4],
);
const kept = grounded.flightState._trailFixes[3];
assert.ok(
Math.abs(Cesium.Cartographic.fromCartesian(kept.position).height - 121) < 1,
);
});
test('a history tail far from the first live fix does not connect to it', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
// History ends 20 minutes before the first live fix, 1,400 km away.
const waypoints = leg(t0, 50, 0, 5);
const liveStart = t0 + 24 * MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 50,
lon: 20 + i * 0.12,
}));
const { trail, tracking } = trackedHarness({
liveFixes,
waypoints,
nowMs: liveStart + 3 * MINUTE,
});
tracking._startTrail('abc123');
await settle();
assert.deepEqual(
trail.last().map((segment) => segment.length),
[5, 3],
);
});
test('the head is drawn to a nearby aircraft and not to one more than 150 km away', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const near = Cesium.Cartesian3.fromDegrees(1.3, 50, 10_000);
const joined = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: near,
});
joined.tracking._startTrail('abc123');
const drawn = headPositions(joined.viewer);
assert.equal(drawn.length, 2);
assert.ok(Cesium.Cartesian3.distance(drawn[1], near) < 1e-6);
const far = Cesium.Cartesian3.fromDegrees(4, 50, 10_000);
const detached = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: far,
});
detached.tracking._startTrail('abc123');
assert.deepEqual(headPositions(detached.viewer), []);
// An hour without a fix: the head no longer joins the last body point.
const stale = trackedHarness({ liveFixes, nowMs: t0 + HOUR, head: near });
stale.tracking._startTrail('abc123');
assert.deepEqual(headPositions(stale.viewer), []);
});
test('a live fix after a one-hour feed gap starts a new segment', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
});
tracking._startTrail('abc123');
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3],
);
const later = t0 + 2 * MINUTE + HOUR;
// The display time moves past the new fixes.
flightState.now = () => later + 3 * MINUTE;
for (const [i, lon] of [3, 3.12, 3.24].entries()) {
tracking._appendTrailFix(
Cesium.Cartesian3.fromDegrees(lon, 50, 10_000),
later + i * MINUTE,
);
}
assert.equal(flightState._trailFixes.length, 6);
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3, 3],
);
});
test('every waypoint of a 24 h history survives, and the trail stays within TRAIL_MAX_POINTS', async () => {
// A full day at one fix per minute.
const day = 24 * 60;
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const full = leg(t0, 10, -100, day);
const liveStart = t0 + day * MINUTE;
const liveFixes = [{ timeMs: liveStart, lat: 10, lon: -100 + day * 0.12 }];
const kept = trackedHarness({
liveFixes,
waypoints: full,
nowMs: liveStart + MINUTE,
});
kept.tracking._startTrail('abc123');
await settle();
assert.ok(TRAIL_MAX_POINTS >= day + 500);
assert.equal(kept.flightState._trailFixes.length, full.length + 1);
assert.deepEqual(
kept.trail.last().map((segment) => segment.length),
[day + 1],
);
const count = TRAIL_MAX_POINTS + 120;
const large = leg(
liveStart - count * MINUTE,
10,
-100 + (day - count) * 0.12,
count,
);
const capped = trackedHarness({
liveFixes,
waypoints: large,
nowMs: liveStart + MINUTE,
});
capped.tracking._startTrail('abc123');
await settle();
const fixes = capped.flightState._trailFixes;
assert.equal(fixes.length, TRAIL_MAX_POINTS);
// The oldest go first: the newest live fix is still the last point.
assert.equal(fixes[fixes.length - 1].timeMs, liveStart);
});
+866
View File
@@ -0,0 +1,866 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { RENDER_DELAY_SEC, STALE_ALPHA } from './policy.js';
// Between reports, a tracked trail is the path the icon was actually drawn
// along: the real display position, recorded as the frames go by. These tests
// drive the real motion and display path frame by frame.
const SEC = 1000;
const ICAO = '00a001';
const T0 = Date.UTC(2026, 10, 9, 21, 0);
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
function services(trail, ground) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
aircraftPresentation: {
isTr3b: () => false,
tr3bTypeLabel: (id, label) => label,
},
focus: {
nearFarScalarValueAtDistance: () => 1,
clearFocusTarget() {},
publishFocusTargetFromCachedPosition() {},
},
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: ground,
stickyFloorCell: (lat, lon) => ({ lat, lon }),
neighborFloorM: () => null,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) && Number.isFinite(alt)
? Math.max(alt, g + 1.5)
: alt,
},
};
}
/**
* Real motion, real display position and real tracking over one contact.
* `frame()` runs one rendered frame: the layer's preRender work, then the
* head callback, as Cesium does.
*/
function harness({
fixes,
info,
nowMs,
ground = () => null,
envelopeM = null,
}) {
const segmentsCalls = [];
let inHead = false;
const trail = {
createTrail: () => ({
setSegments(segments) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail, ground);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
flightState.records.data.set(ICAO, info);
let frameNumber = 0;
const added = [];
let entityChanges = 0;
flightState._viewer = {
isDestroyed: () => false,
scene: {
frameState: {
get frameNumber() {
return frameNumber;
},
},
},
entities: {
add(definition) {
entityChanges += 1;
added.push(definition);
return definition;
},
remove() {
entityChanges += 1;
},
},
};
flightState._trackedIcao = ICAO;
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set(ICAO, fixes);
const parts = {
rendering: { _modelOwnsVisual: () => false, _modelSpec: () => ({}) },
queries: { _contactLabel: (id, info) => info?.callsign || id },
};
parts.motion = createMotion({ flightState, services: svc, parts });
if (envelopeM != null) {
// A 3D model is the visual: its centre is the drawn position.
parts.motion._trackedModelCenterWorld = () =>
parts.motion._trackedDisplayCached();
parts.motion._trackedModelEnvelopeM = () => envelopeM;
}
const tracking = createTracking({
flightState,
services: svc,
parts,
layer: {},
});
parts.tracking = tracking;
const realDateNow = Date.now;
Date.now = () => clock;
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
const head = () => {
const before = entityChanges;
inHead = true;
try {
return headEntity().polyline.positions.getValue();
} finally {
inHead = false;
assert.equal(
entityChanges,
before,
'the head callback added or removed an entity',
);
}
};
/** Drawn icon positions, one per frame. */
const drawn = [];
const frame = ({ readHead = true } = {}) => {
frameNumber += 1;
tracking._trailTick();
const icon = parts.motion._trackedDisplayPosition(ICAO);
if (icon) drawn.push(Cesium.Cartesian3.clone(icon));
if (readHead) head();
};
const run = (toMs, { frameMs = 100, readHead = true } = {}) => {
while (clock < toMs) {
clock = Math.min(toMs, clock + frameMs);
frame({ readHead });
}
};
return {
flightState,
tracking,
motion: parts.motion,
head,
drawn,
run,
body: () => segmentsCalls.at(-1) ?? [],
bodyPoints: () => (segmentsCalls.at(-1) ?? []).flat(),
restore: () => {
Date.now = realDateNow;
},
};
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
if (path.length === 1) return Cesium.Cartesian3.distance(p, path[0]);
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
/** No vertex turns the line back sharper than 120° over a short step. */
function assertNoSpur(line, label) {
const u = new Cesium.Cartesian3();
const v = new Cesium.Cartesian3();
for (let i = 1; i < line.length - 1; i++) {
Cesium.Cartesian3.subtract(line[i], line[i - 1], u);
Cesium.Cartesian3.subtract(line[i + 1], line[i], v);
const lu = Cesium.Cartesian3.magnitude(u);
const lv = Cesium.Cartesian3.magnitude(v);
if (!(lu > 0 && lv > 0) || Math.min(lu, lv) > 500) continue;
const cos = Cesium.Cartesian3.dot(u, v) / (lu * lv);
assert.ok(
cos >= -0.5,
`${label}: spur at vertex ${i} of ${line.length} (turn ${(
(Math.acos(cos) * 180) /
Math.PI
).toFixed(0)}°)`,
);
}
}
const indexOfPoint = (line, p) =>
line.findIndex((q) => Cesium.Cartesian3.distance(p, q) < 0.01);
// A departing jet: four northbound climb reports, then a left turn through
// west to south with no report while the icon flies it.
function departure() {
const fixes = [
fix(T0, 37.6329, -122.3661, 213, 85, 8),
fix(T0 + 30 * SEC, 37.6542, -122.3616, 495, 95, 0),
fix(T0 + 60 * SEC, 37.6813, -122.3615, 808, 104, 350),
fix(T0 + 90 * SEC, 37.7066, -122.3755, 1257, 112, 340),
];
const info = {
altitude: 1257,
renderAltitudeM: 1257,
rawLat: 37.7066,
rawLon: -122.3755,
velocity: 112,
true_track: 340,
turnRateDps: -2,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 200 * SEC,
};
return { fixes, info };
}
test('a report newer than the display time arrives mid-turn: the trail between the two reports stays on the drawn arc', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// 40 s of turn with no new report: the icon flies the arc.
h.run(start + 40 * SEC);
const arc = h.drawn.slice();
// The next report is where the plane really was 60 s after the last one,
// still ahead of the display time (normal 30 s delay, slow feed). The icon
// now interpolates the straight line between the two reports and is eased
// onto it.
const at60 = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 150 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(at60);
const report = fix(
T0 + 150 * SEC,
Cesium.Math.toDegrees(c.latitude),
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
220,
);
fixes.push(report);
info.true_track = 220;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// The display passes the new report.
h.run(start + 75 * SEC);
const line = h.bodyPoints();
const from = indexOfPoint(line, fixes[3].position);
const to = indexOfPoint(line, report.position);
assert.ok(from >= 0 && to > from, 'both reports are on the trail');
const between = line.slice(from, to + 1);
// Every vertex between the reports is where the icon was drawn...
const drawnPath = [fixes[3].position, ...h.drawn, report.position];
for (const vertex of between) {
const off = distanceToPath(vertex, drawnPath);
assert.ok(
off < 50,
`trail vertex ${off.toFixed(0)} m off the drawn path`,
);
}
// ...and the arc the icon flew is still there: no straight chord. (The
// arc strays far from the chord, so a chord would fail this.)
const chord = [fixes[3].position, report.position];
assert.ok(Math.max(...arc.map((p) => distanceToPath(p, chord))) > 300);
// The last 10 s of the arc are exempt: the correction pulled the icon back
// over them, and keeping them would fold the line back on itself.
for (const point of arc.slice(0, -100)) {
const off = distanceToPath(point, between);
assert.ok(off < 50, `drawn arc point ${off.toFixed(0)} m from the trail`);
}
assertNoSpur(line, 'trail');
assertNoSpur(h.head(), 'head');
} finally {
h.restore();
}
});
test('a fresh report after a hold resumes with no backward spur', () => {
const fixes = [0, 1, 2, 3].map((i) =>
fix(T0 + i * 15 * SEC, 40, -100 + i * 0.04, 10_000, 230, 90),
);
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -100 + 0.12,
velocity: 230,
true_track: 90,
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 45 * SEC,
};
const start = T0 + 45 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// The contact coasts five minutes, then holds; recording stops while it
// holds.
h.run(start + 320 * SEC);
const held = h.bodyPoints().length;
h.run(start + 360 * SEC);
assert.equal(h.bodyPoints().length, held, 'the trail grew during a hold');
// A fresh report, a little north of the track, ahead of the display.
const display = start + 360 * SEC - RENDER_DELAY_SEC * SEC;
// Just past where it held: five minutes and ten seconds at 230 m/s.
const aheadDeg = (230 * 310) / (111_320 * Math.cos((40 * Math.PI) / 180));
const fresh = fix(
display + 10 * SEC,
40.01,
-100 + 0.12 + aheadDeg,
10_000,
230,
80,
);
fixes.push(fresh);
info.lastContactEpochMs = fresh.epochMs;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(fresh.position),
fresh.epochMs,
);
h.run(start + 390 * SEC);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), fresh.position) >= 0);
} finally {
h.restore();
}
});
test('with the head hidden the body keeps advancing, and untracking clears the recording', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 5 * SEC);
const before = h.bodyPoints().at(-1);
// Cockpit view: the head is hidden and its callback never runs.
h.run(start + 30 * SEC, { readHead: false });
// The body's end keeps following the icon; straight stretches are thinned.
const end = h.bodyPoints().at(-1);
assert.ok(
Cesium.Cartesian3.distance(end, before) > 1_000,
'the body stopped advancing',
);
assert.ok(Cesium.Cartesian3.distance(end, h.drawn.at(-1)) < 300);
h.tracking._clearTrail();
assert.equal(h.flightState._trailFixes.length, 0);
// A new track of the same plane starts from reports only.
h.tracking._startTrail(ICAO);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
test('a live fix with no altitude breaks the line and puts no point at an invented height', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const invented = Cesium.Cartesian3.fromDegrees(-122.38, 37.71, 10_000);
h.tracking._appendTrailFix(invented, T0 + 95 * SEC, { nullAltitude: true });
const later = Cesium.Cartesian3.fromDegrees(-122.39, 37.715, 1400);
h.tracking._appendTrailFix(later, T0 + 100 * SEC);
h.run(T0 + 100 * SEC + RENDER_DELAY_SEC * SEC + SEC, { frameMs: 500 });
for (const point of h.bodyPoints())
assert.ok(Cesium.Cartographic.fromCartesian(point).height < 5_000);
// The reports on either side of it are not joined.
const segments = h.body();
const holding = (p) =>
segments.findIndex((segment) =>
segment.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(holding(fixes[3].position), holding(later));
} finally {
h.restore();
}
});
test('with a 3D model as the visual, the trail stops outside the model and the head runs from there to the plane', () => {
const { fixes, info } = departure();
info.turnRateDps = 0;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const envelopeM = 300;
const h = harness({ fixes, info, nowMs: start, envelopeM });
try {
h.tracking._startTrail(ICAO);
h.run(start + 30 * SEC);
const icon = h.drawn.at(-1);
const body = h.bodyPoints();
const head = h.head();
assert.ok(head.length >= 2, 'no head is drawn');
assert.ok(Cesium.Cartesian3.distance(head.at(-1), icon) < 0.01);
// No trail point inside the model, and the head starts where the body ends.
for (const point of body)
assert.ok(Cesium.Cartesian3.distance(point, icon) >= envelopeM - 1);
assert.ok(Cesium.Cartesian3.distance(head[0], body.at(-1)) < 0.01);
// Body and head together follow where the icon was drawn.
const line = body.concat(head);
for (const point of h.drawn) {
const off = distanceToPath(point, line);
assert.ok(off < 35, `drawn icon ${off.toFixed(0)} m off the trail`);
}
} finally {
h.restore();
}
});
test('a report the display already passed replaces the guess drawn after it, with no spike', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 40 * SEC);
const displayMs = T0 + 130 * SEC;
// A report for 10 s before the display time, 400 m inside the drawn arc.
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 120 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
T0 + 120 * SEC,
Cesium.Math.toDegrees(c.latitude) - 400 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
250,
);
fixes.push(report);
info.true_track = 250;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
h.run(start + 55 * SEC);
const recorded = h.flightState._trailFixes.filter((entry) => entry.drawn);
// Nothing recorded between the report and the end of the ease.
assert.deepEqual(
recorded
.filter(
(entry) =>
entry.timeMs > report.epochMs && entry.timeMs < displayMs + 900,
)
.map((entry) => entry.timeMs - T0),
[],
);
const line = h.bodyPoints();
assert.ok(indexOfPoint(line, report.position) >= 0);
assertNoSpur(line, 'trail');
} finally {
h.restore();
}
});
test('a history report at an invented height is seeded with no position, and nothing is recorded next to it', () => {
const { fixes, info } = departure();
fixes[3].inventedHeight = true;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const seeded = h.flightState._trailFixes.find(
(entry) => entry.timeMs === fixes[3].epochMs,
);
assert.equal(seeded.position, null);
assert.equal(seeded.nullAltitude, true);
h.run(start + 10 * SEC);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
// A turboprop at FL210 flying east out of receiver coverage: its last
// position is at T0 + 30 s, and contact goes on for four more minutes.
function outOfCoverage() {
const fixes = [
fix(T0, 43, -108.1, 6_400, 140, 90),
fix(T0 + 30 * SEC, 43, -108.05, 6_400, 140, 90),
];
const info = {
altitude: 6_400,
renderAltitudeM: 6_400,
rawLat: 43,
rawLon: -108.05,
velocity: 140,
true_track: 90,
callsign: 'TEST7',
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 30 * SEC + 240 * SEC,
};
return { fixes, info };
}
/** Display time (ms) `afterSec` past the last report. */
const pastReport = (afterSec) =>
T0 + 30 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
test('a plane that stops reporting keeps moving dimmed as STALE from its first missed update, then stops as SIGNAL LOST', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(30));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
assert.equal(h.tracking._trackedIconColor().alpha, 1);
// The first missed update.
h.flightState.records.missingPolls.set(ICAO, 1);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.match(h.tracking._trackedLabelText(ICAO), /STALE/);
// Drawn with the fleet's stale fade.
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
// Still moving well past 90 s.
h.run(pastReport(200));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-11)) > 100,
);
// The coast ends 300 s past the report; the plane stops there.
h.run(pastReport(310));
const recorded = h.flightState._trailFixes.filter((e) => e.drawn).length;
h.run(pastReport(330));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-50)) < 0.01,
);
assert.equal(
h.flightState._trailFixes.filter((e) => e.drawn).length,
recorded,
'the trail kept recording a stopped plane',
);
// 330 s of display past the report is 330 s + the display delay of wall time.
const minutes = Math.floor((330 + RENDER_DELAY_SEC) / 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${minutes} min`,
dimmed: true,
});
assert.match(
h.tracking._trackedLabelText(ICAO),
new RegExp(`SIGNAL LOST · ${minutes} min`),
);
} finally {
h.restore();
}
});
test('a plane held at its last report shows SIGNAL LOST in seconds under a minute', () => {
// Its last report is 100 m above a known ground height: it holds there.
const { fixes, info } = outOfCoverage();
const h = harness({
fixes,
info,
nowMs: pastReport(-5),
ground: () => 6_300,
});
try {
h.tracking._startTrail(ICAO);
h.flightState.records.missingPolls.set(ICAO, 1);
h.run(pastReport(10));
assert.ok(10 + RENDER_DELAY_SEC < 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${10 + RENDER_DELAY_SEC} s`,
dimmed: true,
});
} finally {
h.restore();
}
});
test('a report arriving 200 s into a coast: the plane eases onto it and the trail drops what was drawn after it', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(200));
// Heard again: the report is for 10 s before the display time, 2 km north
// of where the coast put it.
const at = T0 + 30 * SEC + 190 * SEC;
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(at)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
at,
Cesium.Math.toDegrees(c.latitude) + 2_000 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
140,
90,
);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
assert.deepEqual(
h.flightState._trailFixes.filter(
(e) => e.drawn && e.timeMs > report.epochMs,
),
[],
);
const from = h.drawn.length;
h.run(pastReport(203));
const after = h.drawn.slice(from - 1);
for (let i = 1; i < after.length; i++)
assert.ok(
Cesium.Cartesian3.distance(after[i], after[i - 1]) < 400,
'the plane jumped instead of easing',
);
// Eased onto the path from the report: 13 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(after.at(-1), report.position);
assert.ok(
Math.abs(off - 140 * 13) < 150,
`${off.toFixed(0)} m from the report`,
);
} finally {
h.restore();
}
});
test('a tracked plane that stops reporting: STALE from the first missed poll, SIGNAL LOST after the five-minute coast, kept at 9 min, removed at 10 min', () => {
const { fixes, info } = outOfCoverage();
const fixMs = fixes.at(-1).epochMs;
// Contact stops with the position: the plane left coverage.
info.lastContactEpochMs = fixMs;
info.newestFixMs = fixMs;
const h = harness({ fixes, info, nowMs: fixMs + 5 * SEC });
try {
h.tracking._startTrail(ICAO);
const records = h.flightState.records;
const polls = new Map();
let removedAtSec = null;
// A poll every 10 s that lists the plane without a position.
for (let afterSec = 10; afterSec <= 660; afterSec += 10) {
h.run(fixMs + afterSec * SEC, { frameMs: 500, readHead: false });
const result = records.absence(ICAO, {
complete: true,
retainIds: new Set([ICAO]),
likelyLanded: false,
tracked: true,
});
if (result === 'remove') {
removedAtSec = afterSec;
break;
}
polls.set(afterSec, {
cue: h.tracking._trackedStaleCue(ICAO),
drawn: Cesium.Cartesian3.clone(h.drawn.at(-1)),
});
}
// Wall time when the display reaches the end of the coast.
const coastEndSec = 300 + RENDER_DELAY_SEC;
for (const [afterSec, { cue }] of polls) {
if (afterSec <= coastEndSec) {
assert.deepEqual(cue, { text: 'STALE', dimmed: true }, `${afterSec} s`);
} else {
assert.deepEqual(
cue,
{
text: `SIGNAL LOST · ${Math.floor(afterSec / 60)} min`,
dimmed: true,
},
`${afterSec} s`,
);
}
}
const moved = (a, b) =>
Cesium.Cartesian3.distance(polls.get(a).drawn, polls.get(b).drawn);
// Moving through the coast, still at its last speed at four minutes.
assert.ok(Math.abs(moved(230, 240) - 140 * 10) < 20);
// Stopped once the coast has ended.
const after = Math.ceil(coastEndSec / 10) * 10;
assert.ok(moved(after, 540) < 0.01);
assert.equal(polls.get(300).cue.text, 'STALE');
assert.deepEqual(polls.get(540).cue, {
text: 'SIGNAL LOST · 9 min',
dimmed: true,
});
assert.equal(removedAtSec, 600);
} finally {
h.restore();
}
});
test('a repeated old fix counts as a missed update for the tracked plane', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
// The feed re-sends the last position: its fix time did not advance.
h.flightState.records.fixRepeated.add(ICAO);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('a stalled feed dims the tracked plane as STALE', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
// The whole feed is backing off: no poll counts a miss, and no newer
// position is coming in.
h.flightState.feed._backoff = true;
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs;
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(360));
const stop = Cesium.Cartesian3.clone(h.drawn.at(-1));
assert.ok(Cesium.Cartesian3.distance(stop, h.drawn.at(-100)) < 0.01);
const kept = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => Cesium.Cartesian3.clone(e.position));
// The thinned straight coast, recorded up to where the plane stopped.
assert.ok(kept.length >= 2);
assert.ok(Cesium.Cartesian3.distance(kept.at(-1), stop) < 300);
// Heard again, for 20 s before the display time: the plane flew on, a
// little north of its old heading.
const at = T0 + 30 * SEC + 340 * SEC;
const lonDeg =
-108.05 + (140 * 340) / (111_320 * Math.cos((43 * Math.PI) / 180));
const report = fix(at, 43 + 1_000 / 111_320, lonDeg, 6_400, 140, 90);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// Nothing was recorded after the stop, so nothing is dropped.
const after = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => e.position);
assert.equal(after.length, kept.length);
kept.forEach((p, i) =>
assert.ok(Cesium.Cartesian3.distance(p, after[i]) < 0.01),
);
const from = h.drawn.length;
h.run(pastReport(365));
const eased = h.drawn.slice(from - 1);
const jump = Cesium.Cartesian3.distance(eased[0], eased.at(-1));
for (let i = 1; i < eased.length; i++)
assert.ok(
Cesium.Cartesian3.distance(eased[i], eased[i - 1]) < jump / 2,
'the plane jumped instead of easing',
);
// On the path from the report: 25 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(eased.at(-1), report.position);
assert.ok(Math.abs(off - 140 * 25) < 150, `${off.toFixed(0)} m`);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), report.position) >= 0);
} finally {
h.restore();
}
});
+40
View File
@@ -0,0 +1,40 @@
/**
* What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js).
*/
export function createClockFollow({ flightState, parts }) {
/**
* Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses and the tracked trail
* belong to the time the clock left.
*/
function resetMotion() {
flightState.records.reset();
flightState._positionHistory.clear();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
flightState._drReconcileValid = false;
flightState._cachedDRFrame = -1;
flightState._cachedDRPosition = null;
parts.tracking._clearTrail();
flightState._lastCamPoseSig = '';
}
/** Drop every contact: the time has none to show, or the clock went live. */
function clear() {
if (flightState._trackedIcao)
parts.tracking._clearTracking(false, { evicted: true });
resetMotion();
for (const [icao24, billboard] of flightState._billboards) {
flightState._billboardCollection?.remove(billboard);
parts.rendering._releaseModel(icao24);
}
flightState._billboards.clear();
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
flightState.feed._count = 0;
flightState.feed._lastUpdate = null;
}
return { resetMotion, clear };
}
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MilitaryFlightRecords } from './records.js';
import { createIngestion, createMilitaryFeed } from './ingestion.js';
import { displayDelayMs, RENDER_DELAY_SEC } from './policy.js';
// Military aircraft follow the world clock only while their source declares
// a timeline (src/time/snapshotTimeline.js); these cover the layer's side.
const T = 1_800_000_000_000;
test('military contacts are drawn behind the live edge, and at the time following the clock', () => {
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs({ timeline: { active: () => false } }), 15_000);
assert.equal(displayDelayMs({ timeline: { active: () => true } }), 0);
});
test('military records judge absence at the layer clock and reset at a jump', () => {
let now = T;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
now: () => now,
});
const { meta } = store.receive(
{
id: 'ae0001',
reference: 'ae0001',
latitude: 39,
longitude: -77,
baroAltitudeM: 3000,
onGround: false,
positionTimeMs: T - 1_000,
},
{ observedAtMs: T, floorWarmPoints: [] },
);
assert.equal(meta.observedReceiptMs, T);
assert.equal(
store.absence('ae0001', { complete: true, tracked: true }),
'stale',
);
now = T + 11 * 60_000;
assert.equal(
store.absence('ae0001', { complete: true, tracked: true }),
'remove',
);
store.receive(
{ id: 'ae0001', latitude: 39, longitude: -77, positionTimeMs: T },
{ observedAtMs: T, floorWarmPoints: [] },
);
store.absence('ae0001', { complete: true });
store.reset();
assert.equal(store.data.size, 0);
assert.equal(store.missingPolls.size, 0);
assert.equal(store.lastSnapshotMs, null);
});
test('military polls carry no time; a snapshot at a time carries it, and polls stop following', async () => {
const queries = [];
const applied = [];
let follows = false;
const feed = createMilitaryFeed({
async getSnapshot(query) {
queries.push(query);
return { records: [], observedAtMs: T, freshness: 'current' };
},
});
const { methods } = createIngestion({
feed,
applySnapshot: (_snapshot, _viewer, options) => {
applied.push(options);
return { count: 0, ids: new Set() };
},
setSourceLabel: () => {},
applyPendingTrackingRestore: () => {},
followsClock: () => follows,
});
await methods.update(null);
assert.deepEqual(queries, [{}]);
assert.deepEqual(applied, [undefined]);
follows = true;
await methods.update(null);
assert.equal(queries.length, 1, 'no poll while following');
assert.equal(await methods.loadAt(T, { replace: true }), true);
assert.deepEqual(queries[1], { atMs: T });
assert.deepEqual(applied[1], { replace: true });
});
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff. Show more