mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge remote-tracking branch 'origin/main' into feat/viewport-context-decision-upstream
This commit is contained in:
commit
100369f9af
189 files changed
+29069
-743
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: ' Şehir\n\u0000 PD ',
|
||||
manufacturer: '😀'.repeat(200),
|
||||
'camera:type': null,
|
||||
'surveillance:zone': '\t traffic zone ',
|
||||
},
|
||||
});
|
||||
assert.equal(record.operator, 'Şehir PD');
|
||||
assert.equal(record.manufacturer.length, 180);
|
||||
assert.equal(record.manufacturer, '😀'.repeat(90));
|
||||
assert.equal(record.cameraType, null);
|
||||
assert.equal(record.zone, 'traffic zone');
|
||||
});
|
||||
@@ -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’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 = {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
|
||||
export function normalizeDisplayText(value, maxLength = 180) {
|
||||
if (value == null || typeof value === 'object') return '';
|
||||
return String(value)
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
.replace(/[\uD800-\uDBFF]$/, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
+181
-8
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
]) {
|
||||
|
||||
@@ -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 }), []);
|
||||
});
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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'));
|
||||
});
|
||||
@@ -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']));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+161
-3
@@ -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",
|
||||
@@ -2284,7 +2386,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": {
|
||||
@@ -3511,6 +3613,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}",
|
||||
|
||||
+145
-3
@@ -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",
|
||||
@@ -2180,7 +2270,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": {
|
||||
@@ -3203,6 +3293,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",
|
||||
|
||||
+149
-3
@@ -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",
|
||||
@@ -500,6 +591,7 @@
|
||||
"LOADING": "CARGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESACTUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
|
||||
"FALLBACK": "RESPALDO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"UNCERTAIN": "INCIERTO",
|
||||
@@ -737,6 +829,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 +2099,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",
|
||||
@@ -3280,6 +3373,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}",
|
||||
|
||||
+149
-3
@@ -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",
|
||||
@@ -1689,14 +1781,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}",
|
||||
@@ -1762,7 +1855,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",
|
||||
@@ -3280,6 +3373,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}",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+149
-3
@@ -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",
|
||||
@@ -1689,14 +1781,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}",
|
||||
@@ -1762,7 +1855,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",
|
||||
@@ -3280,6 +3373,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",
|
||||
|
||||
+141
-3
@@ -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": "情勢図",
|
||||
@@ -1641,14 +1727,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}",
|
||||
@@ -1712,7 +1799,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": "時刻不明",
|
||||
@@ -3126,6 +3213,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}前に目撃",
|
||||
|
||||
+149
-3
@@ -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",
|
||||
@@ -1689,14 +1781,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}",
|
||||
@@ -1762,7 +1855,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",
|
||||
@@ -3280,6 +3373,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}",
|
||||
|
||||
+153
-3
@@ -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": "Приблизьте, чтобы искать картографированные объекты",
|
||||
@@ -398,10 +493,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 +509,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 +2136,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": "ПАУЗА",
|
||||
@@ -3357,6 +3453,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} назад",
|
||||
|
||||
+153
-3
@@ -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": "Розвідувальна обстановка",
|
||||
@@ -1712,14 +1807,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}",
|
||||
@@ -1786,7 +1882,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": "ЧАС НЕВІДОМИЙ",
|
||||
@@ -3357,6 +3453,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} тому",
|
||||
|
||||
+141
-3
@@ -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%",
|
||||
@@ -1617,14 +1703,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}",
|
||||
@@ -1688,7 +1775,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": "时间未知",
|
||||
@@ -3126,6 +3213,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}前目击",
|
||||
|
||||
@@ -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%",
|
||||
@@ -1607,14 +1693,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}",
|
||||
@@ -1678,7 +1765,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": "時間未知",
|
||||
@@ -3126,6 +3213,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}前目擊",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ar": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -47,6 +48,7 @@
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -99,6 +101,7 @@
|
||||
"House",
|
||||
"Hybrid",
|
||||
"Indie",
|
||||
"International",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LIVE",
|
||||
@@ -145,6 +148,7 @@
|
||||
"Pin",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Protest",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
@@ -172,6 +176,7 @@
|
||||
"STD",
|
||||
"SW",
|
||||
"SYNC",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Scanlines",
|
||||
"Scope",
|
||||
@@ -180,6 +185,7 @@
|
||||
"Soul",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"Sudan",
|
||||
"T minus {seconds}",
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
@@ -192,6 +198,7 @@
|
||||
"Turboprop",
|
||||
"UPSTREAM",
|
||||
"Upstream HTTP {status}",
|
||||
"Video",
|
||||
"Vignette",
|
||||
"WHOT/BHOT",
|
||||
"WIND",
|
||||
@@ -217,6 +224,7 @@
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{region}, {country}",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
@@ -235,6 +243,7 @@
|
||||
"es": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -286,6 +295,7 @@
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Local",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
@@ -329,6 +339,7 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
@@ -362,10 +373,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",
|
||||
@@ -386,6 +400,7 @@
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
@@ -462,6 +477,7 @@
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Local",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
@@ -497,6 +513,7 @@
|
||||
"PROPAGATION",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Photos",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
@@ -523,6 +540,7 @@
|
||||
"STATION",
|
||||
"STATIONS",
|
||||
"STD",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
"Saturation",
|
||||
@@ -567,17 +585,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 +615,7 @@
|
||||
"it": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
@@ -651,6 +675,7 @@
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Iran–Hormuz",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"KTS",
|
||||
@@ -709,11 +734,13 @@
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Sudan",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
@@ -732,6 +759,7 @@
|
||||
"VIEWSHED ON",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"Video",
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
@@ -757,10 +785,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",
|
||||
@@ -783,6 +814,7 @@
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -846,6 +878,7 @@
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -901,6 +934,7 @@
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Layout",
|
||||
"Local",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
@@ -943,6 +977,7 @@
|
||||
"STARLINK ✕",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sahel",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Sonar",
|
||||
@@ -975,11 +1010,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",
|
||||
@@ -995,6 +1034,7 @@
|
||||
"iNaturalist"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1018,6 +1058,7 @@
|
||||
"TLE",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"{region}, {country}",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
@@ -1026,6 +1067,7 @@
|
||||
"{division}, {country}"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1044,6 +1086,7 @@
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"{region}, {country}",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
@@ -1053,6 +1096,7 @@
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1123,6 +1167,7 @@
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1190,5 +1235,102 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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,7 +1,8 @@
|
||||
import { normalizeDisplayText } from '../../data/displayText.js';
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
const t = normalizeDisplayText(value);
|
||||
return t || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,8 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
|
||||
layerState._at = null;
|
||||
|
||||
layerState._currentLodId = null;
|
||||
|
||||
layerState._currentLodIndex = -1;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -248,30 +252,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() - RENDER_DELAY_SEC * 1000 - 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 +348,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;
|
||||
@@ -1131,6 +1196,7 @@ export function createMotion({
|
||||
return {
|
||||
_refloorStaleGroundedContacts,
|
||||
_deadReckon,
|
||||
_signalLost,
|
||||
_extrapolateFix,
|
||||
_isTrackWarmingUp,
|
||||
_trackedFocusSizePx,
|
||||
|
||||
@@ -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):
|
||||
@@ -471,3 +471,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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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. */
|
||||
@@ -23,10 +24,45 @@ export class FlightRecords {
|
||||
};
|
||||
this.data = 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 },
|
||||
@@ -158,6 +194,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;
|
||||
@@ -204,9 +246,15 @@ export class FlightRecords {
|
||||
const routeHeld =
|
||||
Boolean(prevMeta?.routeCallsign) &&
|
||||
prevMeta.routeCallsign === nextCallsign.toUpperCase();
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: nextCallsign,
|
||||
altitude: alt,
|
||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||
@@ -215,6 +263,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.
|
||||
@@ -276,15 +325,25 @@ 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 }) {
|
||||
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 (Date.now() - 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.
|
||||
@@ -293,7 +352,6 @@ export class FlightRecords {
|
||||
Date.now() - (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);
|
||||
@@ -306,6 +364,7 @@ export class FlightRecords {
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.fixRepeated.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
STALE_ALPHA,
|
||||
} 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 ||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
staleHoldsForFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
@@ -22,7 +23,7 @@ export function createFlightSnapshotRenderer({
|
||||
queries,
|
||||
}) {
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
|
||||
const { warmGroundFloor } = groundFloor;
|
||||
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
const currentIcaos = new Set();
|
||||
@@ -39,6 +40,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 +114,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 +130,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 +141,28 @@ 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(),
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +267,7 @@ export function createFlightSnapshotRenderer({
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -195,9 +195,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 */
|
||||
|
||||
|
||||
+273
-85
@@ -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,31 @@ import {
|
||||
TRACKED_MODEL_MAX_LOAD_FAILS,
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
STALE_ALPHA,
|
||||
} 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 +351,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() - RENDER_DELAY_SEC * 1000,
|
||||
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() - RENDER_DELAY_SEC * 1000;
|
||||
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 +435,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() - RENDER_DELAY_SEC * 1000,
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,21 +474,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 +512,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 +526,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 +547,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(),
|
||||
@@ -520,7 +626,13 @@ export function createTracking({
|
||||
const time = observedAtMs / 1000;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) 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 +655,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 +688,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 +854,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 +958,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 +1157,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 +1441,7 @@ export function createTracking({
|
||||
_trackedModelRegimeActive,
|
||||
_syncTracked2dRotation,
|
||||
_appendTrailFix,
|
||||
_trailTick,
|
||||
_refreshTrailDisplay,
|
||||
_startTrail,
|
||||
_backfillTrail,
|
||||
@@ -1267,6 +1452,9 @@ export function createTracking({
|
||||
_applyPendingTrackingRestore,
|
||||
_cancelPendingTrackingRestore,
|
||||
_trackedLabelText,
|
||||
_trackedStaleCue,
|
||||
_trackedIconColor,
|
||||
_staleCueTick,
|
||||
_updateTrackedLabelModel,
|
||||
_syncTrackedBillboardImage,
|
||||
_refreshTr3bContact,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
speedRamp,
|
||||
courseBetweenCartesians,
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
floorCoastedPosition,
|
||||
staleCoastLimitSeconds,
|
||||
staleCoastSecondsFrom,
|
||||
staleHoldsForFix,
|
||||
arcOffsetEnu,
|
||||
courseSlewCapDps,
|
||||
limitCourseStep,
|
||||
COAST_MAX_SPEED_MPS,
|
||||
} from '../../data/motionModel.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
@@ -141,23 +145,86 @@ export function createMotion({
|
||||
);
|
||||
}
|
||||
|
||||
// Continue through fresh adsb.lol contacts even when the position epoch has
|
||||
// not advanced, but keep an absolute stale-feed drift ceiling.
|
||||
// Coast on the last speed and heading for five minutes past the newest
|
||||
// fix, 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);
|
||||
const cachedGroundFloor = services.groundFloor?.cachedGroundFloor;
|
||||
_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({
|
||||
// Captured at poll normalization so the fleet tick does not allocate a
|
||||
// Date for every aircraft twelve times per second.
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid allocating
|
||||
// a Date per aircraft on every fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Five minutes of flight after the last report, as flights.
|
||||
minimumSec: 300,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return _extrapolateFix(
|
||||
const holds = staleHoldsForFix(newest, {
|
||||
onGround: info?.onGround,
|
||||
cachedGroundFloor: services.groundFloor?.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() - RENDER_DELAY_SEC * 1000 - 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,9 +239,12 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||
const speed = Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info.speedMps) || 0;
|
||||
const speed = Math.min(
|
||||
Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info.speedMps) || 0,
|
||||
COAST_MAX_SPEED_MPS,
|
||||
);
|
||||
const heading = Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info.track) || 0;
|
||||
@@ -514,6 +584,7 @@ export function createMotion({
|
||||
}
|
||||
return {
|
||||
_deadReckon,
|
||||
_signalLost,
|
||||
_extrapolateFix,
|
||||
_isTrackWarmingUp,
|
||||
_trackedFocusSizePx,
|
||||
|
||||
@@ -172,7 +172,7 @@ export const TRAIL_COLOR = '#FFB800';
|
||||
|
||||
/** @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 (mirrors flights.js): the fleet renders at
|
||||
@@ -271,3 +271,6 @@ export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500;
|
||||
* synchronously (single primitive). */
|
||||
|
||||
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;
|
||||
@@ -6,4 +6,8 @@ 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;
|
||||
|
||||
export const ERROR_BACKOFF_INTERVAL = 20000;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
TRACKED_RETENTION_MS,
|
||||
} from './recordPolicy.js';
|
||||
|
||||
/** Portable military aircraft metadata and bounded missing-poll retention. */
|
||||
@@ -14,9 +15,44 @@ export class MilitaryFlightRecords {
|
||||
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
|
||||
this.data = 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(
|
||||
aircraft,
|
||||
{ observedAtMs, floorWarmPoints: _floorWarmPoints, modelOwnsVisual },
|
||||
@@ -88,6 +124,10 @@ export class MilitaryFlightRecords {
|
||||
// already uses (which also carries the on-ground 0 m / last-known-baro
|
||||
// case), so the two never disagree on the "no data yet" case.
|
||||
let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM;
|
||||
// No altitude for an airborne contact: the height is the airborne
|
||||
// default, invented. The tracked trail keeps no point there.
|
||||
const renderAltitudeInvented =
|
||||
pickedAltM == null && !onGround && aircraft.baroAltitudeM == null;
|
||||
// Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only
|
||||
// AIRBORNE contact near steep terrain can compute a render height
|
||||
// BELOW the local surface (no alt_geom; baro+N carries QNH error
|
||||
@@ -127,9 +167,11 @@ export class MilitaryFlightRecords {
|
||||
// Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold
|
||||
// last-known-good (bounded by the layer's eviction, which deletes the entry).
|
||||
const stickyType = stickyText(type, prevMeta?.type);
|
||||
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
|
||||
const meta = {
|
||||
sourceReference: aircraft.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
type: stickyType,
|
||||
// Type outranks category automatically inside classifyAircraft.
|
||||
@@ -146,6 +188,7 @@ export class MilitaryFlightRecords {
|
||||
// altitudeFt/baro).
|
||||
geoAltitudeM,
|
||||
renderAltitudeM,
|
||||
renderAltitudeInvented,
|
||||
// A poll that omits track/speed holds the last values, like the civil
|
||||
// records; 0 only until one is known. A grounded row without a speed
|
||||
// reads 0, so a parked plane never coasts at its landing speed.
|
||||
@@ -184,10 +227,25 @@ export class MilitaryFlightRecords {
|
||||
prevMeta,
|
||||
meta,
|
||||
groundFlipped,
|
||||
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
|
||||
fixEpochMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, retainIds, likelyLanded }) {
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
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 (Date.now() - 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.
|
||||
@@ -196,7 +254,6 @@ export class MilitaryFlightRecords {
|
||||
Date.now() - (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);
|
||||
@@ -208,6 +265,7 @@ export class MilitaryFlightRecords {
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.fixRepeated.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,98 @@ test('military records hold the last track and speed when a poll omits them', ()
|
||||
assert.equal(parked.meta.speedMps, 0);
|
||||
assert.equal(parked.meta.track, 90);
|
||||
});
|
||||
|
||||
test('an airborne military contact reported with no altitude is marked as drawn at an invented height', () => {
|
||||
const records = store();
|
||||
const none = records.receive(
|
||||
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(none.meta.renderAltitudeInvented, true);
|
||||
const known = records.receive(
|
||||
observation({ positionTimeMs: 1700000005000 }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(known.meta.renderAltitudeInvented, false);
|
||||
const parked = store().receive(
|
||||
observation({ baroAltitudeM: null, onGround: true }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(parked.meta.renderAltitudeInvented, false);
|
||||
});
|
||||
|
||||
test('the tracked military 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(['aaa001']) },
|
||||
{ complete: false },
|
||||
]) {
|
||||
const records = store();
|
||||
records.receive(
|
||||
observation({ positionTimeMs: fixMs }),
|
||||
context({ observedAtMs: fixMs + 1_000 }),
|
||||
);
|
||||
const absent = (tracked) =>
|
||||
records.absence('aaa001', {
|
||||
...snapshot,
|
||||
likelyLanded: false,
|
||||
tracked,
|
||||
});
|
||||
clock = fixMs + 15_000;
|
||||
assert.equal(absent(true), 'stale');
|
||||
assert.equal(records.missingPolls.get('aaa001'), 1);
|
||||
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(records.missingPolls.has('aaa001'), false);
|
||||
}
|
||||
const records = store();
|
||||
records.receive(
|
||||
observation({ positionTimeMs: fixMs }),
|
||||
context({ observedAtMs: fixMs + 1_000 }),
|
||||
);
|
||||
clock = fixMs + 15_000;
|
||||
const untracked = (snapshot) =>
|
||||
records.absence('aaa001', { ...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 military fix time marks the contact as not updated; removal is unchanged', () => {
|
||||
const records = store();
|
||||
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
|
||||
const poll = (snapshotMs, positionTimeMs) => {
|
||||
records.beginSnapshot(snapshotMs);
|
||||
records.receive(
|
||||
observation({ positionTimeMs }),
|
||||
context({ observedAtMs: snapshotMs }),
|
||||
);
|
||||
};
|
||||
poll(fixMs + 2_000, fixMs);
|
||||
assert.equal(records.fixRepeated.has('aaa001'), false);
|
||||
// adsb.lol re-sends the last position for up to a minute.
|
||||
poll(fixMs + 12_000, fixMs);
|
||||
assert.equal(records.fixRepeated.has('aaa001'), true);
|
||||
assert.equal(records.missingPolls.has('aaa001'), false);
|
||||
poll(fixMs + 12_000, fixMs);
|
||||
assert.equal(records.fixRepeated.has('aaa001'), true);
|
||||
poll(fixMs + 22_000, fixMs + 10_000);
|
||||
assert.equal(records.fixRepeated.has('aaa001'), false);
|
||||
poll(fixMs + 32_000, fixMs + 10_000);
|
||||
records.forget('aaa001');
|
||||
assert.equal(records.fixRepeated.has('aaa001'), false);
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
STALE_ALPHA,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -640,6 +641,19 @@ export function createRendering({
|
||||
/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The
|
||||
* tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */
|
||||
|
||||
/** 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 : TRACKED_ICON_COLOR
|
||||
).withAlpha(alpha);
|
||||
}
|
||||
|
||||
function _updateTrackedModel() {
|
||||
const active =
|
||||
flightState._trackedIcao &&
|
||||
@@ -787,6 +801,7 @@ export function createRendering({
|
||||
flightState._trackedModel.modelMatrix,
|
||||
spec.headingOffsetDeg,
|
||||
);
|
||||
_applyTrackedModelFade(flightState._trackedModel);
|
||||
if (!flightState._trackedModel.ready) return;
|
||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
@@ -814,6 +829,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 ||
|
||||
@@ -989,7 +1011,9 @@ export function createRendering({
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? 1
|
||||
: _militaryBillboardScale(icao24),
|
||||
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
|
||||
baseAlpha: flightState.records.missingPolls.get(icao24)
|
||||
? STALE_ALPHA
|
||||
: 1,
|
||||
baseColor,
|
||||
focusFactor: focus.factor,
|
||||
cameraDistanceM,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
staleHoldsForFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
@@ -29,6 +30,7 @@ export function createMilitarySnapshotRenderer({
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
const currentIcaos = new Set();
|
||||
const receiptNowMs = snapshot.observedAtMs;
|
||||
records.beginSnapshot(snapshot.observedAtMs);
|
||||
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
|
||||
// clamp — collected during the loop (low airborne contacts only),
|
||||
// batch-resolved once after it. Never a fetch inside the loop.
|
||||
@@ -83,6 +85,9 @@ export function createMilitarySnapshotRenderer({
|
||||
position: position.clone(),
|
||||
velocity: meta.speedMps,
|
||||
track: meta.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();
|
||||
@@ -95,26 +100,36 @@ export function createMilitarySnapshotRenderer({
|
||||
// (owner): ground traffic appends too — taxi history stays live
|
||||
// after touchdown; _appendTrailFix floors grounded positions at
|
||||
// the surface so the ground leg drapes instead of diving.
|
||||
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) {
|
||||
liftRepeatedGroundFix(newest, position, meta.onGround);
|
||||
}
|
||||
// A forward synthetic fix follows the stale rules (none while the
|
||||
// contact holds at its report, a coast capped from the real report,
|
||||
// never below the ground) and is a motion aid, never a trail report.
|
||||
const kinematicsChanged =
|
||||
newest.velocity !== meta.speedMps || newest.track !== meta.track;
|
||||
if (kinematicsChanged) {
|
||||
const holds = staleHoldsForFix(newest, {
|
||||
onGround: meta.onGround,
|
||||
cachedGroundFloor,
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
velocity: meta.speedMps,
|
||||
track: meta.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +276,7 @@ export function createMilitarySnapshotRenderer({
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMilitarySnapshotRenderer } 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 = '00a005';
|
||||
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 = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
militaryRegistry: stub(),
|
||||
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,
|
||||
altitudeFt: 130,
|
||||
speedMps: velocity,
|
||||
track,
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
klass: 'light',
|
||||
});
|
||||
|
||||
test('military: 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('military: 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,
|
||||
altitudeFt: 29_500,
|
||||
speedMps: velocity,
|
||||
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('military: 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('military: 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);
|
||||
assert.equal(h.history.length, 2);
|
||||
assert.equal(h.history[0].staleHolds, false);
|
||||
assert.equal(h.appended.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
|
||||
import { RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
const SEC = 1000;
|
||||
|
||||
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
|
||||
const flightState = {
|
||||
records: { data: new Map([['00a003', info]]) },
|
||||
_positionHistory: new Map([['00a003', fixes]]),
|
||||
_scratchArc: {},
|
||||
_scratchOffset: new Cesium.Cartesian3(),
|
||||
_scratchEnu: new Cesium.Matrix4(),
|
||||
_scratchRenderTime: new Cesium.JulianDate(),
|
||||
now: () => nowMs,
|
||||
};
|
||||
const motion = createMotion({
|
||||
flightState,
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: ground },
|
||||
camera: {},
|
||||
focus: {},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return 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,
|
||||
};
|
||||
}
|
||||
|
||||
test('a military contact stale after a low approach holds at its last report', () => {
|
||||
const t0 = Date.UTC(2026, 10, 9, 20, 0);
|
||||
const ground = 1_590; // An airbase, ellipsoidal.
|
||||
const fixes = [
|
||||
fix(t0, 35.03, -106.58, ground + 300, 75, 260),
|
||||
fix(t0 + 10 * SEC, 35.03, -106.588, ground + 40, 70, 260),
|
||||
];
|
||||
const info = {
|
||||
altitudeFt: 5_350,
|
||||
renderAltitudeM: ground + 40,
|
||||
rawLat: 35.03,
|
||||
rawLon: -106.588,
|
||||
speedMps: 70,
|
||||
track: 260,
|
||||
onGround: false,
|
||||
};
|
||||
for (const afterSec of [10, 60, 200]) {
|
||||
const motion = motionFixture({
|
||||
info,
|
||||
fixes,
|
||||
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
|
||||
ground: () => ground,
|
||||
});
|
||||
const drawn = motion._deadReckon('00a003', new Cesium.Cartesian3());
|
||||
assert.ok(Cesium.Cartesian3.distance(drawn, fixes[1].position) < 0.01);
|
||||
}
|
||||
});
|
||||
|
||||
test('a cruising military contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
|
||||
const t0 = Date.UTC(2026, 10, 9, 20, 0);
|
||||
const fixes = [
|
||||
fix(t0, 36, -115, 9_000, 220, 90),
|
||||
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 220, 90),
|
||||
];
|
||||
const info = {
|
||||
altitudeFt: 29_500,
|
||||
renderAltitudeM: 9_000,
|
||||
rawLat: 36,
|
||||
rawLon: -114.975,
|
||||
speedMps: 220,
|
||||
track: 90,
|
||||
onGround: false,
|
||||
lastContactEpochMs: t0 + 10 * SEC + 300 * SEC,
|
||||
};
|
||||
const at = (afterSec) =>
|
||||
motionFixture({
|
||||
info,
|
||||
fixes,
|
||||
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
|
||||
})._deadReckon('00a003', new Cesium.Cartesian3());
|
||||
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
|
||||
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
|
||||
assert.ok(near(d(at(45)), 220 * 45));
|
||||
assert.ok(near(d(at(250)), 220 * 250), 'still moving at 250 s');
|
||||
assert.ok(near(d(at(400)), 220 * 300), 'stopped at 300 s');
|
||||
});
|
||||
|
||||
test('a bad military speed coasts no faster than the 1,500 kt continuity limit', () => {
|
||||
const t0 = Date.UTC(2026, 10, 9, 20, 0);
|
||||
const fixes = [
|
||||
fix(t0, 36, -115, 9_000, 2_000, 90),
|
||||
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 2_000, 90),
|
||||
];
|
||||
const info = {
|
||||
altitudeFt: 29_500,
|
||||
renderAltitudeM: 9_000,
|
||||
rawLat: 36,
|
||||
rawLon: -114.975,
|
||||
speedMps: 2_000,
|
||||
track: 90,
|
||||
onGround: false,
|
||||
};
|
||||
const drawn = motionFixture({
|
||||
info,
|
||||
fixes,
|
||||
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC,
|
||||
})._deadReckon('00a003', 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('a holding military contact draws no head and its trail ends at the last report', () => {
|
||||
const t0 = Date.UTC(2026, 10, 9, 20, 0);
|
||||
const fixes = [0, 1, 2].map((i) =>
|
||||
fix(t0 + i * 10 * SEC, 35.03, -106.57 - i * 0.008, 1_700, 70, 260),
|
||||
);
|
||||
const svc = {
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'picking',
|
||||
'sprites',
|
||||
'aircraftPresentation',
|
||||
'camera',
|
||||
'militaryRegistry',
|
||||
'labels',
|
||||
'meshFloor',
|
||||
'focus',
|
||||
'readout',
|
||||
'context',
|
||||
'render',
|
||||
'recession',
|
||||
].map((name) => [name, {}]),
|
||||
),
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
|
||||
groundFloor: {
|
||||
resolveGroundFloorCellsBounded: async () => {},
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (alt) => alt,
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
svc.trails = {
|
||||
createTrail: () => ({
|
||||
setSegments: (segments) => calls.push(segments.flat()),
|
||||
setPositions: (positions) => calls.push(positions.slice()),
|
||||
setVisible() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
};
|
||||
const flightState = createFlightState({ services: svc, source: {} });
|
||||
const added = [];
|
||||
flightState._viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add: (definition) => (added.push(definition), definition),
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
flightState._trackedIcao = '00a003';
|
||||
flightState.now = () => t0 + 20 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC;
|
||||
flightState._positionHistory.set('00a003', fixes);
|
||||
const last = Cesium.Cartesian3.clone(fixes[2].position);
|
||||
const tracking = createTracking({
|
||||
flightState,
|
||||
services: svc,
|
||||
parts: {
|
||||
motion: {
|
||||
_isTrackWarmingUp: () => false,
|
||||
_trackedTrailCached: () => last,
|
||||
_trackedDisplayPosition: () => last,
|
||||
_trackedModelCenterWorld: () => null,
|
||||
_trackedModelEnvelopeM: () => null,
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
tracking._startTrail('00a003');
|
||||
assert.equal(calls.at(-1).length, 3);
|
||||
const head = added.find((d) =>
|
||||
String(d.id).startsWith('gev-trail:mil-head-'),
|
||||
);
|
||||
assert.deepEqual(head.polyline.positions.getValue(), []);
|
||||
});
|
||||
@@ -178,9 +178,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 */
|
||||
|
||||
|
||||
+286
-104
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||
@@ -19,14 +19,33 @@ import {
|
||||
TRACKED_MODEL_MAX_LOAD_FAILS,
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
STALE_ALPHA,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.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';
|
||||
|
||||
const STALE_TRACKED_COLOR = TRACKED_ICON_COLOR.withAlpha(STALE_ALPHA);
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -206,24 +225,98 @@ export function createTracking({
|
||||
_setCockpitContactMode(active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: amber, faded while stale, clear while the 3D model is the visual. */
|
||||
|
||||
function _trackedIconColor() {
|
||||
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
|
||||
return AMBER_TRANSPARENT;
|
||||
return _trackedStaleCue(flightState._trackedIcao).dimmed
|
||||
? STALE_TRACKED_COLOR
|
||||
: TRACKED_ICON_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the multi-line presentation text for the protected tracked host card.
|
||||
* Lines: callsign, type/registration, operator/altitude.
|
||||
* 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
|
||||
* altitude/speed 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 altitude/speed are never
|
||||
* presented as live.
|
||||
* @param {Object|null} info - Flight metadata from _flightData
|
||||
* @param {string} icao24 - ICAO hex identifier (fallback display name)
|
||||
* @returns {string} Newline-separated label text
|
||||
*/
|
||||
|
||||
function _buildTrackedLabel(info, icao24) {
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? ` · ${t('STALE')}`
|
||||
: '';
|
||||
const cue = _trackedStaleCue(icao24).text;
|
||||
const stale = cue ? ` · ${cue}` : '';
|
||||
const callsign =
|
||||
(parts.queries._toCleanText(info?.callsign) ||
|
||||
parts.queries._toCleanText(info?.registration) ||
|
||||
@@ -456,33 +549,112 @@ 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(_trailFloorPosition(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,
|
||||
_trailFloorPosition(position),
|
||||
timeMs,
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
_refreshTrailDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the trail BODY — 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 this primitive only rebuilds on a real fix (poll
|
||||
* cadence), never at motion cadence.
|
||||
* 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, floored like the head) 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() - RENDER_DELAY_SEC * 1000;
|
||||
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,
|
||||
_trailFloorPosition(Cesium.Cartesian3.clone(drawn)),
|
||||
displayMs,
|
||||
TRAIL_MAX_POINTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
|
||||
_refreshTrailDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the trail BODY — 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.
|
||||
*/
|
||||
|
||||
function _refreshTrailDisplay() {
|
||||
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() - RENDER_DELAY_SEC * 1000,
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,25 +667,27 @@ 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 via _appendTrailFix as they age.
|
||||
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)) {
|
||||
// Floor grounded-history seeds (round 2): a grounded contact's stored
|
||||
// fixes carry the pre-datum default height — the trail copy sits on
|
||||
// the surface instead (pure lift; airborne fixes pass through).
|
||||
flightState._trailPositions.push(
|
||||
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
|
||||
);
|
||||
}
|
||||
if (Number.isFinite(fix.reportEpochMs)) continue;
|
||||
// Floor grounded-history seeds (round 2): a grounded contact's stored
|
||||
// fixes carry the pre-datum default height — the trail copy sits on
|
||||
// the surface instead (pure lift; airborne fixes pass through).
|
||||
insertTrailFix(
|
||||
flightState._trailFixes,
|
||||
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
|
||||
historyFixEpochMs(fix),
|
||||
TRAIL_MAX_POINTS,
|
||||
{ nullAltitude: fix.inventedHeight === true },
|
||||
);
|
||||
}
|
||||
if (!flightState._trail && flightState._viewer) {
|
||||
flightState._trail = createTrail(flightState._viewer, {
|
||||
@@ -534,14 +708,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 — ~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 [];
|
||||
@@ -549,12 +722,20 @@ 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 [];
|
||||
const start = last.position;
|
||||
// Floor the head too (round 2): a grounded tracked contact's DR
|
||||
// display height is the pre-datum default — without this the last
|
||||
// segment dives underground while taxiing.
|
||||
@@ -595,10 +776,10 @@ export function createTracking({
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
|
||||
* stride-thinned to the remaining vertex budget, then spliced strictly older
|
||||
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
|
||||
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
|
||||
* Fire-and-forget optional source history backfill (PRD F2). Every trace
|
||||
* point strictly older than the oldest seeded fix is spliced AHEAD of the
|
||||
* locally accumulated fine segment, each leg its own segment, capped at
|
||||
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
|
||||
* malformed) silently keeps the local-only trail.
|
||||
* @param {string} icao24 - ICAO hex identifier being tracked.
|
||||
* @param {number} token - Backfill token captured at request time.
|
||||
@@ -644,7 +825,13 @@ export function createTracking({
|
||||
lon = point.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
|
||||
parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM });
|
||||
parsed.push({
|
||||
lat,
|
||||
lon,
|
||||
baroAltitudeM: point.baroAltitudeM,
|
||||
onGround: point.onGround === true,
|
||||
timeMs: point.observedAtMs,
|
||||
});
|
||||
}
|
||||
if (!parsed.length) return;
|
||||
|
||||
@@ -667,39 +854,34 @@ export function createTracking({
|
||||
)
|
||||
return;
|
||||
|
||||
let older = [];
|
||||
let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet
|
||||
for (const { lat, lon, baroAltitudeM } of parsed) {
|
||||
const baroM =
|
||||
baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon);
|
||||
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
|
||||
// Ground/no-alt point with an unresolved floor: hold the previous
|
||||
// waypoint's altitude (continuity — never a dive to a made-up depth).
|
||||
// Leading points with nothing to carry keep the old low breadcrumb
|
||||
// sentinel (an arbitrary placeholder, never a reported altitude).
|
||||
if (altM == null) altM = lastAltM != null ? lastAltM : 50;
|
||||
lastAltM = altM;
|
||||
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
|
||||
}
|
||||
|
||||
// Stride-thin the backfill so it plus the live fine segment fit the cap.
|
||||
const budget = Math.max(
|
||||
1,
|
||||
TRAIL_MAX_POINTS - flightState._trailPositions.length,
|
||||
// Every trace point is kept, each leg its own segment. A 'ground' point
|
||||
// is at altitude 0, floored at the ground. A point 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, baroAltitudeM, onGround, timeMs }) => {
|
||||
const known = Number.isFinite(baroAltitudeM)
|
||||
? baroAltitudeM
|
||||
: 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,
|
||||
);
|
||||
if (older.length > budget) {
|
||||
const stride = Math.ceil(older.length / budget);
|
||||
const thinned = [];
|
||||
for (let i = 0; i < older.length; i += stride) thinned.push(older[i]);
|
||||
older = thinned;
|
||||
}
|
||||
|
||||
flightState._trailPositions = older.concat(flightState._trailPositions);
|
||||
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
|
||||
flightState._trailPositions = flightState._trailPositions.slice(
|
||||
flightState._trailPositions.length - TRAIL_MAX_POINTS,
|
||||
);
|
||||
}
|
||||
_refreshTrailDisplay();
|
||||
}
|
||||
|
||||
@@ -709,7 +891,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 &&
|
||||
@@ -926,15 +1110,9 @@ export function createTracking({
|
||||
(CLASS_SCALE_2D[
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass
|
||||
] || 1),
|
||||
// Solid amber 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)
|
||||
? AMBER_TRANSPARENT
|
||||
: TRACKED_ICON_COLOR,
|
||||
false,
|
||||
),
|
||||
// Solid amber (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,
|
||||
@@ -1157,6 +1335,9 @@ export function createTracking({
|
||||
_setCockpitContactMode,
|
||||
_applyCockpitState,
|
||||
_buildTrackedLabel,
|
||||
_trackedStaleCue,
|
||||
_trackedIconColor,
|
||||
_staleCueTick,
|
||||
_updateTrackedLabelModel,
|
||||
_syncTrackedBillboardImage,
|
||||
_refreshTr3bContact,
|
||||
@@ -1168,6 +1349,7 @@ export function createTracking({
|
||||
_syncTracked2dRotation,
|
||||
_trailFloorPosition,
|
||||
_appendTrailFix,
|
||||
_trailTick,
|
||||
_refreshTrailDisplay,
|
||||
_startTrail,
|
||||
_backfillTrail,
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
|
||||
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) && Number.isFinite(ground)
|
||||
? Math.max(alt, ground + 1)
|
||||
: Number.isFinite(alt)
|
||||
? alt
|
||||
: Number.isFinite(ground)
|
||||
? ground + 1
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A fake trail that records what draws: segments of two or more points. */
|
||||
function recordingTrail() {
|
||||
const calls = [];
|
||||
return {
|
||||
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 trackedHarness({ liveFixes = [], trace = null, nowMs, floor, head }) {
|
||||
const trail = recordingTrail();
|
||||
const svc = services({ trails: trail, floor });
|
||||
const flightState = createFlightState({
|
||||
services: svc,
|
||||
source: {
|
||||
label: 'Fixture military aircraft',
|
||||
async getTrack() {
|
||||
if (!trace) throw new Error('404');
|
||||
return { records: trace };
|
||||
},
|
||||
},
|
||||
});
|
||||
const added = [];
|
||||
flightState._viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add(definition) {
|
||||
added.push(definition);
|
||||
return definition;
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
flightState._trackedIcao = 'ae0001';
|
||||
flightState.now = () => nowMs;
|
||||
flightState._positionHistory.set(
|
||||
'ae0001',
|
||||
liveFixes.map((fix) => ({
|
||||
time: Cesium.JulianDate.fromDate(new Date(fix.timeMs)),
|
||||
epochMs: fix.timeMs,
|
||||
position: Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 9_000),
|
||||
})),
|
||||
);
|
||||
const tracking = createTracking({
|
||||
flightState,
|
||||
services: svc,
|
||||
parts: {
|
||||
motion: {
|
||||
_isTrackWarmingUp: () => false,
|
||||
_trackedTrailCached: () => head ?? null,
|
||||
_trackedDisplayPosition: () => head ?? null,
|
||||
_trackedModelCenterWorld: () => null,
|
||||
_trackedModelEnvelopeM: () => null,
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
const headPositions = () =>
|
||||
added
|
||||
.find((definition) =>
|
||||
String(definition.id).startsWith('gev-trail:mil-head-'),
|
||||
)
|
||||
.polyline.positions.getValue();
|
||||
return { trail, tracking, flightState, headPositions };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** readsb-shaped trace points as the military source parses them. */
|
||||
function point(timeMs, lat, lon, baroAltitudeM = 9_000, onGround = false) {
|
||||
return {
|
||||
observedAtMs: timeMs,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
baroAltitudeM,
|
||||
onGround,
|
||||
};
|
||||
}
|
||||
|
||||
function leg(startMs, lat, lon, count) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
point(startMs + i * MINUTE, lat, lon + i * 0.12),
|
||||
);
|
||||
}
|
||||
|
||||
test('a multi-leg military trace becomes separate segments with no line across a gap', async () => {
|
||||
const t0 = Date.UTC(2026, 10, 8, 0, 0);
|
||||
const first = leg(t0, 36, -115, 5);
|
||||
const second = leg(t0 + 5 * MINUTE + 3 * HOUR, 35, -106, 5);
|
||||
const third = leg(t0 + 10 * MINUTE + 5 * HOUR, 38, -77, 5);
|
||||
const trace = [...first, ...second, ...third];
|
||||
const liveStart = t0 + 15 * MINUTE + 5 * HOUR;
|
||||
const liveFixes = [0, 1, 2].map((i) => ({
|
||||
timeMs: liveStart + i * MINUTE,
|
||||
lat: 38,
|
||||
lon: -77 + 0.6 + i * 0.12,
|
||||
}));
|
||||
const { trail, flightState, tracking } = trackedHarness({
|
||||
liveFixes,
|
||||
trace,
|
||||
nowMs: liveStart + 3 * MINUTE,
|
||||
});
|
||||
tracking._startTrail('ae0001');
|
||||
await settle();
|
||||
assert.equal(flightState._trailFixes.length, trace.length + 3);
|
||||
assert.deepEqual(
|
||||
trail.last().map((segment) => segment.length),
|
||||
[5, 5, 8],
|
||||
);
|
||||
});
|
||||
|
||||
test('a military trace point with no altitude breaks the line and is never given a made-up height', async () => {
|
||||
const t0 = Date.UTC(2026, 10, 8, 0, 0);
|
||||
const trace = leg(t0, 35, -106, 7);
|
||||
trace[3] = point(trace[3].observedAtMs, 35, -105.64, null);
|
||||
const liveFixes = [{ timeMs: t0 + 7 * MINUTE, lat: 35, lon: -105.16 }];
|
||||
const { trail, flightState, tracking } = trackedHarness({
|
||||
liveFixes,
|
||||
trace,
|
||||
nowMs: t0 + 9 * MINUTE,
|
||||
});
|
||||
tracking._startTrail('ae0001');
|
||||
await settle();
|
||||
// Skipped (no floor known): 3 + 3 + the live fix drawn.
|
||||
assert.equal(flightState._trailFixes.length, trace.length - 1 + 1);
|
||||
assert.deepEqual(
|
||||
trail.last().map((segment) => segment.length),
|
||||
[3, 4],
|
||||
);
|
||||
for (const fix of flightState._trailFixes) {
|
||||
const height = Cesium.Cartographic.fromCartesian(fix.position).height;
|
||||
assert.ok(Math.abs(height - 9_000) < 1, `no invented height: ${height}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a military ground point is drawn on the ground and stays joined', async () => {
|
||||
const t0 = Date.UTC(2026, 10, 8, 0, 0);
|
||||
// Taxiing: 'ground' points a few hundred metres apart, one minute apart.
|
||||
const trace = Array.from({ length: 4 }, (_, i) =>
|
||||
point(t0 + i * MINUTE, 35, -106.6 + i * 0.004, null, true),
|
||||
);
|
||||
const liveFixes = [{ timeMs: t0 + 4 * MINUTE, lat: 35, lon: -106.584 }];
|
||||
const { trail, flightState, tracking } = trackedHarness({
|
||||
liveFixes,
|
||||
trace,
|
||||
nowMs: t0 + 6 * MINUTE,
|
||||
floor: () => 1_590,
|
||||
});
|
||||
tracking._startTrail('ae0001');
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
trail.last().map((segment) => segment.length),
|
||||
[5],
|
||||
);
|
||||
const height = Cesium.Cartographic.fromCartesian(
|
||||
flightState._trailFixes[0].position,
|
||||
).height;
|
||||
assert.ok(Math.abs(height - 1_591) < 1);
|
||||
});
|
||||
|
||||
test('the military 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: 35,
|
||||
lon: -106 + i * 0.12,
|
||||
}));
|
||||
const near = Cesium.Cartesian3.fromDegrees(-105.7, 35, 9_000);
|
||||
const joined = trackedHarness({
|
||||
liveFixes,
|
||||
nowMs: t0 + 3 * MINUTE,
|
||||
head: near,
|
||||
});
|
||||
joined.tracking._startTrail('ae0001');
|
||||
assert.equal(joined.headPositions().length, 2);
|
||||
|
||||
const far = Cesium.Cartesian3.fromDegrees(-103, 35, 9_000);
|
||||
const detached = trackedHarness({
|
||||
liveFixes,
|
||||
nowMs: t0 + 3 * MINUTE,
|
||||
head: far,
|
||||
});
|
||||
detached.tracking._startTrail('ae0001');
|
||||
assert.deepEqual(detached.headPositions(), []);
|
||||
});
|
||||
|
||||
test('a full 24 h military trace fits within TRAIL_MAX_POINTS without thinning', async () => {
|
||||
const day = 24 * 60;
|
||||
const t0 = Date.UTC(2026, 10, 8, 0, 0);
|
||||
const trace = leg(t0, 20, -150, day);
|
||||
const liveStart = t0 + day * MINUTE;
|
||||
const liveFixes = [{ timeMs: liveStart, lat: 20, lon: -150 + day * 0.12 }];
|
||||
const { flightState, tracking } = trackedHarness({
|
||||
liveFixes,
|
||||
trace,
|
||||
nowMs: liveStart + MINUTE,
|
||||
});
|
||||
tracking._startTrail('ae0001');
|
||||
await settle();
|
||||
assert.ok(TRAIL_MAX_POINTS >= day + 500);
|
||||
assert.equal(flightState._trailFixes.length, day + 1);
|
||||
});
|
||||
@@ -0,0 +1,717 @@
|
||||
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';
|
||||
|
||||
// A tracked military contact's trail between reports is the path its icon
|
||||
// was drawn along, recorded from the real display path frame by frame.
|
||||
|
||||
const SEC = 1000;
|
||||
const ICAO = '00a004';
|
||||
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,
|
||||
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 }) {
|
||||
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: {} });
|
||||
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: {
|
||||
_toCleanText: (value) => (typeof value === 'string' ? value.trim() : ''),
|
||||
_formatAltitude: (ft) => `${ft} ft`,
|
||||
},
|
||||
};
|
||||
parts.motion = createMotion({ flightState, services: svc, parts });
|
||||
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:mil-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 = {
|
||||
altitudeFt: 4_124,
|
||||
renderAltitudeM: 1257,
|
||||
rawLat: 37.7066,
|
||||
rawLon: -122.3755,
|
||||
speedMps: 112,
|
||||
track: 340,
|
||||
turnRateDps: -2,
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
lastContactEpochMs: T0 + 200 * SEC,
|
||||
};
|
||||
return { fixes, info };
|
||||
}
|
||||
|
||||
test('military: a report newer than the display time arrives mid-turn, and the trail between the 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.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('military: 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.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('military: 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 = {
|
||||
altitudeFt: 21_000,
|
||||
renderAltitudeM: 6_400,
|
||||
rawLat: 43,
|
||||
rawLon: -108.05,
|
||||
speedMps: 140,
|
||||
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('military: 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._buildTrackedLabel(h.flightState.records.data.get(ICAO), 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._buildTrackedLabel(h.flightState.records.data.get(ICAO), ICAO),
|
||||
new RegExp(`SIGNAL LOST · ${minutes} min`),
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('military: 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('military: 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('military: 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('military: 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('military: 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('military: 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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
// Event cards on the globe: a picture, the headline in two lines, and "age ·
|
||||
// sources · sides", with the severity colour down the left edge and the
|
||||
// event's marker as a badge on the picture. Painted on a canvas and handed to
|
||||
// the world overlay as a thumbnail entry, so the overlay places them, keeps
|
||||
// them apart and draws their leader to the marker, as it does CCTV cards.
|
||||
//
|
||||
// The picture is the publisher's own (hotlinked, never copied, credited on
|
||||
// the card); until it loads, or without one, the card shows a tile in the
|
||||
// severity colour with the category icon.
|
||||
|
||||
import { WORLD_OVERLAY_STYLE } from '../../overlays/worldOverlayTokens.js';
|
||||
import { NEWS_SEVERITY } from './model.js';
|
||||
import { paintMarker } from './sprites.js';
|
||||
|
||||
/** Card size in CSS pixels. */
|
||||
export const CARD_W = 168;
|
||||
export const CARD_H = 136;
|
||||
const PAD = 4;
|
||||
const PIC_H = 84;
|
||||
const EDGE = 3;
|
||||
const SCALE = 2; // canvas pixels per CSS pixel
|
||||
const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
||||
const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace';
|
||||
const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
||||
// The map shows about ten cards plus the clicked one; a few more stay ready
|
||||
// for a pan back. The least recently drawn card goes, and its picture with it.
|
||||
export const MAX_CARDS = 24;
|
||||
|
||||
const pictures = new Map(); // url → {image, state: 'loading'|'ok'|'failed', waiters}
|
||||
const cards = new Map(); // event id → {canvas, key, view, look, pic}, oldest first
|
||||
|
||||
/** Forget a picture no kept card shows any more. */
|
||||
function releasePicture(url) {
|
||||
if (!url || [...cards.values()].some((card) => card.view.image === url))
|
||||
return;
|
||||
const entry = pictures.get(url);
|
||||
if (!entry) return;
|
||||
entry.waiters.clear();
|
||||
entry.image.onload = entry.image.onerror = null;
|
||||
pictures.delete(url);
|
||||
}
|
||||
|
||||
function picture(url, onLoad) {
|
||||
let entry = pictures.get(url);
|
||||
if (!entry) {
|
||||
const image = new Image();
|
||||
// No referrer: the publisher sees a plain image request, as from a link preview.
|
||||
image.referrerPolicy = 'no-referrer';
|
||||
image.decoding = 'async';
|
||||
entry = { image, state: 'loading', waiters: new Set() };
|
||||
pictures.set(url, entry);
|
||||
image.onload = () => {
|
||||
entry.state = 'ok';
|
||||
for (const done of entry.waiters) done();
|
||||
entry.waiters.clear();
|
||||
};
|
||||
image.onerror = () => {
|
||||
entry.state = 'failed';
|
||||
entry.waiters.clear();
|
||||
};
|
||||
image.src = url;
|
||||
}
|
||||
if (entry.state === 'loading' && onLoad) entry.waiters.add(onLoad);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/** `text` in at most `lines` lines of `width`, the last cut with an ellipsis. */
|
||||
export function wrapLines(ctx, text, width, lines) {
|
||||
const words = String(text || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const out = [];
|
||||
let line = '';
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const next = line ? `${line} ${words[i]}` : words[i];
|
||||
if (ctx.measureText(next).width <= width || !line) {
|
||||
line = next;
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
line = words[i];
|
||||
if (out.length === lines - 1) {
|
||||
line = words.slice(i).join(' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (line) out.push(line);
|
||||
const last = out.length - 1;
|
||||
if (last >= 0 && ctx.measureText(out[last]).width > width) {
|
||||
let cut = out[last];
|
||||
while (cut && ctx.measureText(`${cut}…`).width > width)
|
||||
cut = cut.slice(0, -1);
|
||||
out[last] = `${cut.trimEnd()}…`;
|
||||
}
|
||||
return out.slice(0, lines);
|
||||
}
|
||||
|
||||
/** Cover-fit an image into a box. */
|
||||
function drawCover(ctx, image, x, y, w, h) {
|
||||
const iw = image.naturalWidth || image.width;
|
||||
const ih = image.naturalHeight || image.height;
|
||||
if (!iw || !ih) return;
|
||||
const s = Math.max(w / iw, h / ih);
|
||||
const sw = w / s;
|
||||
const sh = h / s;
|
||||
ctx.drawImage(image, (iw - sw) / 2, (ih - sh) / 2, sw, sh, x, y, w, h);
|
||||
}
|
||||
|
||||
function paint(canvas, view, look, pic) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(SCALE, 0, 0, SCALE, 0, 0);
|
||||
ctx.clearRect(0, 0, CARD_W, CARD_H);
|
||||
const color = (NEWS_SEVERITY[look.severity] || NEWS_SEVERITY[0]).color;
|
||||
|
||||
roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = WORLD_OVERLAY_STYLE.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
// Severity down the left edge.
|
||||
ctx.save();
|
||||
roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, EDGE, CARD_H);
|
||||
ctx.restore();
|
||||
|
||||
const px = PAD + EDGE;
|
||||
const pw = CARD_W - px - PAD;
|
||||
ctx.save();
|
||||
roundRect(ctx, px, PAD, pw, PIC_H, 3);
|
||||
ctx.clip();
|
||||
if (pic?.state === 'ok') {
|
||||
try {
|
||||
drawCover(ctx, pic.image, px, PAD, pw, PIC_H);
|
||||
} catch {
|
||||
pic.state = 'failed';
|
||||
}
|
||||
}
|
||||
if (pic?.state !== 'ok') {
|
||||
// No picture: a plain glass tile with the event's marker.
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fillRect(px, PAD, pw, PIC_H);
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
||||
ctx.fillRect(px, PAD, pw, PIC_H);
|
||||
paintMarker(ctx, look, px + pw / 2, PAD + PIC_H / 2, 34);
|
||||
}
|
||||
ctx.restore();
|
||||
// The event's marker as a badge, so the card reads with the map.
|
||||
if (pic?.state === 'ok') paintMarker(ctx, look, px + 13, PAD + 13, 18);
|
||||
// The picture's publisher, always named on it.
|
||||
if (pic?.state === 'ok' && view.credit) {
|
||||
ctx.font = CREDIT_FONT;
|
||||
const w = Math.min(pw - 30, ctx.measureText(view.credit).width + 8);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||||
ctx.fillRect(px + pw - w, PAD + PIC_H - 12, w, 12);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
wrapLines(ctx, view.credit, w - 8, 1)[0] || '',
|
||||
px + pw - w + 4,
|
||||
PAD + PIC_H - 6,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = TITLE_FONT;
|
||||
wrapLines(ctx, view.title, pw, 2).forEach((line, i) =>
|
||||
ctx.fillText(line, px, PAD + PIC_H + 14 + i * 13),
|
||||
);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = META_FONT;
|
||||
ctx.fillText(wrapLines(ctx, view.meta, pw, 1)[0] || '', px, CARD_H - PAD - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas of an event's card, one per event: a change in what it shows
|
||||
* (the age in its meta line ticks by the minute) repaints the same canvas.
|
||||
* `onChange` runs when its picture arrives and the canvas has been repainted
|
||||
* (ask the scene for a frame).
|
||||
* @returns {HTMLCanvasElement|null} null where there is no canvas
|
||||
*/
|
||||
export function eventCard(id, view, look, onChange) {
|
||||
const key = `${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.credit || ''}|${view.title}|${view.meta}`;
|
||||
let card = cards.get(id);
|
||||
if (card) {
|
||||
// Most recently drawn last.
|
||||
cards.delete(id);
|
||||
cards.set(id, card);
|
||||
if (card.key === key) return card.canvas;
|
||||
} else {
|
||||
const canvas = globalThis.document?.createElement?.('canvas');
|
||||
if (
|
||||
!canvas?.getContext?.('2d') ||
|
||||
typeof globalThis.Path2D !== 'function' ||
|
||||
typeof globalThis.Image !== 'function'
|
||||
)
|
||||
return null;
|
||||
canvas.width = CARD_W * SCALE;
|
||||
canvas.height = CARD_H * SCALE;
|
||||
card = { canvas, key: null, view: {}, look, pic: null };
|
||||
cards.set(id, card);
|
||||
while (cards.size > MAX_CARDS) {
|
||||
const [oldest, gone] = cards.entries().next().value;
|
||||
cards.delete(oldest);
|
||||
releasePicture(gone.view.image);
|
||||
}
|
||||
}
|
||||
const previous = card.view.image;
|
||||
card.key = key;
|
||||
card.view = view;
|
||||
card.look = look;
|
||||
const shown = card;
|
||||
card.pic = view.image
|
||||
? picture(view.image, () => {
|
||||
if (cards.get(id) !== shown || shown.view.image !== view.image) return;
|
||||
paint(shown.canvas, shown.view, shown.look, shown.pic);
|
||||
onChange?.();
|
||||
})
|
||||
: null;
|
||||
if (previous && previous !== view.image) releasePicture(previous);
|
||||
paint(card.canvas, view, look, card.pic);
|
||||
return card.canvas;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MAX_CARDS, eventCard } from './cards.js';
|
||||
|
||||
const look = { severity: 3, category: 'strike', rim: 'confirmed', size: 20 };
|
||||
const view = (extra = {}) => ({
|
||||
title: 'Strike on Kharkiv',
|
||||
meta: '10 min · 6 sources · UA, RU',
|
||||
image: 'https://a.test/p.jpg',
|
||||
credit: 'A',
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Node has no canvas: a 2D context that accepts every call, and an Image that
|
||||
// records what was asked for.
|
||||
function fakeBrowser(t) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
Path2D: globalThis.Path2D,
|
||||
Image: globalThis.Image,
|
||||
};
|
||||
const images = [];
|
||||
const ctx = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, name) =>
|
||||
name === 'measureText'
|
||||
? (text) => ({ width: String(text).length * 5 })
|
||||
: name === 'createLinearGradient'
|
||||
? () => ({ addColorStop() {} })
|
||||
: () => {},
|
||||
set: () => true,
|
||||
},
|
||||
);
|
||||
let canvases = 0;
|
||||
globalThis.document = {
|
||||
createElement: () => {
|
||||
canvases++;
|
||||
return { getContext: () => ctx };
|
||||
},
|
||||
};
|
||||
globalThis.Path2D = class {};
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
images.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
for (const [name, value] of Object.entries(saved))
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else globalThis[name] = value;
|
||||
});
|
||||
return { images, canvases: () => canvases };
|
||||
}
|
||||
|
||||
test('a card keeps one canvas per event while its age ticks', (t) => {
|
||||
const browser = fakeBrowser(t);
|
||||
const first = eventCard('ev-a', view(), look);
|
||||
assert.ok(first);
|
||||
// The meta line's minute-level age changes: the same canvas, repainted.
|
||||
const later = eventCard('ev-a', view({ meta: '11 min · 6 sources' }), look);
|
||||
assert.equal(later, first);
|
||||
assert.equal(browser.canvases(), 1);
|
||||
// One picture request for the event, however often it is drawn.
|
||||
assert.equal(browser.images.length, 1);
|
||||
// Its picture arriving repaints and asks for a frame once; a card with no
|
||||
// picture never does.
|
||||
let frames = 0;
|
||||
const pictured = eventCard(
|
||||
'ev-b',
|
||||
view({ image: 'https://b.test/p.jpg' }),
|
||||
look,
|
||||
() => frames++,
|
||||
);
|
||||
assert.ok(pictured);
|
||||
browser.images.at(-1).onload();
|
||||
assert.equal(frames, 1);
|
||||
assert.ok(eventCard('ev-c', view({ image: null }), look));
|
||||
});
|
||||
|
||||
test('the card cache is bounded and evicts pictures with their cards', (t) => {
|
||||
const browser = fakeBrowser(t);
|
||||
const kept = eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look);
|
||||
for (let i = 0; i < MAX_CARDS * 3; i++) {
|
||||
eventCard(`ev-${i}`, view({ image: `https://p.test/${i}.jpg` }), look);
|
||||
// A card drawn every frame stays: the least recently drawn goes first.
|
||||
assert.equal(
|
||||
eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look),
|
||||
kept,
|
||||
);
|
||||
}
|
||||
const requested = browser.images.length;
|
||||
// The first event was evicted with its picture: drawing it again paints a
|
||||
// new canvas and asks for the picture anew.
|
||||
const again = eventCard(
|
||||
'ev-0',
|
||||
view({ image: 'https://p.test/0.jpg' }),
|
||||
look,
|
||||
);
|
||||
assert.ok(again);
|
||||
assert.equal(browser.images.length, requested + 1);
|
||||
// Evicted pictures are let go: their load handlers are cleared.
|
||||
assert.equal(browser.images[1].onload, null);
|
||||
// The recent ones are still cached.
|
||||
const recent = MAX_CARDS * 3 - 1;
|
||||
eventCard(
|
||||
`ev-${recent}`,
|
||||
view({ image: `https://p.test/${recent}.jpg` }),
|
||||
look,
|
||||
);
|
||||
assert.equal(browser.images.length, requested + 1);
|
||||
});
|
||||
|
||||
test('without a canvas no card is made and nothing is cached', (t) => {
|
||||
fakeBrowser(t);
|
||||
globalThis.document = { createElement: () => ({ getContext: () => null }) };
|
||||
assert.equal(eventCard('none', view(), look), null);
|
||||
globalThis.document = undefined;
|
||||
assert.equal(eventCard('none', view(), look), null);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,826 @@
|
||||
// Pure presentation model for the news layer: severity, category glyphs,
|
||||
// attestation, marker looks and placement, the feed's filters and paging, and
|
||||
// the event cards the News panel and the globe show. No Cesium, no DOM.
|
||||
|
||||
import { getLocale, msg, t, tn } from '../../i18n/index.js';
|
||||
|
||||
export const NEWS_LAYER_ID = 'news';
|
||||
export const NEWS_OVERLAY_SOURCE_ID = 'news';
|
||||
export const NEWS_PICK_PREFIX = 'news:';
|
||||
export const NEWS_FEED_PAGE = 60;
|
||||
export const NEWS_MAP_LIMIT = 500;
|
||||
/** News is real time only: nothing older than a day is shown anywhere. */
|
||||
export const NEWS_WINDOW_MS = 24 * 3_600_000;
|
||||
|
||||
/**
|
||||
* The window a server serves, in ms: its `windowHours` (a whole number of
|
||||
* hours up to 24; a host may serve fewer), else the full day.
|
||||
*/
|
||||
export function newsWindowMs(hours) {
|
||||
return Number.isInteger(hours) && hours >= 1 && hours <= 24
|
||||
? hours * 3_600_000
|
||||
: NEWS_WINDOW_MS;
|
||||
}
|
||||
|
||||
/** The windows the News panel offers, longest first. */
|
||||
export const NEWS_WINDOW_CHOICES = Object.freeze([24, 12, 6, 3, 1]);
|
||||
|
||||
/**
|
||||
* The windows offered under a server's own window of `maxHours`: that window
|
||||
* itself, then each shorter choice.
|
||||
*/
|
||||
export function newsWindowChoices(maxHours) {
|
||||
const cap = newsWindowMs(maxHours) / 3_600_000;
|
||||
return [cap, ...NEWS_WINDOW_CHOICES.filter((hours) => hours < cap)];
|
||||
}
|
||||
// A publish time this far ahead of the clock is skew, not a future story
|
||||
// (as data/regionalNewsWindow.js).
|
||||
const NEWS_CLOCK_SKEW_MS = 15 * 60_000;
|
||||
/** At most this many reports are kept in the feed list, newest first. */
|
||||
export const NEWS_FEED_MAX = 600;
|
||||
// Above this camera height the layer shows one beacon per hotspot; below it
|
||||
// (or once a hotspot is chosen) the outlines and individual reports.
|
||||
export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000;
|
||||
// "In view": reports near the view centre, only once the camera is low enough
|
||||
// for "here" to mean a region, read again when it settles in a new cell.
|
||||
// Near is inside the area the server names there (a country, a large
|
||||
// country's state, a disputed area), else, at sea, within the radius.
|
||||
export const NEWS_IN_VIEW_MAX_HEIGHT_M = 3_000_000;
|
||||
export const NEWS_IN_VIEW_RADIUS_KM = 500;
|
||||
export const NEWS_IN_VIEW_LIMIT = 12;
|
||||
/** The Local section lists at most this many reports near the view centre. */
|
||||
export const NEWS_LOCAL_LIMIT = 40;
|
||||
/**
|
||||
* The News panel's sections: Conflicts (the hotspots' significant news, on the
|
||||
* map whenever the layer is on) and Local (everything near the view centre,
|
||||
* on the map while the section is chosen).
|
||||
*/
|
||||
export const NEWS_SECTIONS = Object.freeze(['conflicts', 'local']);
|
||||
export const NEWS_SETTLE_MS = 800;
|
||||
export const NEWS_CELL_DEG = 0.5;
|
||||
/** A feed card's click flies the camera to this height over the event. */
|
||||
export const NEWS_FOCUS_HEIGHT_M = 150_000;
|
||||
// Minor news (a broad outlet's prices, sport, health) is left out unless asked for.
|
||||
export const NEWS_RELEVANT = 'core,related';
|
||||
|
||||
const SOURCE_LABEL = msg('Source');
|
||||
const DISPUTED = msg('Disputed: {name}');
|
||||
const VIA = msg('via {source}');
|
||||
const MEDIA_LABELS = { video: msg('Video'), photo: msg('Photos') };
|
||||
const CLAIMED_BY = msg('Claimed by {who}');
|
||||
const CLAIMED = msg('Claimed');
|
||||
const UNCONFIRMED = msg('Not yet confirmed by others');
|
||||
const REPORTED_BY_SIDE = msg('Reported by {side} sources');
|
||||
const REPORTED_BY_SIDES = msg('Reported by {sides}');
|
||||
const SIDE_SOURCES = msg('{side} sources');
|
||||
const OFFICIAL = msg('Official');
|
||||
const OFFICIAL_AFFILIATIONS = new Set(['military', 'state']);
|
||||
const ATTESTATION = {
|
||||
'both-sides': msg('Reported by both sides'),
|
||||
independent: msg('Independently reported'),
|
||||
single: msg('Single source'),
|
||||
};
|
||||
const NEUTRAL_SIDES = Object.freeze({
|
||||
intl: msg('International'),
|
||||
independent: msg('Independent'),
|
||||
unknown: msg('Unattributed'),
|
||||
external: msg('Outside party'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Short chip names for the curated hotspots; the API's own English name is
|
||||
* the fallback for any other.
|
||||
*/
|
||||
export const NEWS_HOTSPOT_LABELS = Object.freeze({
|
||||
'ua-ru-war': msg('Ukraine–Russia'),
|
||||
'israel-gaza-lebanon': msg('Israel–Gaza–Lebanon'),
|
||||
'iran-hormuz': msg('Iran–Hormuz'),
|
||||
yemen: msg('Yemen–Red Sea'),
|
||||
'sudan-war': msg('Sudan'),
|
||||
sahel: msg('Sahel'),
|
||||
elsewhere: msg('Elsewhere'),
|
||||
// Core's own news (no news server): the world is one hotspot.
|
||||
worldwide: msg('Worldwide'),
|
||||
});
|
||||
|
||||
/** A hotspot's chip label in the interface language. */
|
||||
export function hotspotLabel(hotspot) {
|
||||
const key = NEWS_HOTSPOT_LABELS[hotspot?.id];
|
||||
if (key) return t(key);
|
||||
return hotspot?.catchAll ? t(NEWS_HOTSPOT_LABELS.elsewhere) : hotspot?.name;
|
||||
}
|
||||
|
||||
/** A country's name in the interface language, from its ISO code. */
|
||||
export function countryName(code) {
|
||||
if (!code) return '';
|
||||
try {
|
||||
return (
|
||||
new Intl.DisplayNames([getLocale(), 'en'], { type: 'region' }).of(code) ||
|
||||
code
|
||||
);
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
const AREA_IN_COUNTRY = msg('{region}, {country}');
|
||||
|
||||
/**
|
||||
* The Local section's area in the interface language: a country by its code,
|
||||
* a state with its country, a disputed area by its own name only.
|
||||
*/
|
||||
export function areaLabel(area) {
|
||||
if (!area) return '';
|
||||
if (area.kind === 'disputed' || !area.country) return area.name;
|
||||
if (area.kind === 'country') return countryName(area.country);
|
||||
return t(AREA_IN_COUNTRY, {
|
||||
region: area.name,
|
||||
country: countryName(area.country),
|
||||
});
|
||||
}
|
||||
|
||||
/** Label for a disputed area on the map: its name, marked as disputed. */
|
||||
export const disputedLabel = (area) => t(DISPUTED, { name: area.name });
|
||||
|
||||
// A category is told by its icon (a 16-unit SVG path, filled or stroked). On
|
||||
// the map and in the feed colour is severity (NEWS_SEVERITY), never the type
|
||||
// of event or a side.
|
||||
export const NEWS_CATEGORIES = Object.freeze({
|
||||
strike: {
|
||||
label: msg('Strike'),
|
||||
icon: 'M8 0.8 L9.6 5.2 L14.2 3.6 L11.2 7.6 L15.2 10.6 L10.3 10.6 L8 15.2 L5.7 10.6 L0.8 10.6 L4.8 7.6 L1.8 3.6 L6.4 5.2 Z',
|
||||
solid: true,
|
||||
},
|
||||
// A threat on its way (a drone's course, an air-raid alert): nothing has
|
||||
// hit yet. Not a physical event, so never a picture card.
|
||||
alert: {
|
||||
label: msg('Alert'),
|
||||
icon: 'M1.5 8 H11.5 M8 4.5 L11.5 8 L8 11.5 M14.5 3.5 V12.5',
|
||||
},
|
||||
'air-defence': {
|
||||
label: msg('Air defence'),
|
||||
icon: 'M8 1.5 L13.5 3.5 V8 C13.5 11.3 10.8 13.4 8 14.6 C5.2 13.4 2.5 11.3 2.5 8 V3.5 Z M8 4.5 V11',
|
||||
},
|
||||
ground: {
|
||||
label: msg('Ground fighting'),
|
||||
icon: 'M3 13 L12 4 M8.5 3.5 H12.5 V7.5 M13 13 L4 4 M3.5 7.5 V3.5 H7.5',
|
||||
},
|
||||
casualties: {
|
||||
label: msg('Casualties'),
|
||||
icon: 'M6.2 2 H9.8 V6.2 H14 V9.8 H9.8 V14 H6.2 V9.8 H2 V6.2 H6.2 Z',
|
||||
solid: true,
|
||||
},
|
||||
humanitarian: {
|
||||
label: msg('Humanitarian'),
|
||||
icon: 'M8 2.5 L14.5 13.5 H1.5 Z M8 2.5 V13.5',
|
||||
},
|
||||
infrastructure: {
|
||||
label: msg('Infrastructure'),
|
||||
icon: 'M9.2 1 L3.2 9 H7.6 L6.6 15 L12.8 6.8 H8.4 Z',
|
||||
solid: true,
|
||||
},
|
||||
disaster: {
|
||||
label: msg('Disaster'),
|
||||
icon: 'M1.5 6.5 C3.5 4.5 5.5 4.5 7.5 6.5 S11.5 8.5 14.5 6.5 M1.5 10.5 C3.5 8.5 5.5 8.5 7.5 10.5 S11.5 12.5 14.5 10.5',
|
||||
},
|
||||
protest: {
|
||||
label: msg('Protest'),
|
||||
icon: 'M2.5 6.2 V9.8 H5 L12.5 13.2 V2.8 L5 6.2 Z',
|
||||
},
|
||||
political: {
|
||||
label: msg('Political'),
|
||||
icon: 'M2 14 H14 M2.5 5.5 H13.5 M8 1.8 L14 4.8 H2 Z M4.2 6.5 V12 M8 6.5 V12 M11.8 6.5 V12',
|
||||
},
|
||||
// Business, trade and industry: a national feed's news in Local.
|
||||
economy: {
|
||||
label: msg('Economy'),
|
||||
icon: 'M2 13.5 H14 M2.5 11 L6 7.5 L8.5 9.5 L13.5 4.5 M10.5 4.5 H13.5 V7.5',
|
||||
},
|
||||
other: {
|
||||
label: msg('Other'),
|
||||
icon: 'M8 4.5 A3.5 3.5 0 1 0 8.01 4.5 Z',
|
||||
solid: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Severity 0–3, in colours GEV already uses: its dim and primary text, the
|
||||
// military layers' amber and the Cockpit's emergency red. They are drawn on
|
||||
// a dark disc, so from minor up lightness falls as severity rises and the
|
||||
// ramp still reads under colour blindness and in greyscale.
|
||||
export const NEWS_SEVERITY = Object.freeze([
|
||||
{ label: msg('No physical event'), color: '#7c878d' },
|
||||
{ label: msg('Minor incident'), color: '#e8eaed' },
|
||||
{ label: msg('Significant'), color: '#ffb800' },
|
||||
{ label: msg('Major'), color: '#ff4444' },
|
||||
]);
|
||||
/** The feed's default floor: significant and major events. */
|
||||
export const NEWS_DEFAULT_MIN_SEVERITY = 2;
|
||||
const PHYSICAL = new Set([
|
||||
'strike',
|
||||
'air-defence',
|
||||
'ground',
|
||||
'casualties',
|
||||
'infrastructure',
|
||||
'disaster',
|
||||
]);
|
||||
|
||||
export const categoryStyle = (category) =>
|
||||
NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other;
|
||||
export const categoryLabel = (category) => t(categoryStyle(category).label);
|
||||
/** Party codes (UA, RU, IL…) are shown as-is; neutral sides are translated. */
|
||||
export const sideLabel = (side) =>
|
||||
Object.hasOwn(NEUTRAL_SIDES, side) ? t(NEUTRAL_SIDES[side]) : side;
|
||||
|
||||
/**
|
||||
* A report's severity, 0–3: the server's (the local model's reading, else
|
||||
* its rules, raised to its event's), or a fallback from the category.
|
||||
*/
|
||||
export function reportSeverity(report) {
|
||||
const own = Number.isInteger(report?.severity) ? report.severity : null;
|
||||
const event = Number.isInteger(report?.event?.severity)
|
||||
? report.event.severity
|
||||
: null;
|
||||
if (own === null && event === null)
|
||||
return PHYSICAL.has(report?.category) ? 1 : 0;
|
||||
return Math.max(own ?? 0, event ?? 0);
|
||||
}
|
||||
export const severityStyle = (report) => NEWS_SEVERITY[reportSeverity(report)];
|
||||
export const severityLabel = (report) => t(severityStyle(report).label);
|
||||
|
||||
/** When a report (or its event) was last heard of, in ms; NaN when unknown. */
|
||||
const lastHeard = (report) =>
|
||||
Date.parse(report?.event?.lastAt || report?.publishedAt || '');
|
||||
|
||||
/**
|
||||
* Whether a time is inside the window (the last 24 hours unless the server
|
||||
* serves fewer; the only news shown); one more than 15 minutes ahead of the
|
||||
* clock is not.
|
||||
*/
|
||||
function inWindow(value, now, windowMs = NEWS_WINDOW_MS) {
|
||||
const at = Date.parse(value || '');
|
||||
return (
|
||||
Number.isFinite(at) &&
|
||||
now - at <= windowMs &&
|
||||
at - now <= NEWS_CLOCK_SKEW_MS
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a report is inside the window (the only news shown). */
|
||||
export function withinNewsWindow(
|
||||
report,
|
||||
now = Date.now(),
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
) {
|
||||
return inWindow(report?.publishedAt, now, windowMs);
|
||||
}
|
||||
|
||||
/** The event's one-report-per-side list, inside the last 24 hours only. */
|
||||
const recentSides = (report, now) =>
|
||||
(report?.event?.sides || []).filter((s) => inWindow(s.publishedAt, now));
|
||||
|
||||
/**
|
||||
* How a report or its event is attested, as a chip: `claimed` (someone's
|
||||
* claim, named where known), `confirmed` (both sides, or an independent
|
||||
* source), `side` (one party's own press), `single` (one report), or null.
|
||||
*/
|
||||
export function attestation(report) {
|
||||
const event = report?.event;
|
||||
const status = event?.status || (report?.claim ? 'claimed' : null);
|
||||
if (status === 'claimed') {
|
||||
const who = event?.claimedBy || report?.claim?.by;
|
||||
return {
|
||||
kind: 'claimed',
|
||||
label: who ? t(CLAIMED_BY, { who }) : t(CLAIMED),
|
||||
};
|
||||
}
|
||||
if (status === 'both-sides' || status === 'independent')
|
||||
return { kind: 'confirmed', label: t(ATTESTATION[status]) };
|
||||
if (status === 'side' && event.reportedSide)
|
||||
return {
|
||||
kind: 'side',
|
||||
label: t(REPORTED_BY_SIDE, { side: sideLabel(event.reportedSide) }),
|
||||
};
|
||||
if (status === 'single')
|
||||
return { kind: 'single', label: t(ATTESTATION.single) };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attestation chip under a report. An official channel's own post is
|
||||
* already marked Official: "Claimed by" itself would read as doubt about an
|
||||
* alert. It says only that nobody else has reported it yet, and nothing for
|
||||
* an alert, which is the channel's own notice.
|
||||
*/
|
||||
function ownClaimChip(report) {
|
||||
const chip = attestation(report);
|
||||
if (
|
||||
chip?.kind !== 'claimed' ||
|
||||
!OFFICIAL_AFFILIATIONS.has(report.affiliation)
|
||||
)
|
||||
return chip;
|
||||
const who = report.event?.claimedBy || report.claim?.by;
|
||||
if (who && who !== report.sourceName && who !== report.publisher) return chip;
|
||||
return report.category === 'alert'
|
||||
? null
|
||||
: { kind: 'claimed', label: t(UNCONFIRMED) };
|
||||
}
|
||||
|
||||
// The map card is a label, not a reader: long headlines are cut.
|
||||
export const NEWS_CARD_TITLE_MAX = 90;
|
||||
|
||||
/** A headline cut at a word to fit `max` characters. */
|
||||
export function shortTitle(title, max = NEWS_CARD_TITLE_MAX) {
|
||||
const text = String(title || '');
|
||||
if (text.length <= max) return text;
|
||||
const cut = text.slice(0, max - 1);
|
||||
const word = cut.lastIndexOf(' ');
|
||||
return `${word > max * 0.6 ? cut.slice(0, word) : cut}…`;
|
||||
}
|
||||
|
||||
/** The media a post carries ("Video", "Photos"), translated. */
|
||||
export function mediaLabels(report) {
|
||||
return (report?.media || [])
|
||||
.map((m) => (MEDIA_LABELS[m] ? t(MEDIA_LABELS[m]) : ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long ago, in few words: "now" under a minute, "N min" under an hour,
|
||||
* "N h" under a day. Older is never shown; it reads
|
||||
* "24 h".
|
||||
*/
|
||||
export function ageWords(at, now = Date.now()) {
|
||||
const minutes = Math.max(0, Math.floor((now - Date.parse(at)) / 60_000));
|
||||
if (!Number.isFinite(minutes)) return '';
|
||||
if (minutes < 1) return t('now', { context: 'age' });
|
||||
if (minutes < 60) return t('{minutes} min', { minutes });
|
||||
return t('{hours} h', { hours: Math.min(24, Math.floor(minutes / 60)) });
|
||||
}
|
||||
|
||||
/**
|
||||
* How a report's marker looks: colour = severity, icon = category, rim =
|
||||
* attestation (`confirmed` solid, `claimed` dashed, else thin), size =
|
||||
* sources, opacity = age (fading to 45 % over a day).
|
||||
*/
|
||||
export function markerStyle(report, now = Date.now()) {
|
||||
const kind = attestation(report)?.kind;
|
||||
const at = lastHeard(report);
|
||||
const hours = Number.isFinite(at) ? Math.max(0, (now - at) / 3_600_000) : 0;
|
||||
return {
|
||||
category: Object.hasOwn(NEWS_CATEGORIES, report?.category)
|
||||
? report.category
|
||||
: 'other',
|
||||
severity: reportSeverity(report),
|
||||
rim: kind === 'confirmed' || kind === 'claimed' ? kind : 'thin',
|
||||
size: eventPixelSize(report?.event?.size),
|
||||
alpha: Math.max(0.45, 1 - (0.55 * Math.min(hours, 24)) / 24),
|
||||
};
|
||||
}
|
||||
|
||||
function hash(text) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trend glyph for a hotspot: ▲/▼ when the last 24 h differ from the 24 h
|
||||
* before by more than a fifth, nothing when flat or without a prior day.
|
||||
*/
|
||||
export function hotspotTrend(last24h, prev24h) {
|
||||
if (!(prev24h > 0)) return '';
|
||||
if (last24h > prev24h * 1.2) return '▲';
|
||||
if (last24h < prev24h * 0.8) return '▼';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Beacon size in pixels: grows with the square root of daily activity. */
|
||||
export const beaconPixelSize = (last24h) =>
|
||||
Math.round(Math.min(26, 10 + Math.sqrt(Math.max(0, last24h)) * 1.4));
|
||||
|
||||
/** Marker size for an event: grows with the number of distinct sources. */
|
||||
export function eventPixelSize(sources = 1) {
|
||||
return Math.round(
|
||||
// No smaller than 24: the category icon is half the disc and unreadable
|
||||
// below that.
|
||||
Math.min(36, 24 + 3 * Math.log2(Math.max(1, Number(sources) || 1))),
|
||||
);
|
||||
}
|
||||
|
||||
// An event several sources report within the last few hours gets a halo.
|
||||
export const NEWS_PRESSING_SOURCES = 5;
|
||||
const PRESSING_MS = 3 * 3_600_000;
|
||||
|
||||
/** Whether an event is fresh and widely reported, or fresh and major. */
|
||||
export function isPressing(report, now = Date.now()) {
|
||||
const event = report?.event;
|
||||
const fresh = now - lastHeard(report) <= PRESSING_MS;
|
||||
return Boolean(
|
||||
fresh &&
|
||||
((event && event.size >= NEWS_PRESSING_SOURCES) ||
|
||||
reportSeverity(report) === 3),
|
||||
);
|
||||
}
|
||||
|
||||
// The big events stand as picture cards over their marker: several
|
||||
// sources, and major or pressing.
|
||||
export const NEWS_CARD_LIMIT = 10;
|
||||
export const NEWS_CARD_MIN_SOURCES = 2;
|
||||
|
||||
/** Whether an event gets a card on the map: physical events only, not statements. */
|
||||
export function isCardEvent(report, now = Date.now()) {
|
||||
return Boolean(
|
||||
report?.event?.size >= NEWS_CARD_MIN_SOURCES &&
|
||||
PHYSICAL.has(report.category) &&
|
||||
(reportSeverity(report) === 3 || isPressing(report, now)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The card events among `reports`, most severe, most reported and newest first. */
|
||||
export function cardEvents(reports, now = Date.now(), limit = NEWS_CARD_LIMIT) {
|
||||
return reports
|
||||
.filter((r) => r.pins?.length && isCardEvent(r, now))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
reportSeverity(b) - reportSeverity(a) ||
|
||||
b.event.size - a.event.size ||
|
||||
(b.event.lastAt || '').localeCompare(a.event.lastAt || ''),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** The distinct sides an event was reported by in the last day, lead first. */
|
||||
function eventSides(report, now) {
|
||||
const sides = recentSides(report, now).map((s) => s.side);
|
||||
return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Reported by UA sources · RU sources", or '' when no side is known; an
|
||||
* unattributed side is left out.
|
||||
*/
|
||||
export function sidesLine(report, now = Date.now()) {
|
||||
const sides = eventSides(report, now).filter((side) => side !== 'unknown');
|
||||
if (!sides.length) return '';
|
||||
if (sides.length === 1)
|
||||
return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) });
|
||||
return t(REPORTED_BY_SIDES, {
|
||||
sides: sides
|
||||
.map((side) => t(SIDE_SOURCES, { side: sideLabel(side) }))
|
||||
.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's picture, the publisher named, and its clip when it has one. The
|
||||
* panel shows it for any event; the globe's picture cards are for events of
|
||||
* two or more sources (isCardEvent).
|
||||
*/
|
||||
export function eventPicture(report) {
|
||||
const image = report?.event?.image;
|
||||
if (!image) return null;
|
||||
return {
|
||||
url: image.url,
|
||||
credit: image.publisher || '',
|
||||
...(image.video ? { video: image.video } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** What an event's picture card on the globe shows. */
|
||||
export function cardView(report, now = Date.now()) {
|
||||
const event = report.event;
|
||||
const sides = eventSides(report, now).map(sideLabel);
|
||||
// A globe card's picture needs two or more sources, as the card does.
|
||||
const picture =
|
||||
report?.event?.size >= NEWS_CARD_MIN_SOURCES ? eventPicture(report) : null;
|
||||
return {
|
||||
title: report.titleEn || report.title,
|
||||
meta: [
|
||||
ageWords(event?.lastAt || report.publishedAt, now),
|
||||
tn(event?.size || 1, '{count} source', '{count} sources'),
|
||||
sides.slice(0, 3).join(', ') + (sides.length > 3 ? '…' : ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
image: picture?.url || null,
|
||||
credit: picture?.credit || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** "Source: host ↗" line for a report's card; the card opens the article. */
|
||||
export function sourceLinkLabel(url) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return `${t(SOURCE_LABEL)}: ${host} ↗`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "via GDELT Project": the service a report was read through, which asks to
|
||||
* be credited where its reports show; '' when the server names none.
|
||||
*/
|
||||
export function attributionLabel(report) {
|
||||
return report?.attribution ? t(VIA, { source: report.attribution }) : '';
|
||||
}
|
||||
|
||||
/** The places a report is pinned at, joined, or null. */
|
||||
function placeLine(report) {
|
||||
return (
|
||||
(report.pins?.length ? report.pins : [report.geo])
|
||||
.map((p) => p?.place)
|
||||
.filter(Boolean)
|
||||
.join(' · ') || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One event card in the News panel: picture,
|
||||
* headline, a mono meta line (severity, category, place, sources, age, the
|
||||
* service it came through), the sides line, then attestation and media chips. Labels and colours are
|
||||
* resolved here so the panel imports no layer code.
|
||||
*/
|
||||
export function feedCardView(report, now = Date.now()) {
|
||||
const severity = reportSeverity(report);
|
||||
const style = categoryStyle(report.category);
|
||||
const size = report.event?.size || 1;
|
||||
const translated = Boolean(report.titleEn && report.titleEn !== report.title);
|
||||
return {
|
||||
id: report.id,
|
||||
url: report.url,
|
||||
title: translated ? report.titleEn : report.title,
|
||||
// The local model's English headline over a post in another language:
|
||||
// the original stays beside it.
|
||||
original: translated ? report.title : null,
|
||||
snippet: report.snippet,
|
||||
severity,
|
||||
severityLabel: t(NEWS_SEVERITY[severity].label),
|
||||
color: NEWS_SEVERITY[severity].color,
|
||||
icon: style.icon,
|
||||
solid: Boolean(style.solid),
|
||||
category: categoryLabel(report.category),
|
||||
place: placeLine(report),
|
||||
placedByModel: Boolean(report.geo?.byModel),
|
||||
mappable: Boolean(report.pins?.length),
|
||||
sources: tn(size, '{count} source', '{count} sources'),
|
||||
age: ageWords(report.event?.lastAt || report.publishedAt, now),
|
||||
publisher: report.publisher,
|
||||
via: attributionLabel(report),
|
||||
sides: sidesLine(report, now),
|
||||
sourceLink: sourceLinkLabel(report.url),
|
||||
picture: eventPicture(report),
|
||||
chips: [
|
||||
// An official channel speaking for itself (a ministry, the armed
|
||||
// forces, a press service), not an outlet that quotes one.
|
||||
OFFICIAL_AFFILIATIONS.has(report.affiliation)
|
||||
? { kind: 'official', label: t(OFFICIAL) }
|
||||
: null,
|
||||
ownClaimChip(report),
|
||||
...mediaLabels(report).map((label) => ({ kind: 'media', label })),
|
||||
].filter(Boolean),
|
||||
// The event's first report from each other side, under the lead.
|
||||
others: recentSides(report, now)
|
||||
.slice(1)
|
||||
.map((s) => ({
|
||||
side: sideLabel(s.side),
|
||||
publisher: s.publisher,
|
||||
title: s.title,
|
||||
url: s.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a report is placed no closer than a country: it has no spot of its
|
||||
* own on the map.
|
||||
*/
|
||||
export const countryOnly = (report) =>
|
||||
report.pins?.length > 0 &&
|
||||
report.pins.every((pin) => pin.precision === 'country');
|
||||
|
||||
/**
|
||||
* Country-only reports as one group per country, in the order met:
|
||||
* `{key, place, lat, lon, count}`. They are drawn as one badge with a count,
|
||||
* not as markers piled on the country's centre.
|
||||
*/
|
||||
export function countryGroups(reports) {
|
||||
const groups = new Map();
|
||||
for (const report of reports) {
|
||||
if (!countryOnly(report)) continue;
|
||||
const { lat, lon, place } = report.pins[0];
|
||||
const key = place || `${lat.toFixed(1)},${lon.toFixed(1)}`;
|
||||
const group = groups.get(key);
|
||||
if (group) group.count += 1;
|
||||
else groups.set(key, { key, place: place || '', lat, lon, count: 1 });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker position for a report. Many reports name the same city and would
|
||||
* stack on one centroid, so each is spread on a small deterministic ring
|
||||
* (about 1.5 km for a place, 6 km for a region, 28 km for a country) to stay
|
||||
* individually clickable. The spread is display-only; cards show the place.
|
||||
*/
|
||||
export function reportMarkerPosition(report, pin = 0) {
|
||||
const { lat, lon, precision } = report.pins?.[pin] || report.geo;
|
||||
const h = hash(pin ? `${report.id}~${pin}` : report.id);
|
||||
const angle = ((h % 3600) / 3600) * 2 * Math.PI;
|
||||
const radiusDeg =
|
||||
(precision === 'country' ? 0.25 : precision === 'region' ? 0.055 : 0.014) *
|
||||
(0.35 + ((h >>> 12) % 65) / 100);
|
||||
return {
|
||||
lat: lat + radiusDeg * Math.sin(angle),
|
||||
lon: lon + (radiusDeg * Math.cos(angle)) / Math.cos((lat * Math.PI) / 180),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a page into the feed, newest first, one row per report id, keeping
|
||||
* only the window and at most `max` rows (a long session does not grow
|
||||
* the list). `mode` 'head' refreshes the top and keeps the older cursor;
|
||||
* 'append' adds an older page and takes its cursor; 'replace' starts over.
|
||||
* A head page that has older pages behind it and shares no row with the list
|
||||
* left a gap (more than a page arrived since), so it starts over too.
|
||||
*/
|
||||
export function mergeFeedPage(
|
||||
feed,
|
||||
page,
|
||||
mode,
|
||||
now = Date.now(),
|
||||
max = NEWS_FEED_MAX,
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
) {
|
||||
const byId = new Map();
|
||||
if (mode === 'head' && page.nextBefore && feed.items.length) {
|
||||
const held = new Set(feed.items.map((item) => item.id));
|
||||
if (!page.reports.some((item) => held.has(item.id))) mode = 'replace';
|
||||
}
|
||||
const items =
|
||||
mode === 'replace'
|
||||
? page.reports
|
||||
: mode === 'append'
|
||||
? [...feed.items, ...page.reports]
|
||||
: [...page.reports, ...feed.items];
|
||||
for (const item of items)
|
||||
if (!byId.has(item.id) && withinNewsWindow(item, now, windowMs))
|
||||
byId.set(item.id, item);
|
||||
return {
|
||||
items: [...byId.values()]
|
||||
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
|
||||
.slice(0, max),
|
||||
nextBefore:
|
||||
mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalised filters; anything unknown falls back to its default. */
|
||||
export function normalizeFilters(next = {}, hotspots = [], previous = {}) {
|
||||
const merged = { ...previous, ...next };
|
||||
const hotspot = hotspots.some((h) => h.id === merged.hotspot)
|
||||
? merged.hotspot
|
||||
: 'all';
|
||||
const category = Object.hasOwn(NEWS_CATEGORIES, merged.category)
|
||||
? merged.category
|
||||
: 'all';
|
||||
const minSeverity =
|
||||
Number.isInteger(merged.minSeverity) &&
|
||||
merged.minSeverity >= 0 &&
|
||||
merged.minSeverity <= 3
|
||||
? merged.minSeverity
|
||||
: NEWS_DEFAULT_MIN_SEVERITY;
|
||||
return {
|
||||
hotspot,
|
||||
category,
|
||||
minSeverity,
|
||||
section: NEWS_SECTIONS.includes(merged.section)
|
||||
? merged.section
|
||||
: 'conflicts',
|
||||
// A shorter window than the server's; null shows all it serves.
|
||||
hours: NEWS_WINDOW_CHOICES.includes(merged.hours) ? merged.hours : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a report passes the filters the server does not apply. */
|
||||
export function passesFilters(
|
||||
report,
|
||||
filters,
|
||||
now = Date.now(),
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
) {
|
||||
return (
|
||||
withinNewsWindow(report, now, windowMs) &&
|
||||
reportSeverity(report) >= filters.minSeverity
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a report belongs on the map under the current filters. */
|
||||
export function visibleOnMap(
|
||||
report,
|
||||
filters,
|
||||
now = Date.now(),
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
) {
|
||||
return (
|
||||
(filters.hotspot === 'all' ||
|
||||
(report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) &&
|
||||
(filters.category === 'all' || report.category === filters.category) &&
|
||||
passesFilters(report, filters, now, windowMs)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters Local applies: every hotspot and every severity, near the view
|
||||
* centre; the category and the window still hold.
|
||||
*/
|
||||
export const localFilters = (filters) => ({
|
||||
...filters,
|
||||
hotspot: 'all',
|
||||
minSeverity: 0,
|
||||
});
|
||||
|
||||
/** API query for the Conflicts feed under the current filters: the window only. */
|
||||
export function feedQueryFor(filters, extra = {}) {
|
||||
return {
|
||||
events: true,
|
||||
today: true,
|
||||
relevance: NEWS_RELEVANT,
|
||||
hotspot: filters.hotspot,
|
||||
category: filters.category === 'all' ? '' : filters.category,
|
||||
limit: NEWS_FEED_PAGE,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** The half-degree cell a point lies in, as a key. */
|
||||
export function viewCell({ lat, lon }) {
|
||||
return `${Math.floor(lat / NEWS_CELL_DEG)}:${Math.floor(lon / NEWS_CELL_DEG)}`;
|
||||
}
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function distanceKm(a, b) {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (b.lat - a.lat) * rad;
|
||||
const dLon = (b.lon - a.lon) * rad;
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
function inRing(ring, { lat, lon }) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Whether a point lies in an outline's polygons (`{outer, holes}`). */
|
||||
export function inPolygons(polygons, point) {
|
||||
return polygons.some(
|
||||
({ outer, holes }) =>
|
||||
inRing(outer, point) && !holes.some((hole) => inRing(hole, point)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reports near `centre` that pass the filters, newest first: any pin
|
||||
* inside `area` (outline polygons), or without one within
|
||||
* NEWS_IN_VIEW_RADIUS_KM. Newest, not most severe: an area's own news often
|
||||
* carries no severity, and the area's fighting would otherwise crowd it out.
|
||||
*/
|
||||
export function reportsInView(
|
||||
reports,
|
||||
centre,
|
||||
filters,
|
||||
now = Date.now(),
|
||||
limit = NEWS_IN_VIEW_LIMIT,
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
area = null,
|
||||
) {
|
||||
if (!centre) return [];
|
||||
const near = area?.length
|
||||
? (pin) => inPolygons(area, pin)
|
||||
: (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM;
|
||||
return reports
|
||||
.filter(
|
||||
(r) =>
|
||||
passesFilters(r, filters, now, windowMs) &&
|
||||
(filters.category === 'all' || r.category === filters.category) &&
|
||||
r.pins?.some(near),
|
||||
)
|
||||
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeFeedPayload,
|
||||
normalizeHotspotsPayload,
|
||||
normalizeOutlinePayload,
|
||||
} from './records.js';
|
||||
import {
|
||||
NEWS_IN_VIEW_RADIUS_KM,
|
||||
ageWords,
|
||||
areaLabel,
|
||||
beaconPixelSize,
|
||||
cardEvents,
|
||||
cardView,
|
||||
distanceKm,
|
||||
eventPixelSize,
|
||||
feedCardView,
|
||||
feedQueryFor,
|
||||
hotspotLabel,
|
||||
hotspotTrend,
|
||||
inPolygons,
|
||||
isPressing,
|
||||
markerStyle,
|
||||
mergeFeedPage,
|
||||
newsWindowChoices,
|
||||
newsWindowMs,
|
||||
normalizeFilters,
|
||||
passesFilters,
|
||||
reportMarkerPosition,
|
||||
reportSeverity,
|
||||
reportsInView,
|
||||
sidesLine,
|
||||
sourceLinkLabel,
|
||||
viewCell,
|
||||
visibleOnMap,
|
||||
withinNewsWindow,
|
||||
} from './model.js';
|
||||
import { newsFeedQuery } from './source.js';
|
||||
|
||||
const NOW = Date.parse('2026-10-28T10:00:00Z');
|
||||
const report = (id, publishedAt, extra = {}) => ({
|
||||
id,
|
||||
title: `Report ${id}`,
|
||||
url: `https://example.com/${id}`,
|
||||
publishedAt,
|
||||
publisher: 'Example',
|
||||
side: 'UA',
|
||||
category: 'strike',
|
||||
hotspotIds: ['ua'],
|
||||
geo: {
|
||||
lat: 50,
|
||||
lon: 36,
|
||||
place: 'Kharkiv',
|
||||
precision: 'city',
|
||||
mappable: true,
|
||||
},
|
||||
pins: [
|
||||
{ lat: 50, lon: 36, place: 'Kharkiv', precision: 'city', mappable: true },
|
||||
],
|
||||
...extra,
|
||||
});
|
||||
const event = (extra = {}) => ({
|
||||
status: 'both-sides',
|
||||
size: 6,
|
||||
severity: 3,
|
||||
lastAt: '2026-10-28T09:30:00Z',
|
||||
sides: [
|
||||
{
|
||||
side: 'UA',
|
||||
title: 'a',
|
||||
url: 'https://a.test/',
|
||||
publisher: 'A',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
},
|
||||
{
|
||||
side: 'RU',
|
||||
title: 'b',
|
||||
url: 'https://b.test/',
|
||||
publisher: 'B',
|
||||
publishedAt: '2026-10-28T09:20:00Z',
|
||||
},
|
||||
],
|
||||
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('payloads are validated record by record; empty and malformed payloads', () => {
|
||||
const hotspots = normalizeHotspotsPayload({
|
||||
hotspots: [
|
||||
{
|
||||
id: 'ua',
|
||||
name: 'UA',
|
||||
bbox: [22, 44, 42, 56],
|
||||
parties: ['UA', 'RU', '<x>'],
|
||||
counts: { last24h: 3 },
|
||||
},
|
||||
{ id: 'bad', name: 'Bad', bbox: [42, 44, 22, 56] },
|
||||
{ id: '../x', name: 'Bad id', bbox: [22, 44, 42, 56] },
|
||||
],
|
||||
ingest: { lastRunAt: '2026-10-28T10:00:00Z' },
|
||||
});
|
||||
assert.deepEqual(
|
||||
hotspots.hotspots.map((h) => [h.id, h.parties, h.last24h]),
|
||||
[['ua', ['UA', 'RU'], 3]],
|
||||
);
|
||||
assert.equal(hotspots.lastRunAt, '2026-10-28T10:00:00.000Z');
|
||||
assert.equal(normalizeHotspotsPayload({}), null);
|
||||
assert.equal(normalizeHotspotsPayload(null), null);
|
||||
assert.deepEqual(normalizeHotspotsPayload({ hotspots: [] }).hotspots, []);
|
||||
|
||||
const feed = normalizeFeedPayload({
|
||||
reports: [
|
||||
{
|
||||
id: 'r_1',
|
||||
title: ' Strike on Kharkiv ',
|
||||
url: 'https://x.test/a',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
geo: { lat: 50, lon: 36, mappable: true },
|
||||
media: ['video', 'video', 'audio'],
|
||||
event: { size: 3, image: { url: 'http://x.test/p.jpg' } },
|
||||
},
|
||||
{
|
||||
id: 'r_2',
|
||||
title: 'Bad link',
|
||||
url: 'javascript:alert(1)',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r_3',
|
||||
title: 'Bad geo',
|
||||
url: 'https://user:[email protected]/c',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r_4',
|
||||
title: 'Off the globe',
|
||||
url: 'https://x.test/d',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
geo: { lat: 200, lon: 36 },
|
||||
},
|
||||
],
|
||||
nextBefore: '2026-10-28T08:00:00Z',
|
||||
});
|
||||
assert.deepEqual(
|
||||
feed.reports.map((r) => [r.id, r.title, Boolean(r.geo), r.pins.length]),
|
||||
[
|
||||
['r_1', 'Strike on Kharkiv', true, 1],
|
||||
['r_4', 'Off the globe', false, 0],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(feed.reports[0].media, ['video']);
|
||||
// A picture must be HTTPS on the publisher's site.
|
||||
assert.equal(feed.reports[0].event.image, null);
|
||||
assert.equal(feed.nextBefore, '2026-10-28T08:00:00.000Z');
|
||||
assert.equal(normalizeFeedPayload({ reports: 'x' }), null);
|
||||
assert.deepEqual(normalizeFeedPayload({ reports: [] }), {
|
||||
reports: [],
|
||||
nextBefore: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("an event's clip is kept only as an HTTPS file a browser plays itself", () => {
|
||||
const clip = (video) =>
|
||||
normalizeFeedPayload({
|
||||
reports: [
|
||||
{
|
||||
id: 'r',
|
||||
title: 'T',
|
||||
url: 'https://x.test/r',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
event: {
|
||||
size: 1,
|
||||
image: { url: 'https://cdn.test/p.jpg', publisher: 'P', video },
|
||||
},
|
||||
},
|
||||
],
|
||||
}).reports[0].event.image.video;
|
||||
assert.equal(
|
||||
clip('https://cdn.test/v.mp4?token=a'),
|
||||
'https://cdn.test/v.mp4?token=a',
|
||||
);
|
||||
assert.equal(clip('https://cdn.test/v.webm'), 'https://cdn.test/v.webm');
|
||||
assert.equal(clip('http://cdn.test/v.mp4'), undefined);
|
||||
assert.equal(clip('https://cdn.test/v.m3u8'), undefined);
|
||||
assert.equal(clip(undefined), undefined);
|
||||
});
|
||||
|
||||
test('outlines keep valid rings, borders and named disputed areas', () => {
|
||||
const ring = [
|
||||
[30, 45],
|
||||
[31, 45],
|
||||
[31, 46],
|
||||
];
|
||||
const outline = normalizeOutlinePayload({
|
||||
polygons: [{ outer: ring, holes: [[[0, 0]]] }, { outer: [[1, 2]] }],
|
||||
borders: [ring, 'x'],
|
||||
disputed: [
|
||||
{
|
||||
name: 'Crimea',
|
||||
kind: 'held',
|
||||
heldBy: 'RU',
|
||||
claimedBy: 'UA',
|
||||
rings: [ring],
|
||||
},
|
||||
{ name: 'No rings', rings: [] },
|
||||
{ name: 'Odd kind', kind: 'zzz', heldBy: 'nope', rings: [ring] },
|
||||
],
|
||||
});
|
||||
assert.equal(outline.polygons.length, 1);
|
||||
assert.deepEqual(outline.polygons[0].holes, []);
|
||||
assert.equal(outline.borders.length, 1);
|
||||
assert.deepEqual(
|
||||
outline.disputed.map((d) => [d.name, d.kind, d.heldBy]),
|
||||
[
|
||||
['Crimea', 'held', 'RU'],
|
||||
['Odd kind', 'held', null],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(normalizeOutlinePayload(null), {
|
||||
polygons: [],
|
||||
borders: [],
|
||||
disputed: [],
|
||||
area: null,
|
||||
});
|
||||
// Indonesia's 208 islands are all kept.
|
||||
const islands = Array.from({ length: 208 }, () => ({ outer: ring }));
|
||||
assert.equal(
|
||||
normalizeOutlinePayload({ polygons: islands }).polygons.length,
|
||||
208,
|
||||
);
|
||||
});
|
||||
|
||||
test('a point outline names its area, and Local keeps to it', () => {
|
||||
const box = [
|
||||
[35, 48],
|
||||
[37, 48],
|
||||
[37, 50.5],
|
||||
[35, 50.5],
|
||||
];
|
||||
const { area, polygons } = normalizeOutlinePayload({
|
||||
area: { key: 'US-TX', name: 'Texas', kind: 'region', country: 'US' },
|
||||
polygons: [{ outer: box, holes: [] }],
|
||||
});
|
||||
assert.deepEqual(area, {
|
||||
key: 'US-TX',
|
||||
name: 'Texas',
|
||||
kind: 'region',
|
||||
country: 'US',
|
||||
});
|
||||
assert.equal(areaLabel(area), 'Texas, United States');
|
||||
assert.equal(
|
||||
areaLabel({ ...area, kind: 'country', country: 'UA' }),
|
||||
'Ukraine',
|
||||
);
|
||||
// A disputed area is named as itself, never as either side's.
|
||||
assert.equal(
|
||||
areaLabel({ key: 'disputed:Crimea', name: 'Crimea', kind: 'disputed' }),
|
||||
'Crimea',
|
||||
);
|
||||
assert.equal(normalizeOutlinePayload({ area: { key: 'X' } }).area, null);
|
||||
const at = (id, lat, lon) =>
|
||||
report(id, '2026-10-28T09:00:00Z', {
|
||||
pins: [{ lat, lon, mappable: true }],
|
||||
});
|
||||
const reports = [at('inside', 50, 36), at('beyond', 49, 39)];
|
||||
const filters = normalizeFilters({ minSeverity: 0 });
|
||||
const centre = { lat: 49, lon: 36 };
|
||||
const now = Date.parse('2026-10-28T10:00:00Z');
|
||||
const ids = (area) =>
|
||||
reportsInView(reports, centre, filters, now, 10, undefined, area).map(
|
||||
(r) => r.id,
|
||||
);
|
||||
// Within 500 km both; inside the area only the one.
|
||||
assert.deepEqual(ids(null), ['inside', 'beyond']);
|
||||
assert.deepEqual(ids(polygons), ['inside']);
|
||||
assert.ok(inPolygons(polygons, { lat: 49, lon: 36 }));
|
||||
assert.ok(!inPolygons([{ outer: box, holes: [box] }], { lat: 49, lon: 36 }));
|
||||
});
|
||||
|
||||
test('feed pages merge newest first without duplicates', () => {
|
||||
const a = report('a', '2026-10-28T10:00:00Z');
|
||||
const b = report('b', '2026-10-28T09:00:00Z');
|
||||
const c = report('c', '2026-10-28T08:00:00Z');
|
||||
let feed = mergeFeedPage(
|
||||
{ items: [] },
|
||||
{ reports: [a, b], nextBefore: 'B' },
|
||||
'replace',
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||
[['a', 'b'], 'B'],
|
||||
);
|
||||
feed = mergeFeedPage(
|
||||
feed,
|
||||
{ reports: [b, c], nextBefore: null },
|
||||
'append',
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||
[['a', 'b', 'c'], null],
|
||||
);
|
||||
const newer = report('n', '2026-10-28T11:00:00Z');
|
||||
feed = mergeFeedPage(
|
||||
{ ...feed, nextBefore: 'C' },
|
||||
{ reports: [newer, a], nextBefore: 'X' },
|
||||
'head',
|
||||
NOW + 3_600_000,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||
[['n', 'a', 'b', 'c'], 'C'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeFeedPage({ items: [] }, { reports: [], nextBefore: null }, 'replace'),
|
||||
{ items: [], nextBefore: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('a head page that does not reach the rows held starts the list over', () => {
|
||||
const held = report('h', '2026-10-28T06:00:00Z');
|
||||
const fresh = report('n', '2026-10-28T09:30:00Z');
|
||||
// More than a page arrived since the last refresh (a suspended tab): the
|
||||
// reports between this page and the rows held are behind its cursor.
|
||||
const feed = mergeFeedPage(
|
||||
{ items: [held], nextBefore: 'OLD' },
|
||||
{ reports: [fresh], nextBefore: 'NEW' },
|
||||
'head',
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||
[['n'], 'NEW'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the feed list keeps only the last 24 hours and at most its cap', () => {
|
||||
const fresh = report('f', '2026-10-28T09:00:00Z');
|
||||
const stale = report('s', '2026-10-27T09:00:00Z');
|
||||
const ahead = report('x', '2026-10-28T11:00:00Z');
|
||||
// A long session: yesterday's rows already held fall out on the next merge.
|
||||
const feed = mergeFeedPage(
|
||||
{ items: [stale], nextBefore: 'S' },
|
||||
{ reports: [fresh, ahead], nextBefore: 'F' },
|
||||
'head',
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
feed.items.map((r) => r.id),
|
||||
['f'],
|
||||
);
|
||||
const many = Array.from({ length: 30 }, (_, i) =>
|
||||
report(`r${i}`, new Date(NOW - (i + 1) * 60_000).toISOString()),
|
||||
);
|
||||
const capped = mergeFeedPage(
|
||||
{ items: [] },
|
||||
{ reports: many, nextBefore: null },
|
||||
'replace',
|
||||
NOW,
|
||||
10,
|
||||
);
|
||||
assert.equal(capped.items.length, 10);
|
||||
assert.equal(capped.items[0].id, 'r0');
|
||||
assert.equal(capped.items.at(-1).id, 'r9');
|
||||
// Everything outside the window: an empty list, not an error.
|
||||
assert.deepEqual(
|
||||
mergeFeedPage(
|
||||
{ items: [] },
|
||||
{ reports: [stale], nextBefore: null },
|
||||
'replace',
|
||||
NOW,
|
||||
).items,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('filters default to significant news and map to a last-24-hours query', () => {
|
||||
const hotspots = [{ id: 'ua' }, { id: 'me' }];
|
||||
const filters = normalizeFilters(
|
||||
{ hotspot: 'ua', category: 'nope', minSeverity: 9 },
|
||||
hotspots,
|
||||
);
|
||||
assert.deepEqual(filters, {
|
||||
hotspot: 'ua',
|
||||
category: 'all',
|
||||
minSeverity: 2,
|
||||
section: 'conflicts',
|
||||
hours: null,
|
||||
});
|
||||
assert.equal(
|
||||
normalizeFilters({ section: 'local' }, hotspots).section,
|
||||
'local',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeFilters({ section: 'nope' }, hotspots).section,
|
||||
'conflicts',
|
||||
);
|
||||
assert.equal(normalizeFilters({ hours: 6 }, hotspots).hours, 6);
|
||||
assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null);
|
||||
assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0);
|
||||
assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all');
|
||||
assert.equal(
|
||||
newsFeedQuery(
|
||||
feedQueryFor(filters, { before: '2026-10-28T08:00:00.000Z' }),
|
||||
),
|
||||
'hotspot=ua&before=2026-10-28T08%3A00%3A00.000Z&limit=60&relevance=core%2Crelated&today=1&events=1',
|
||||
);
|
||||
// The Conflicts feed reads the same whichever section is chosen.
|
||||
assert.equal(
|
||||
newsFeedQuery(feedQueryFor({ ...filters, section: 'local' })),
|
||||
newsFeedQuery(feedQueryFor(filters)),
|
||||
);
|
||||
});
|
||||
|
||||
test('only the last 24 hours pass, and the severity floor applies', () => {
|
||||
const filters = normalizeFilters();
|
||||
const fresh = report('f', '2026-10-28T09:00:00Z', { severity: 2 });
|
||||
const minor = report('m', '2026-10-28T09:00:00Z', { severity: 1 });
|
||||
const old = report('o', '2026-10-27T09:00:00Z', { severity: 3 });
|
||||
assert.equal(withinNewsWindow(fresh, NOW), true);
|
||||
assert.equal(withinNewsWindow(old, NOW), false);
|
||||
assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false);
|
||||
// Clock skew up to 15 min ahead passes; further ahead is not news yet.
|
||||
const skew = report('k', '2026-10-28T10:14:00Z');
|
||||
const future = report('z', '2026-10-28T10:16:00Z');
|
||||
assert.equal(withinNewsWindow(skew, NOW), true);
|
||||
assert.equal(withinNewsWindow(future, NOW), false);
|
||||
assert.deepEqual(
|
||||
[fresh, minor, old]
|
||||
.filter((r) => passesFilters(r, filters, NOW))
|
||||
.map((r) => r.id),
|
||||
['f'],
|
||||
);
|
||||
assert.equal(visibleOnMap(minor, { ...filters, minSeverity: 0 }, NOW), true);
|
||||
assert.equal(visibleOnMap(fresh, { ...filters, hotspot: 'me' }, NOW), false);
|
||||
});
|
||||
|
||||
test('ageWords says now, minutes or hours', () => {
|
||||
assert.equal(ageWords('2026-10-28T09:59:30Z', NOW), 'now');
|
||||
assert.equal(ageWords('2026-10-28T09:55:00Z', NOW), '5 min');
|
||||
assert.equal(ageWords('2026-10-28T07:10:00Z', NOW), '2 h');
|
||||
assert.equal(ageWords('2026-10-26T07:10:00Z', NOW), '24 h');
|
||||
assert.equal(ageWords('not a date', NOW), '');
|
||||
});
|
||||
|
||||
test('markers spread deterministically near their place; source link line', () => {
|
||||
const r = report('r_spread', '2026-10-28T10:00:00Z');
|
||||
const p1 = reportMarkerPosition(r);
|
||||
assert.deepEqual(reportMarkerPosition(r), p1);
|
||||
assert.ok(Math.abs(p1.lat - 50) < 0.02 && Math.abs(p1.lon - 36) < 0.04);
|
||||
assert.notDeepEqual(
|
||||
reportMarkerPosition(report('r_other', r.publishedAt)),
|
||||
p1,
|
||||
);
|
||||
assert.equal(
|
||||
sourceLinkLabel('https://www.reliefweb.int/report/x'),
|
||||
'Source: reliefweb.int ↗',
|
||||
);
|
||||
assert.equal(sourceLinkLabel('not a url'), '');
|
||||
});
|
||||
|
||||
test('beacons carry a focus, a size by activity and a trend only against a prior day', () => {
|
||||
const { hotspots } = normalizeHotspotsPayload({
|
||||
hotspots: [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
bbox: [20, 40, 40, 50],
|
||||
focus: { lat: 45, lon: 30 },
|
||||
counts: { last24h: 120, prev24h: 80 },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'B',
|
||||
bbox: [20, 40, 40, 50],
|
||||
focus: { lat: 80, lon: 30 },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
hotspots.map((h) => h.focus),
|
||||
[
|
||||
{ lat: 45, lon: 30 },
|
||||
{ lat: 45, lon: 30 },
|
||||
],
|
||||
);
|
||||
assert.equal(hotspotTrend(120, 80), '▲');
|
||||
assert.equal(hotspotTrend(50, 80), '▼');
|
||||
assert.equal(hotspotTrend(85, 80), '');
|
||||
assert.equal(hotspotTrend(40, 0), '');
|
||||
assert.equal(beaconPixelSize(0), 10);
|
||||
assert.equal(beaconPixelSize(10_000), 26);
|
||||
assert.equal(
|
||||
hotspotLabel({ id: 'ua-ru-war', name: 'Russia–Ukraine war' }),
|
||||
'Ukraine–Russia',
|
||||
);
|
||||
assert.equal(hotspotLabel({ id: 'new-one', name: 'New one' }), 'New one');
|
||||
assert.equal(
|
||||
hotspotLabel({ id: 'x', name: 'X', catchAll: true }),
|
||||
'Elsewhere',
|
||||
);
|
||||
assert.equal(
|
||||
hotspotLabel({ id: 'worldwide', name: 'Worldwide', catchAll: true }),
|
||||
'Worldwide',
|
||||
);
|
||||
});
|
||||
|
||||
test('marker style: severity fill, attestation rim, size by sources, fading with age', () => {
|
||||
const r = report('r_style', '2026-10-28T10:00:00Z', {
|
||||
severity: 2,
|
||||
event: event({
|
||||
status: 'claimed',
|
||||
size: 4,
|
||||
lastAt: '2026-10-28T10:00:00Z',
|
||||
}),
|
||||
});
|
||||
const fresh = markerStyle(r, NOW);
|
||||
assert.equal(fresh.severity, 3); // the event's, when higher than the report's
|
||||
assert.equal(fresh.rim, 'claimed');
|
||||
assert.equal(fresh.size, eventPixelSize(4));
|
||||
// The icon is half the disc: no marker is too small to read it.
|
||||
assert.equal(eventPixelSize(1), 24);
|
||||
assert.equal(eventPixelSize(1000), 36);
|
||||
assert.equal(fresh.alpha, 1);
|
||||
assert.equal(markerStyle(r, NOW + 48 * 3_600_000).alpha, 0.45);
|
||||
assert.equal(reportSeverity({ category: 'political' }), 0);
|
||||
assert.equal(reportSeverity({ category: 'strike' }), 1);
|
||||
});
|
||||
|
||||
test('picture cards: physical, two or more sources, major or pressing, at most ten', () => {
|
||||
const big = report('big', '2026-10-28T09:30:00Z', { event: event() });
|
||||
const statement = report('st', '2026-10-28T09:30:00Z', {
|
||||
category: 'political',
|
||||
event: event(),
|
||||
});
|
||||
const lone = report('lone', '2026-10-28T09:30:00Z', {
|
||||
event: event({ size: 1 }),
|
||||
});
|
||||
const stale = report('stale', '2026-10-28T01:00:00Z', {
|
||||
event: event({ severity: 2, lastAt: '2026-10-28T01:00:00Z' }),
|
||||
});
|
||||
assert.equal(isPressing(big, NOW), true);
|
||||
assert.deepEqual(
|
||||
cardEvents([statement, lone, stale, big], NOW).map((r) => r.id),
|
||||
['big'],
|
||||
);
|
||||
const many = Array.from({ length: 14 }, (_, i) =>
|
||||
report(`m${i}`, '2026-10-28T09:30:00Z', { event: event() }),
|
||||
);
|
||||
assert.equal(cardEvents(many, NOW).length, 10);
|
||||
const view = cardView(big, NOW);
|
||||
assert.equal(view.image, 'https://a.test/p.jpg');
|
||||
assert.equal(view.credit, 'A');
|
||||
assert.equal(view.meta, '30 min · 6 sources · UA, RU');
|
||||
assert.equal(cardView(lone, NOW).image, null);
|
||||
});
|
||||
|
||||
test('feed cards: headline, meta parts, sides and chips', () => {
|
||||
const r = report('fc', '2026-10-28T09:00:00Z', {
|
||||
title: 'Удар по Харкову',
|
||||
titleEn: 'Strike on Kharkiv',
|
||||
affiliation: 'military',
|
||||
media: ['video', 'photo'],
|
||||
geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true, byModel: true },
|
||||
event: event({ lastAt: '2026-10-28T09:50:00Z' }),
|
||||
});
|
||||
const view = feedCardView(r, NOW);
|
||||
assert.equal(view.title, 'Strike on Kharkiv');
|
||||
assert.equal(view.original, 'Удар по Харкову');
|
||||
assert.equal(view.placedByModel, true);
|
||||
assert.equal(view.severityLabel, 'Major');
|
||||
assert.equal(view.color, '#ff4444');
|
||||
assert.equal(view.category, 'Strike');
|
||||
assert.equal(view.place, 'Kharkiv');
|
||||
assert.equal(view.sources, '6 sources');
|
||||
assert.equal(view.age, '10 min');
|
||||
assert.equal(view.sides, 'Reported by UA sources · RU sources');
|
||||
assert.deepEqual(view.picture, { url: 'https://a.test/p.jpg', credit: 'A' });
|
||||
assert.deepEqual(
|
||||
view.chips.map((c) => [c.kind, c.label]),
|
||||
[
|
||||
['official', 'Official'],
|
||||
['confirmed', 'Reported by both sides'],
|
||||
['media', 'Video'],
|
||||
['media', 'Photos'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.others.map((o) => o.side),
|
||||
['RU'],
|
||||
);
|
||||
// A lone report: its own side, no picture, one source.
|
||||
const single = feedCardView(report('s', '2026-10-28T09:00:00Z'), NOW);
|
||||
assert.equal(single.sides, 'Reported by UA sources');
|
||||
assert.equal(single.picture, null);
|
||||
// A one-source event (a Telegram post) shows its picture and its clip in
|
||||
// the panel, though the globe's picture cards need two sources.
|
||||
const post = report('p', '2026-10-28T09:00:00Z', {
|
||||
event: {
|
||||
size: 1,
|
||||
sides: [],
|
||||
image: {
|
||||
url: 'https://cdn4.telesco.pe/file/v.jpg',
|
||||
publisher: 'Channel',
|
||||
video: 'https://cdn4.telesco.pe/file/v.mp4?token=x',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(feedCardView(post, NOW).picture, {
|
||||
url: 'https://cdn4.telesco.pe/file/v.jpg',
|
||||
credit: 'Channel',
|
||||
video: 'https://cdn4.telesco.pe/file/v.mp4?token=x',
|
||||
});
|
||||
assert.equal(single.original, null);
|
||||
assert.equal(sidesLine({ side: 'unknown' }), '');
|
||||
});
|
||||
|
||||
test('event sides older than a day are left out of the card and its sides line', () => {
|
||||
const r = report('w', '2026-10-28T09:00:00Z', {
|
||||
event: event({
|
||||
sides: [
|
||||
{
|
||||
side: 'UA',
|
||||
title: 'a',
|
||||
url: 'https://a.test/',
|
||||
publisher: 'A',
|
||||
publishedAt: '2026-10-28T08:00:00Z',
|
||||
},
|
||||
{
|
||||
side: 'RU',
|
||||
title: 'old',
|
||||
url: 'https://b.test/',
|
||||
publisher: 'B',
|
||||
publishedAt: '2026-10-26T08:00:00Z',
|
||||
},
|
||||
{
|
||||
side: 'unknown',
|
||||
title: 'c',
|
||||
url: 'https://c.test/',
|
||||
publisher: 'C',
|
||||
publishedAt: '2026-10-28T09:10:00Z',
|
||||
},
|
||||
// No date: nothing shows it is recent.
|
||||
{ side: 'IL', title: 'd', url: 'https://d.test/', publisher: 'D' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const view = feedCardView(r, NOW);
|
||||
assert.deepEqual(
|
||||
view.others.map((o) => [o.side, o.title]),
|
||||
[['Unattributed', 'c']],
|
||||
);
|
||||
// The unattributed side is not named in the sides line.
|
||||
assert.equal(view.sides, 'Reported by UA sources');
|
||||
assert.equal(cardView(r, NOW).meta, '30 min · 6 sources · UA, Unattributed');
|
||||
// All sides outside the window: no others, the report's own side remains.
|
||||
const stale = report('v', '2026-10-28T09:00:00Z', {
|
||||
event: event({
|
||||
sides: [
|
||||
{
|
||||
side: 'RU',
|
||||
title: 'old',
|
||||
url: 'https://b.test/',
|
||||
publisher: 'B',
|
||||
publishedAt: '2026-10-26T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(feedCardView(stale, NOW).others, []);
|
||||
assert.equal(feedCardView(stale, NOW).sides, 'Reported by UA sources');
|
||||
});
|
||||
|
||||
test('in view: half-degree cells and reports within 500 km of the centre', () => {
|
||||
assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73');
|
||||
assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73');
|
||||
assert.notEqual(viewCell({ lat: 50.6, lon: 36.7 }), '100:73');
|
||||
assert.ok(
|
||||
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.5,
|
||||
);
|
||||
const filters = normalizeFilters({ minSeverity: 0 });
|
||||
const near = report('near', '2026-10-28T09:00:00Z', { severity: 1 });
|
||||
const major = report('major', '2026-10-28T08:00:00Z', { severity: 3 });
|
||||
const far = report('far', '2026-10-28T09:00:00Z', {
|
||||
pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }],
|
||||
});
|
||||
const old = report('old', '2026-10-27T08:00:00Z');
|
||||
const centre = { lat: 49, lon: 36 };
|
||||
assert.ok(distanceKm(centre, far.pins[0]) > NEWS_IN_VIEW_RADIUS_KM);
|
||||
assert.deepEqual(
|
||||
reportsInView([near, far, old, major], centre, filters, NOW).map(
|
||||
(r) => r.id,
|
||||
),
|
||||
['near', 'major'],
|
||||
);
|
||||
assert.deepEqual(reportsInView([near], null, filters, NOW), []);
|
||||
assert.deepEqual(reportsInView([], centre, filters, NOW), []);
|
||||
});
|
||||
|
||||
test('a server may serve fewer hours than the day, never more', () => {
|
||||
assert.equal(newsWindowMs(12), 12 * 3_600_000);
|
||||
assert.equal(newsWindowMs(24), 24 * 3_600_000);
|
||||
for (const bad of [0, 25, 12.5, '12', null, undefined])
|
||||
assert.equal(newsWindowMs(bad), 24 * 3_600_000);
|
||||
const page = {
|
||||
reports: [
|
||||
report('fresh', '2026-10-28T02:00:00Z'),
|
||||
report('old', '2026-10-27T20:00:00Z'),
|
||||
],
|
||||
nextBefore: null,
|
||||
};
|
||||
const empty = { items: [], nextBefore: null };
|
||||
const ids = (windowMs) =>
|
||||
mergeFeedPage(empty, page, 'replace', NOW, undefined, windowMs).items.map(
|
||||
(r) => r.id,
|
||||
);
|
||||
assert.deepEqual(ids(newsWindowMs(12)), ['fresh']);
|
||||
assert.deepEqual(ids(newsWindowMs(24)), ['fresh', 'old']);
|
||||
});
|
||||
|
||||
test('the hotspots answer carries the window it was counted over', () => {
|
||||
const base = {
|
||||
hotspots: [],
|
||||
ingest: { lastRunAt: '2026-10-28T09:00:00Z' },
|
||||
};
|
||||
assert.equal(
|
||||
normalizeHotspotsPayload({ ...base, windowHours: 12 }).windowHours,
|
||||
12,
|
||||
);
|
||||
assert.equal(normalizeHotspotsPayload(base).windowHours, null);
|
||||
assert.equal(
|
||||
normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours,
|
||||
null,
|
||||
);
|
||||
const shorter = normalizeHotspotsPayload({
|
||||
...base,
|
||||
windowHours: 3,
|
||||
maxWindowHours: 12,
|
||||
});
|
||||
assert.deepEqual([shorter.windowHours, shorter.maxWindowHours], [3, 12]);
|
||||
assert.equal(normalizeHotspotsPayload(base).maxWindowHours, null);
|
||||
});
|
||||
|
||||
test('the window choices stop at the window the server serves', () => {
|
||||
assert.deepEqual(newsWindowChoices(24), [24, 12, 6, 3, 1]);
|
||||
assert.deepEqual(newsWindowChoices(12), [12, 6, 3, 1]);
|
||||
assert.deepEqual(newsWindowChoices(8), [8, 6, 3, 1]);
|
||||
assert.deepEqual(newsWindowChoices(1), [1]);
|
||||
assert.deepEqual(newsWindowChoices(null), [24, 12, 6, 3, 1]);
|
||||
});
|
||||
|
||||
test('a checked severity keeps saying who set it', () => {
|
||||
const page = normalizeFeedPayload({
|
||||
reports: ['label', 'model', 'guess'].map((by, i) => ({
|
||||
...report(`r${i}`, '2026-10-28T09:00:00Z'),
|
||||
severity: 2,
|
||||
severityBy: by,
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(
|
||||
page.reports.map((r) => r.severityBy),
|
||||
['label', 'model', 'rules'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// Validate /api/news payloads before they reach the scene or the DOM. Every
|
||||
// string is bounded, every URL is http(s), every position is finite; a record
|
||||
// that fails is dropped, a payload that fails is rejected whole. A portable
|
||||
// export (layers/news/source): no i18n here.
|
||||
|
||||
const CATEGORY = /^[a-z][a-z-]{1,31}$/;
|
||||
const CODE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
|
||||
const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const EVENT_STATUSES = Object.freeze([
|
||||
'both-sides',
|
||||
'independent',
|
||||
'claimed',
|
||||
'reported',
|
||||
'side',
|
||||
'single',
|
||||
]);
|
||||
|
||||
const text = (value, max) =>
|
||||
typeof value === 'string' && value.trim()
|
||||
? value.replace(/\s+/g, ' ').trim().slice(0, max)
|
||||
: null;
|
||||
const finite = (value, min, max) =>
|
||||
Number.isFinite(value) && value >= min && value <= max ? value : null;
|
||||
const iso = (value) => {
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
};
|
||||
const code = (value) =>
|
||||
typeof value === 'string' && CODE.test(value) ? value : null;
|
||||
const severity = (value) =>
|
||||
Number.isInteger(value) && value >= 0 && value <= 3 ? value : null;
|
||||
|
||||
/** An http(s) URL without credentials, as its normalised href, or null. */
|
||||
export function httpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBbox(bbox) {
|
||||
if (!Array.isArray(bbox) || bbox.length !== 4) return null;
|
||||
const [west, south, east, north] = bbox;
|
||||
if (
|
||||
finite(west, -180, 180) === null ||
|
||||
finite(east, -180, 180) === null ||
|
||||
finite(south, -90, 90) === null ||
|
||||
finite(north, -90, 90) === null ||
|
||||
west >= east ||
|
||||
south >= north
|
||||
)
|
||||
return null;
|
||||
return [west, south, east, north];
|
||||
}
|
||||
|
||||
/** Beacon position: the curated focus point, else the bbox centre. */
|
||||
function normalizeFocus(focus, [west, south, east, north]) {
|
||||
const lat = finite(focus?.lat, south, north);
|
||||
const lon = finite(focus?.lon, west, east);
|
||||
return lat !== null && lon !== null
|
||||
? { lat, lon }
|
||||
: { lat: (south + north) / 2, lon: (west + east) / 2 };
|
||||
}
|
||||
|
||||
function normalizeDeveloping(items) {
|
||||
const out = [];
|
||||
for (const raw of Array.isArray(items) ? items.slice(0, 16) : []) {
|
||||
const lat = finite(raw?.focus?.lat, -90, 90);
|
||||
const lon = finite(raw?.focus?.lon, -180, 180);
|
||||
const country =
|
||||
typeof raw?.country === 'string' && /^[A-Z]{2}$/.test(raw.country)
|
||||
? raw.country
|
||||
: null;
|
||||
if (lat === null || lon === null || !country) continue;
|
||||
// A state where the catch-all flags states ("US-FL"), else the country.
|
||||
const area =
|
||||
typeof raw.area === 'string' && /^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(raw.area)
|
||||
? raw.area
|
||||
: null;
|
||||
out.push({
|
||||
country,
|
||||
key: area || country,
|
||||
name: text(raw.name, 80) || country,
|
||||
events: Math.max(0, Math.floor(Number(raw.events) || 0)),
|
||||
reports: Math.max(0, Math.floor(Number(raw.reports) || 0)),
|
||||
focus: { lat, lon },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const hoursOrNull = (hours) =>
|
||||
Number.isInteger(hours) && hours >= 1 && hours <= 24 ? hours : null;
|
||||
|
||||
/**
|
||||
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours,
|
||||
* maxWindowHours}`, or null when the payload has no hotspot list.
|
||||
* `windowHours` is the window the counts cover and `maxWindowHours` the
|
||||
* longest the server serves this reader; each is null when the server names
|
||||
* none.
|
||||
*/
|
||||
export function normalizeHotspotsPayload(payload) {
|
||||
if (!Array.isArray(payload?.hotspots)) return null;
|
||||
const hotspots = [];
|
||||
for (const raw of payload.hotspots.slice(0, 64)) {
|
||||
const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null;
|
||||
const name = text(raw?.name, 80);
|
||||
const bbox = normalizeBbox(raw?.bbox);
|
||||
if (!id || !name || !bbox) continue;
|
||||
const counts = raw.counts || {};
|
||||
hotspots.push({
|
||||
id,
|
||||
name,
|
||||
kind: text(raw.kind, 16) || 'conflict',
|
||||
catchAll: raw.catchAll === true,
|
||||
developing: normalizeDeveloping(raw.developing),
|
||||
bbox,
|
||||
parties: Array.isArray(raw.parties)
|
||||
? raw.parties.filter((p) => code(p))
|
||||
: [],
|
||||
last24h: Math.max(0, Math.floor(Number(counts.last24h) || 0)),
|
||||
mapped24h: Math.max(0, Math.floor(Number(counts.mapped24h) || 0)),
|
||||
prev24h: Number.isFinite(counts.prev24h)
|
||||
? Math.max(0, Math.floor(counts.prev24h))
|
||||
: null,
|
||||
focus: normalizeFocus(raw.focus, bbox),
|
||||
latestAt: iso(raw.latestAt),
|
||||
});
|
||||
}
|
||||
return {
|
||||
hotspots,
|
||||
categories: Array.isArray(payload.categories)
|
||||
? payload.categories.filter(
|
||||
(c) => typeof c === 'string' && CATEGORY.test(c),
|
||||
)
|
||||
: [],
|
||||
lastRunAt: iso(payload.ingest?.lastRunAt),
|
||||
windowHours: hoursOrNull(payload.windowHours),
|
||||
maxWindowHours: hoursOrNull(payload.maxWindowHours),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGeo(geo) {
|
||||
if (!geo || typeof geo !== 'object') return null;
|
||||
const lat = finite(geo.lat, -90, 90);
|
||||
const lon = finite(geo.lon, -180, 180);
|
||||
if (lat === null || lon === null) return null;
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
place: text(geo.place, 80),
|
||||
precision: text(geo.precision, 16) || 'city',
|
||||
confidence: finite(geo.confidence, 0, 1) ?? 0,
|
||||
mappable: geo.mappable === true,
|
||||
byModel: geo.method === 'llm',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's picture: an HTTPS URL on the publisher's site, and whose it is;
|
||||
* with the post's own clip when it has one a browser plays itself (an HTTPS
|
||||
* MP4 or WebM file), the picture then its poster.
|
||||
*/
|
||||
function eventImage(image) {
|
||||
const url = httpUrl(image?.url);
|
||||
if (!url?.startsWith('https://')) return null;
|
||||
const video = httpUrl(image.video);
|
||||
const playable =
|
||||
video?.startsWith('https://') &&
|
||||
/\.(?:mp4|m4v|webm)$/i.test(new URL(video).pathname);
|
||||
return {
|
||||
url,
|
||||
publisher: text(image.publisher, 80) || '',
|
||||
...(playable ? { video } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An event summary from `/feed?events=1`: size and one report per side. */
|
||||
function normalizeEvent(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
const count = (value) => Math.max(1, Math.floor(Number(value) || 1));
|
||||
const sides = [];
|
||||
for (const side of Array.isArray(event.sides)
|
||||
? event.sides.slice(0, 8)
|
||||
: []) {
|
||||
const url = httpUrl(side?.url);
|
||||
const title = text(side?.title, 300);
|
||||
if (!url || !title) continue;
|
||||
sides.push({
|
||||
side: code(side.side) || 'unknown',
|
||||
title,
|
||||
url,
|
||||
publisher: text(side.publisher, 80) || '',
|
||||
publishedAt: iso(side.publishedAt),
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: typeof event.id === 'string' && ID.test(event.id) ? event.id : null,
|
||||
status: EVENT_STATUSES.includes(event.status) ? event.status : null,
|
||||
claimedBy: text(event.claimedBy, 80),
|
||||
reportedSide: code(event.reportedSide),
|
||||
size: count(event.size),
|
||||
reports: count(event.reports),
|
||||
severity: severity(event.severity),
|
||||
firstAt: iso(event.firstAt),
|
||||
lastAt: iso(event.lastAt),
|
||||
sides,
|
||||
image: eventImage(event.image),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReport(raw) {
|
||||
const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null;
|
||||
const title = text(raw?.title, 300);
|
||||
const url = httpUrl(raw?.url);
|
||||
const publishedAt = iso(raw?.publishedAt);
|
||||
if (!id || !title || !url || !publishedAt) return null;
|
||||
const source = raw.source || {};
|
||||
const geo = normalizeGeo(raw.geo);
|
||||
// Map positions: the report's own place plus any other hotspot its title
|
||||
// names (a roundup is pinned in each).
|
||||
const pins = [];
|
||||
for (const place of [
|
||||
geo,
|
||||
...(Array.isArray(raw.locations)
|
||||
? raw.locations.slice(0, 8).map(normalizeGeo)
|
||||
: []),
|
||||
])
|
||||
if (
|
||||
place?.mappable &&
|
||||
!pins.some((p) => p.lat === place.lat && p.lon === place.lon)
|
||||
)
|
||||
pins.push(place);
|
||||
const hotspotId = typeof raw.hotspotId === 'string' ? raw.hotspotId : null;
|
||||
return {
|
||||
id,
|
||||
hotspotId,
|
||||
hotspotIds: Array.isArray(raw.hotspotIds)
|
||||
? raw.hotspotIds.filter((h) => typeof h === 'string').slice(0, 8)
|
||||
: [hotspotId].filter(Boolean),
|
||||
title,
|
||||
url,
|
||||
snippet: text(raw.snippet, 400),
|
||||
publisher: text(raw.publisher, 80) || text(source.name, 80) || '',
|
||||
sourceName: text(source.name, 80) || text(raw.publisher, 80) || '',
|
||||
kind: text(raw.kind, 16) || 'rss',
|
||||
side: code(raw.side) || 'unknown',
|
||||
affiliation: text(raw.affiliation, 32),
|
||||
attribution: text(source.attribution, 120),
|
||||
category:
|
||||
typeof raw.category === 'string' && CATEGORY.test(raw.category)
|
||||
? raw.category
|
||||
: 'other',
|
||||
publishedAt,
|
||||
geo,
|
||||
pins,
|
||||
event: normalizeEvent(raw.event),
|
||||
titleEn: text(raw.titleEn, 200),
|
||||
relevance: ['core', 'related', 'minor'].includes(raw.relevance)
|
||||
? raw.relevance
|
||||
: 'related',
|
||||
severity: severity(raw.severity),
|
||||
severityBy: ['model', 'label'].includes(raw.severityBy)
|
||||
? raw.severityBy
|
||||
: 'rules',
|
||||
claim:
|
||||
raw.claim && typeof raw.claim === 'object'
|
||||
? { by: text(raw.claim.by, 80) }
|
||||
: null,
|
||||
media: Array.isArray(raw.media)
|
||||
? [...new Set(raw.media.filter((m) => m === 'video' || m === 'photo'))]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return null;
|
||||
const points = [];
|
||||
for (const point of ring.slice(0, 20_000)) {
|
||||
const lon = finite(point?.[0], -180, 180);
|
||||
const lat = finite(point?.[1], -90, 90);
|
||||
if (lon !== null && lat !== null) points.push([lon, lat]);
|
||||
}
|
||||
return points.length >= 3 ? points : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/news/outline` → `{polygons, borders, disputed, area}`: the dissolved
|
||||
* extent (`{outer, holes}`), the national borders inside it (rings), its
|
||||
* disputed areas and, answering a point, the area named there (else null),
|
||||
* invalid parts dropped.
|
||||
*/
|
||||
export function normalizeOutlinePayload(payload) {
|
||||
const polygons = [];
|
||||
// Above every country's island count (Indonesia's is 208): an outline cut
|
||||
// short leaves islands outside the area, read again on each settle there.
|
||||
for (const raw of Array.isArray(payload?.polygons)
|
||||
? payload.polygons.slice(0, 300)
|
||||
: []) {
|
||||
const outer = normalizeRing(raw?.outer);
|
||||
if (!outer) continue;
|
||||
const holes = (Array.isArray(raw.holes) ? raw.holes.slice(0, 200) : [])
|
||||
.map(normalizeRing)
|
||||
.filter(Boolean);
|
||||
polygons.push({ outer, holes });
|
||||
}
|
||||
const borders = (
|
||||
Array.isArray(payload?.borders) ? payload.borders.slice(0, 50) : []
|
||||
)
|
||||
.map(normalizeRing)
|
||||
.filter(Boolean);
|
||||
const KINDS = ['held', 'self', 'un', 'unclaimed'];
|
||||
const country = (value) =>
|
||||
typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? value : null;
|
||||
const disputed = [];
|
||||
for (const raw of Array.isArray(payload?.disputed)
|
||||
? payload.disputed.slice(0, 30)
|
||||
: []) {
|
||||
const rings = (Array.isArray(raw?.rings) ? raw.rings.slice(0, 20) : [])
|
||||
.map(normalizeRing)
|
||||
.filter(Boolean);
|
||||
const name = text(raw?.name, 60);
|
||||
if (!rings.length || !name) continue;
|
||||
disputed.push({
|
||||
name,
|
||||
kind: KINDS.includes(raw.kind) ? raw.kind : 'held',
|
||||
heldBy: country(raw.heldBy),
|
||||
claimedBy: country(raw.claimedBy),
|
||||
rings,
|
||||
});
|
||||
}
|
||||
return { polygons, borders, disputed, area: outlineArea(payload?.area) };
|
||||
}
|
||||
|
||||
const AREA_KINDS = ['country', 'region', 'disputed'];
|
||||
function outlineArea(raw) {
|
||||
const key = text(raw?.key, 80);
|
||||
const name = text(raw?.name, 80);
|
||||
if (!key || !name) return null;
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
kind: AREA_KINDS.includes(raw.kind) ? raw.kind : 'country',
|
||||
country:
|
||||
typeof raw.country === 'string' && /^[A-Z]{2}$/.test(raw.country)
|
||||
? raw.country
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `/api/news/feed` → `{reports, nextBefore}`, or null without a report list. */
|
||||
export function normalizeFeedPayload(payload) {
|
||||
if (!Array.isArray(payload?.reports)) return null;
|
||||
const reports = [];
|
||||
for (const raw of payload.reports.slice(0, 500)) {
|
||||
const report = normalizeReport(raw);
|
||||
if (report) reports.push(report);
|
||||
}
|
||||
return { reports, nextBefore: iso(payload.nextBefore) };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
import {
|
||||
normalizeFeedPayload,
|
||||
normalizeHotspotsPayload,
|
||||
normalizeOutlinePayload,
|
||||
} from './records.js';
|
||||
|
||||
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||
// the layer shows them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
const BASE_PATH = '/api/news';
|
||||
// The outline geometry version the client draws (disputed areas included).
|
||||
const OUTLINE_VERSION = '4';
|
||||
|
||||
/** The three read routes the news layer calls, for a host's path allowlist. */
|
||||
export const NEWS_API_PATHS = Object.freeze([
|
||||
`${BASE_PATH}/hotspots`,
|
||||
`${BASE_PATH}/feed`,
|
||||
`${BASE_PATH}/outline`,
|
||||
]);
|
||||
|
||||
const FEED_KEYS = Object.freeze([
|
||||
'hotspot',
|
||||
'before',
|
||||
'since',
|
||||
'limit',
|
||||
'category',
|
||||
'relevance',
|
||||
'at',
|
||||
]);
|
||||
const FLAG_KEYS = Object.freeze(['geo', 'today', 'events']);
|
||||
const ISO = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/;
|
||||
const LIST = /^[a-z][a-z,-]{0,127}$/;
|
||||
// A point to two decimals (~1 km), as areaPoint writes it.
|
||||
const AT = /^-?\d{1,2}(?:\.\d{1,2})?,-?\d{1,3}(?:\.\d{1,2})?$/;
|
||||
// A coordinate to two decimals, without trailing zeros ("50.4", "-97.74").
|
||||
const areaPoint = (value) => String(Math.round(value * 100) / 100);
|
||||
const FEED_VALUE = {
|
||||
hotspot: (v) => /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(v),
|
||||
before: (v) => ISO.test(v),
|
||||
since: (v) => ISO.test(v),
|
||||
limit: (v) => /^\d{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 500,
|
||||
category: (v) => LIST.test(v),
|
||||
relevance: (v) => LIST.test(v),
|
||||
at: (v) => AT.test(v),
|
||||
};
|
||||
|
||||
/**
|
||||
* Feed query string from a plain object; unknown keys and empty values are
|
||||
* dropped. `at` is a point (`{lat, lon}`), the Local section's area there.
|
||||
*/
|
||||
export function newsFeedQuery(query = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of FEED_KEYS) {
|
||||
const value =
|
||||
key === 'at' && query.at
|
||||
? `${areaPoint(query.at.lat)},${areaPoint(query.at.lon)}`
|
||||
: query[key];
|
||||
if (value !== undefined && value !== null && value !== '')
|
||||
params.set(key, String(value));
|
||||
}
|
||||
for (const key of FLAG_KEYS) if (query[key]) params.set(key, '1');
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request URL is one this client sends: a known route, known keys
|
||||
* only, each value in its form. A host proxying the routes can admit exactly
|
||||
* these and refuse the rest.
|
||||
* @param {URL} url
|
||||
*/
|
||||
export function newsQueryAllowed(url) {
|
||||
const params = [...url.searchParams];
|
||||
if (new Set(params.map(([key]) => key)).size !== params.length) return false;
|
||||
if (url.pathname === `${BASE_PATH}/hotspots`)
|
||||
return (
|
||||
params.length === 0 ||
|
||||
(params.length === 1 &&
|
||||
params[0][0] === 'hours' &&
|
||||
/^\d{1,2}$/.test(params[0][1]) &&
|
||||
Number(params[0][1]) >= 1 &&
|
||||
Number(params[0][1]) <= 24)
|
||||
);
|
||||
if (url.pathname === `${BASE_PATH}/outline`) {
|
||||
const keys = params.map(([key]) => key).sort();
|
||||
const value = url.searchParams;
|
||||
return (
|
||||
params.length === 2 &&
|
||||
keys[1] === 'v' &&
|
||||
value.get('v') === OUTLINE_VERSION &&
|
||||
((keys[0] === 'hotspot' && FEED_VALUE.hotspot(value.get('hotspot'))) ||
|
||||
(keys[0] === 'country' &&
|
||||
/^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/.test(value.get('country'))) ||
|
||||
(keys[0] === 'at' && AT.test(value.get('at'))))
|
||||
);
|
||||
}
|
||||
if (url.pathname !== `${BASE_PATH}/feed`) return false;
|
||||
return params.every(([key, value]) =>
|
||||
FLAG_KEYS.includes(key)
|
||||
? value === '1'
|
||||
: Object.hasOwn(FEED_VALUE, key) && FEED_VALUE[key](value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed read: a server without the news routes is a permanent miss; one
|
||||
* that reads a section from a key it lacks (503 `no_key`, `key_rejected` or,
|
||||
* its allowance for the day spent, `key_quota`, naming the key registry's id)
|
||||
* says which, and is asked again later.
|
||||
*/
|
||||
async function responseError(response) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await readResponseJsonCapped(response, 16 * 1024);
|
||||
} catch {
|
||||
await response.body?.cancel?.().catch?.(() => {});
|
||||
}
|
||||
if (
|
||||
response.status === 503 &&
|
||||
['no_key', 'key_rejected', 'key_quota'].includes(body?.error) &&
|
||||
typeof body.key === 'string' &&
|
||||
/^[a-z][a-z0-9-]{0,31}$/.test(body.key)
|
||||
)
|
||||
return Object.assign(new Error(msg('News key required')), {
|
||||
status: 503,
|
||||
code: body.error,
|
||||
keyRequired: true,
|
||||
keyRejected: body.error === 'key_rejected',
|
||||
keyQuota: body.error === 'key_quota',
|
||||
keyId: body.key,
|
||||
unavailable: false,
|
||||
retryable: true,
|
||||
});
|
||||
const unavailable =
|
||||
response.status === 404 ||
|
||||
(response.status === 503 && isUnavailableCapability(body));
|
||||
return Object.assign(
|
||||
new Error(
|
||||
unavailable
|
||||
? msg('News feed not configured on this server')
|
||||
: msg('News feed unavailable'),
|
||||
),
|
||||
{
|
||||
status: response.status,
|
||||
code: unavailable ? 'unavailable' : 'http',
|
||||
unavailable,
|
||||
retryable: !unavailable,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Read hotspots, reports and outlines from the server's /api/news routes. */
|
||||
export function createNewsSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
async function get(path, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const request = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
try {
|
||||
const response = await fetchImpl(`${BASE_PATH}${path}`, {
|
||||
signal: request,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
return await readResponseJsonCapped(response, MAX_BYTES, request);
|
||||
} catch (cause) {
|
||||
// Out of time the feed reads unavailable, not the browser's own words
|
||||
// ("signal timed out"); the caller's own abort stays an abort.
|
||||
if (timeout.aborted && !signal?.aborted)
|
||||
throw Object.assign(new Error(msg('News feed unavailable')), {
|
||||
code: 'timeout',
|
||||
unavailable: false,
|
||||
retryable: true,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
return {
|
||||
// `hours` asks for counts over a shorter window than the server's own.
|
||||
async getHotspots({ signal, hours } = {}) {
|
||||
const payload = normalizeHotspotsPayload(
|
||||
await get(hours ? `/hotspots?hours=${hours}` : '/hotspots', {
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
if (!payload) throw new Error(msg('Malformed news hotspots response'));
|
||||
return payload;
|
||||
},
|
||||
async getOutline(hotspotId, { signal } = {}) {
|
||||
return normalizeOutlinePayload(
|
||||
await get(
|
||||
`/outline?hotspot=${encodeURIComponent(hotspotId)}&v=${OUTLINE_VERSION}`,
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
},
|
||||
async getCountryOutline(country, { signal } = {}) {
|
||||
return normalizeOutlinePayload(
|
||||
await get(
|
||||
`/outline?country=${encodeURIComponent(country)}&v=${OUTLINE_VERSION}`,
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
},
|
||||
// The area the Local section reads at a point, with its outline.
|
||||
async getAreaOutline({ lat, lon }, { signal } = {}) {
|
||||
return normalizeOutlinePayload(
|
||||
await get(
|
||||
`/outline?at=${areaPoint(lat)},${areaPoint(lon)}&v=${OUTLINE_VERSION}`,
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
},
|
||||
async getFeed(query = {}, { signal } = {}) {
|
||||
const payload = normalizeFeedPayload(
|
||||
await get(`/feed?${newsFeedQuery(query)}`, { signal }),
|
||||
);
|
||||
if (!payload) throw new Error(msg('Malformed news feed response'));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NEWS_API_PATHS,
|
||||
createNewsSource,
|
||||
newsFeedQuery,
|
||||
newsQueryAllowed,
|
||||
} from './source.js';
|
||||
|
||||
const json = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
function recorder(respond) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return respond(url, init);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('reads validated hotspots, feed and outlines from the same-origin routes', async () => {
|
||||
const { calls, fetchImpl } = recorder((url) => {
|
||||
if (url.startsWith('/api/news/hotspots'))
|
||||
return json({
|
||||
hotspots: [{ id: 'ua', name: 'UA', bbox: [22, 44, 42, 56] }],
|
||||
});
|
||||
if (url.startsWith('/api/news/feed'))
|
||||
return json({
|
||||
reports: [
|
||||
{
|
||||
id: 'r1',
|
||||
title: 'T',
|
||||
url: 'https://x.test/',
|
||||
publishedAt: '2026-10-28T09:00:00Z',
|
||||
},
|
||||
],
|
||||
nextBefore: null,
|
||||
});
|
||||
return json({ polygons: [], borders: [], disputed: [] });
|
||||
});
|
||||
const source = createNewsSource({ fetchImpl });
|
||||
assert.equal((await source.getHotspots()).hotspots[0].id, 'ua');
|
||||
assert.equal(
|
||||
(await source.getFeed({ hotspot: 'all', today: true, limit: 60 }))
|
||||
.reports[0].id,
|
||||
'r1',
|
||||
);
|
||||
assert.deepEqual((await source.getOutline('ua')).polygons, []);
|
||||
await source.getCountryOutline('SD');
|
||||
await source.getHotspots({ hours: 6 });
|
||||
await source.getAreaOutline({ lat: 30.26666, lon: -97.7 });
|
||||
await source.getFeed({ hotspot: 'all', at: { lat: 30.26666, lon: -97.7 } });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.url),
|
||||
[
|
||||
'/api/news/hotspots',
|
||||
'/api/news/feed?hotspot=all&limit=60&today=1',
|
||||
'/api/news/outline?hotspot=ua&v=4',
|
||||
'/api/news/outline?country=SD&v=4',
|
||||
'/api/news/hotspots?hours=6',
|
||||
'/api/news/outline?at=30.27,-97.7&v=4',
|
||||
'/api/news/feed?hotspot=all&at=30.27%2C-97.7',
|
||||
],
|
||||
);
|
||||
for (const { url, init } of calls) {
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.cache, 'no-store');
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
// Every URL the client sends is one a host's allowlist admits.
|
||||
assert.equal(newsQueryAllowed(new URL(url, 'http://h')), true, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed, failed and unconfigured reads reject; a server without news is a permanent miss', async () => {
|
||||
const malformed = createNewsSource({ fetchImpl: async () => json({}) });
|
||||
await assert.rejects(malformed.getHotspots(), /Malformed news hotspots/);
|
||||
await assert.rejects(malformed.getFeed(), /Malformed news feed/);
|
||||
|
||||
const down = createNewsSource({
|
||||
fetchImpl: async () => json({ error: 'upstream' }, 502),
|
||||
});
|
||||
await assert.rejects(down.getHotspots(), (error) => {
|
||||
assert.equal(error.message, 'News feed unavailable');
|
||||
assert.equal(error.retryable, true);
|
||||
assert.equal(error.unavailable, false);
|
||||
return true;
|
||||
});
|
||||
|
||||
const unconfigured = createNewsSource({
|
||||
fetchImpl: async () =>
|
||||
json(
|
||||
{ error: 'news_not_configured', unavailable: true, retryable: false },
|
||||
503,
|
||||
),
|
||||
});
|
||||
await assert.rejects(unconfigured.getHotspots(), (error) => {
|
||||
assert.equal(error.message, 'News feed not configured on this server');
|
||||
assert.equal(error.code, 'unavailable');
|
||||
assert.equal(error.unavailable, true);
|
||||
assert.equal(error.retryable, false);
|
||||
return true;
|
||||
});
|
||||
const missing = createNewsSource({
|
||||
fetchImpl: async () => new Response('Not found', { status: 404 }),
|
||||
});
|
||||
await assert.rejects(missing.getFeed(), { unavailable: true });
|
||||
const broken = createNewsSource({
|
||||
fetchImpl: async () => new Response('{', { status: 200 }),
|
||||
});
|
||||
await assert.rejects(broken.getHotspots(), SyntaxError);
|
||||
});
|
||||
|
||||
test('a section read from a key the server lacks names the key and is asked again', async () => {
|
||||
const answer = (body) =>
|
||||
createNewsSource({
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
answer({ error: 'no_key', key: 'liveuamap' }).getHotspots(),
|
||||
(error) => {
|
||||
assert.equal(error.keyRequired, true);
|
||||
assert.equal(error.keyRejected, false);
|
||||
assert.equal(error.keyId, 'liveuamap');
|
||||
assert.equal(error.unavailable, false);
|
||||
assert.equal(error.retryable, true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
answer({ error: 'key_rejected', key: 'worldnewsapi' }).getFeed({
|
||||
at: { lat: 51.13, lon: 71.43 },
|
||||
}),
|
||||
{ keyRequired: true, keyRejected: true, keyId: 'worldnewsapi' },
|
||||
);
|
||||
// The day's allowance spent: still the key's matter, back tomorrow.
|
||||
await assert.rejects(
|
||||
answer({ error: 'key_quota', key: 'worldnewsapi' }).getFeed(),
|
||||
{
|
||||
keyRequired: true,
|
||||
keyRejected: false,
|
||||
keyQuota: true,
|
||||
keyId: 'worldnewsapi',
|
||||
unavailable: false,
|
||||
},
|
||||
);
|
||||
// A key name not in the registry's form is an ordinary failure.
|
||||
await assert.rejects(
|
||||
answer({ error: 'no_key', key: '<script>' }).getHotspots(),
|
||||
(error) => error.keyRequired === undefined && error.code === 'http',
|
||||
);
|
||||
});
|
||||
|
||||
test('reads time out and follow the caller signal', async () => {
|
||||
// AbortSignal.timeout does not hold the event loop open: the hanging fetch
|
||||
// does, until its signal aborts, so the run cannot end before it.
|
||||
const hang = (_url, { signal }) =>
|
||||
new Promise((_, reject) => {
|
||||
const alive = setTimeout(() => {}, 60_000);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(alive);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 });
|
||||
// Out of time it reads unavailable, in the app's words, as retryable.
|
||||
await assert.rejects(slow.getHotspots(), {
|
||||
message: 'News feed unavailable',
|
||||
code: 'timeout',
|
||||
retryable: true,
|
||||
});
|
||||
// The caller's abort stays its own, even once the time is up too.
|
||||
const late = (_url, { signal }) =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(signal.reason), 40);
|
||||
});
|
||||
const leaving = new AbortController();
|
||||
const gone = createNewsSource({ fetchImpl: late, timeoutMs: 20 }).getFeed(
|
||||
{},
|
||||
{ signal: leaving.signal },
|
||||
);
|
||||
leaving.abort(new Error('gone'));
|
||||
await assert.rejects(gone, /gone/);
|
||||
const caller = new AbortController();
|
||||
const pending = createNewsSource({ fetchImpl: hang }).getFeed(
|
||||
{},
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
caller.abort(new Error('gone'));
|
||||
await assert.rejects(pending, /gone/);
|
||||
await assert.rejects(
|
||||
createNewsSource({ fetchImpl: hang }).getHotspots({
|
||||
signal: AbortSignal.abort(new Error('already')),
|
||||
}),
|
||||
/already/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the host allowlist admits the client routes and refuses anything else', () => {
|
||||
const allowed = (path) => newsQueryAllowed(new URL(path, 'http://h'));
|
||||
assert.deepEqual(NEWS_API_PATHS, [
|
||||
'/api/news/hotspots',
|
||||
'/api/news/feed',
|
||||
'/api/news/outline',
|
||||
]);
|
||||
assert.equal(
|
||||
allowed(
|
||||
`/api/news/feed?${newsFeedQuery({ hotspot: 'ua-ru-war', before: '2026-10-28T08:00:00.000Z', limit: 500, category: 'strike', relevance: 'core,related', geo: true, today: true, events: true })}`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(allowed('/api/news/hotspots?hours=6'), true);
|
||||
for (const path of [
|
||||
'/api/news/hotspots?hours=0',
|
||||
'/api/news/hotspots?hours=25',
|
||||
'/api/news/hotspots?hours=6&hours=3',
|
||||
'/api/news/hotspots?hours=6&x=1',
|
||||
'/api/news/hotspots?x=1',
|
||||
'/api/news/feed?limit=501',
|
||||
'/api/news/feed?limit=0',
|
||||
'/api/news/feed?hotspot=all&hotspot=ua',
|
||||
'/api/news/feed?since=yesterday',
|
||||
'/api/news/feed?geo=yes',
|
||||
'/api/news/feed?side=UA',
|
||||
'/api/news/air-alerts',
|
||||
'/api/news/outline?hotspot=ua',
|
||||
'/api/news/outline?hotspot=ua&v=2',
|
||||
'/api/news/outline?country=ukraine&v=4',
|
||||
'/api/news/outline?at=30.267,-97.7&v=4',
|
||||
'/api/news/outline?at=30.27&v=4',
|
||||
'/api/news/outline?at=30.27,-97.7&country=US&v=4',
|
||||
'/api/news/feed?at=30.267,-97.7',
|
||||
])
|
||||
assert.equal(allowed(path), false, path);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// Marker sprites for news reports: a dark disc with the category's icon and
|
||||
// a rim in the severity colour, the rim's form telling attestation. Drawn once per look
|
||||
// on a canvas and handed to Cesium as a data URL, so its texture atlas keeps
|
||||
// one copy per look however often the layer re-renders.
|
||||
|
||||
import { NEWS_CATEGORIES, NEWS_SEVERITY } from './model.js';
|
||||
|
||||
const CANVAS = 64;
|
||||
const BODY = 52; // disc diameter inside the canvas; the rest is rim and ring
|
||||
const INK = 'rgba(10, 10, 15, 0.92)';
|
||||
const SELECTED = '#00d4ff';
|
||||
const COUNT_INK = '#e8eaed';
|
||||
|
||||
/** Canvas pixels per marker pixel: the billboard is drawn at this ratio. */
|
||||
export const SPRITE_SCALE = CANVAS / BODY;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Paint one marker look centred at (cx, cy) with a disc of `body` pixels: the
|
||||
* sprites and the event cards' badges draw the same thing.
|
||||
*/
|
||||
export function paintMarker(ctx, { category, severity, rim }, cx, cy, body) {
|
||||
const k = body / BODY; // the strokes below are tuned for the 52 px body
|
||||
const r = body / 2;
|
||||
// A dark edge keeps the lightest fills apart from bright ground.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r + 1.5 * k, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
|
||||
ctx.fill();
|
||||
// GEV's dark glass disc, as its HUD buttons are; severity colours the rim
|
||||
// and the icon rather than filling the disc.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fill();
|
||||
const color = (NEWS_SEVERITY[severity] || NEWS_SEVERITY[0]).color;
|
||||
|
||||
// Attestation is the rim's form, never its colour.
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r - 3 * k, 0, 2 * Math.PI);
|
||||
ctx.setLineDash(rim === 'claimed' ? [7 * k, 5 * k] : []);
|
||||
ctx.lineWidth = (rim === 'thin' ? 2.5 : 6) * k;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
const style = NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other;
|
||||
const icon = body * 0.5;
|
||||
ctx.save();
|
||||
ctx.translate(cx - icon / 2, cy - icon / 2);
|
||||
ctx.scale(icon / 16, icon / 16);
|
||||
const path = new Path2D(style.icon);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = 1.7;
|
||||
ctx.strokeStyle = color;
|
||||
if (style.solid) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill(path);
|
||||
} else ctx.stroke(path);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URL of the sprite for a look, or null where there is no canvas (the
|
||||
* layer then falls back to a plain point).
|
||||
* @param {{category: string, severity: number, rim: 'confirmed'|'claimed'|'thin', selected?: boolean}} look
|
||||
*/
|
||||
export function markerSprite({ category, severity, rim, selected = false }) {
|
||||
const key = `${category}|${severity}|${rim}|${selected ? 1 : 0}`;
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
const canvas = globalThis.document?.createElement?.('canvas');
|
||||
const ctx = canvas?.getContext?.('2d');
|
||||
if (!ctx || typeof globalThis.Path2D !== 'function') return null;
|
||||
canvas.width = canvas.height = CANVAS;
|
||||
const c = CANVAS / 2;
|
||||
paintMarker(ctx, { category, severity, rim }, c, c, BODY);
|
||||
if (selected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, CANVAS / 2 - 2, 0, 2 * Math.PI);
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = SELECTED;
|
||||
ctx.stroke();
|
||||
}
|
||||
const url = canvas.toDataURL('image/png');
|
||||
cache.set(key, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URL of a country badge: the dark disc with a light rim and the number
|
||||
* of reports it stands for, drawn at the pixels it is shown at so the digits
|
||||
* stay sharp. Null where there is no canvas.
|
||||
* @param {number} count Reports the badge stands for.
|
||||
* @param {number} pixels The disc's diameter in drawn pixels.
|
||||
*/
|
||||
export function countSprite(count, pixels) {
|
||||
const body = Math.max(1, Math.round(pixels));
|
||||
const text = count > 99 ? '99+' : String(count);
|
||||
const key = `count|${text}|${body}`;
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
const canvas = globalThis.document?.createElement?.('canvas');
|
||||
const ctx = canvas?.getContext?.('2d');
|
||||
if (!ctx) return null;
|
||||
const k = body / BODY;
|
||||
canvas.width = canvas.height = Math.round(CANVAS * k);
|
||||
const c = canvas.width / 2;
|
||||
const r = body / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, r + 1.5 * k, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, r - 2 * k, 0, 2 * Math.PI);
|
||||
ctx.lineWidth = 3 * k;
|
||||
ctx.strokeStyle = COUNT_INK;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = COUNT_INK;
|
||||
ctx.font = `600 ${Math.round(body * (text.length > 2 ? 0.34 : 0.44))}px ui-monospace, Menlo, monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, c, c + k);
|
||||
const url = canvas.toDataURL('image/png');
|
||||
cache.set(key, url);
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatDate, formatNumber, msg, t } from '../../i18n/index.js';
|
||||
import { EMODNET_PAGE_URL } from './records.js';
|
||||
|
||||
export const ERA_LABELS = Object.freeze({
|
||||
'before-1900': msg('Before 1900'),
|
||||
'1900-1945': msg('1900–1945'),
|
||||
'after-1945': msg('After 1945'),
|
||||
undated: msg('Date unknown'),
|
||||
});
|
||||
|
||||
export const ERA_COLORS = Object.freeze({
|
||||
'before-1900': '#c79a5b',
|
||||
'1900-1945': '#ff6b5e',
|
||||
'after-1945': '#4fc3f7',
|
||||
undated: '#9aa7b4',
|
||||
});
|
||||
|
||||
function formatDay(date) {
|
||||
const match = /^(-?\d{4})-(\d{2})-(\d{2})$/.exec(date || '');
|
||||
if (!match) return '';
|
||||
return formatDate(
|
||||
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])),
|
||||
{ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' },
|
||||
);
|
||||
}
|
||||
|
||||
function meters(value) {
|
||||
return formatNumber(value, {
|
||||
maximumFractionDigits: value < 10 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** How well the position is known, as one card line (or null). */
|
||||
export function positionLine(record) {
|
||||
if (record.positionNote) return t('Position: centre of the protected area');
|
||||
const accuracy = record.positionAccuracy;
|
||||
if (!Number.isFinite(accuracy)) return null;
|
||||
// 1852 m is EMODnet's "within a nautical mile" for old, reported positions.
|
||||
if (accuracy >= 1000)
|
||||
return t('Position approximate (within about {km} km)', {
|
||||
km: formatNumber(accuracy / 1000, { maximumFractionDigits: 1 }),
|
||||
});
|
||||
return t('Position surveyed (within about {m} m)', { m: meters(accuracy) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a click on the card opens: the Wikipedia (or Wikidata) page, the
|
||||
* wreck's own page when EMODnet lists one, else EMODnet's portal.
|
||||
* @param {object} record
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cardLink(record) {
|
||||
return record?.url || EMODNET_PAGE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and detail lines for one wreck card: deaths first, then when and
|
||||
* where, then the ship facts. Victims appear as a count only; free text that
|
||||
* refers to a person by a title or role is dropped by a keyword filter (see
|
||||
* records.js), which a bare name can still pass.
|
||||
* @param {object} record A normalized shipwreck record.
|
||||
* @returns {{title: string, details: string[], link: string}}
|
||||
*/
|
||||
export function describeShipwreck(record) {
|
||||
const titleParts = [t('Shipwreck')];
|
||||
if (record.name) titleParts.push(record.name);
|
||||
const details = [];
|
||||
// Deaths first, right under the title: the fact readers look for.
|
||||
const toll = [];
|
||||
if (Number.isFinite(record.casualties))
|
||||
toll.push(
|
||||
t('Fatalities: {count}', { count: formatNumber(record.casualties) }),
|
||||
);
|
||||
if (record.survivors)
|
||||
toll.push(
|
||||
t('Survivors: {count}', { count: formatNumber(record.survivors) }),
|
||||
);
|
||||
if (toll.length) details.push(toll.join(' · '));
|
||||
const when =
|
||||
formatDay(record.date) ||
|
||||
(Number.isInteger(record.year) ? String(record.year) : '') ||
|
||||
record.dating ||
|
||||
'';
|
||||
const where = [when, record.country].filter(Boolean);
|
||||
if (where.length) details.push(where.join(' · '));
|
||||
if (record.vesselType)
|
||||
details.push(t('Vessel: {value}', { value: record.vesselType }));
|
||||
if (record.cause)
|
||||
details.push(t('What happened: {value}', { value: record.cause }));
|
||||
if (Number.isFinite(record.depth))
|
||||
details.push(
|
||||
record.depthQuality
|
||||
? t('Depth: {m} m ({quality})', {
|
||||
m: meters(record.depth),
|
||||
quality: record.depthQuality,
|
||||
})
|
||||
: t('Depth: {m} m', { m: meters(record.depth) }),
|
||||
);
|
||||
const position = positionLine(record);
|
||||
if (position) details.push(position);
|
||||
if (record.notes) details.push(t('Notes: {value}', { value: record.notes }));
|
||||
if (record.cargo)
|
||||
details.push(t('Cargo or finds: {value}', { value: record.cargo }));
|
||||
if (record.protected) details.push(t('Protected wreck'));
|
||||
const sources =
|
||||
record.source === 'wikidata'
|
||||
? record.alsoIn === 'emodnet'
|
||||
? 'Wikidata · EMODnet'
|
||||
: 'Wikidata'
|
||||
: record.originator
|
||||
? `EMODnet (${record.originator})`
|
||||
: 'EMODnet';
|
||||
details.push(t('Source: {value}', { value: sources }));
|
||||
const link = cardLink(record);
|
||||
details.push(
|
||||
link === EMODNET_PAGE_URL
|
||||
? t('Click the card to open EMODnet ↗')
|
||||
: link.includes('.wikipedia.org/')
|
||||
? t('Click the card to open the article ↗')
|
||||
: t('Click the card to open the source ↗'),
|
||||
);
|
||||
return { title: titleParts.join(' · '), details, link };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeShipwreck, positionLine } from './cards.js';
|
||||
import { EMODNET_PAGE_URL } from './records.js';
|
||||
|
||||
test('a Wikidata card shows the ship facts and links its article', () => {
|
||||
const card = describeShipwreck({
|
||||
source: 'wikidata',
|
||||
alsoIn: 'emodnet',
|
||||
name: 'HMS Havant',
|
||||
vesselType: 'destroyer',
|
||||
date: '1940-06-01',
|
||||
year: 1940,
|
||||
cause: 'Was sunk on 06/01/1940 by the German air force',
|
||||
casualties: 8,
|
||||
survivors: 120,
|
||||
depth: 14.72,
|
||||
depthQuality: 'Least depth known',
|
||||
positionAccuracy: 3.1,
|
||||
notes: '1939 G and H-class destroyer',
|
||||
country: 'France',
|
||||
url: 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)',
|
||||
});
|
||||
assert.equal(card.title, 'Shipwreck · HMS Havant');
|
||||
assert.equal(card.link, 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)');
|
||||
const text = card.details.join('\n');
|
||||
assert.equal(card.details[0], 'Fatalities: 8 · Survivors: 120');
|
||||
assert.match(card.details[1], /1940 · France$/);
|
||||
assert.match(text, /Vessel: destroyer/);
|
||||
assert.match(text, /What happened: Was sunk/);
|
||||
assert.match(text, /Depth: 15 m \(Least depth known\)/);
|
||||
assert.match(text, /Position surveyed \(within about 3\.1 m\)/);
|
||||
assert.match(text, /Source: Wikidata · EMODnet/);
|
||||
assert.match(text, /open the article/);
|
||||
});
|
||||
|
||||
test('an EMODnet card without a page links the portal', () => {
|
||||
const card = describeShipwreck({
|
||||
source: 'emodnet',
|
||||
name: null,
|
||||
year: null,
|
||||
dating: 'C1st AD',
|
||||
originator: 'The Oxford Roman Economy Project',
|
||||
protected: true,
|
||||
url: null,
|
||||
});
|
||||
assert.equal(card.title, 'Shipwreck');
|
||||
assert.equal(card.link, EMODNET_PAGE_URL);
|
||||
const text = card.details.join('\n');
|
||||
assert.equal(card.details[0], 'C1st AD');
|
||||
assert.match(text, /Protected wreck/);
|
||||
assert.match(text, /EMODnet \(The Oxford Roman Economy Project\)/);
|
||||
assert.match(text, /open EMODnet/);
|
||||
});
|
||||
|
||||
test('position lines read the accuracy', () => {
|
||||
assert.equal(positionLine({}), null);
|
||||
assert.match(positionLine({ positionAccuracy: 1852 }), /about 1\.9 km/);
|
||||
assert.match(
|
||||
positionLine({ positionNote: 'x' }),
|
||||
/centre of the protected area/,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user