mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Merge branch 'main' into upstream/firms-age-rounding
This commit is contained in:
commit
1c08482fa8
406 files changed
+53649
-1838
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,
|
||||
|
||||
+272
-24
@@ -12,13 +12,25 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
import {
|
||||
announceNavigationAuthority,
|
||||
watchCameraOwner,
|
||||
} from '../navigationPolicy.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
|
||||
import {
|
||||
annotationsFromParams,
|
||||
createView,
|
||||
followFromParams,
|
||||
groundedLookAt,
|
||||
streetLevelFromParams,
|
||||
} from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const STREET_LEVEL_LAYER = 'street-level';
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
military_aircraft: 'military',
|
||||
@@ -27,6 +39,8 @@ const FOLLOW_LAYERS = {
|
||||
const FOLLOW_ATTEMPTS = 20;
|
||||
const FOLLOW_RETRY_MS = 1000;
|
||||
const FLIGHT_SECONDS = 2;
|
||||
/** How long a posted view waits for the terrain under its camera. */
|
||||
const TERRAIN_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Whether this page shows the app embedded: `?embed=1` when another page
|
||||
@@ -45,6 +59,14 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the panel page started the app for a tool result issued just now,
|
||||
* not one its host handed back on mounting the panel again.
|
||||
*/
|
||||
function isFreshPanelResult() {
|
||||
return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
@@ -165,6 +187,40 @@ function flyTo(viewer, camera) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `show_street_level` arguments for a view's street-level photos. A plain
|
||||
* link (`autoOpen: false`) only arms a source that bills every load; a view
|
||||
* posted into the embedded app is the client's own request and opens.
|
||||
*/
|
||||
function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
|
||||
return {
|
||||
latitude: streetLevel.lat,
|
||||
longitude: streetLevel.lon,
|
||||
...(streetLevel.provider ? { provider: streetLevel.provider } : {}),
|
||||
fly: true,
|
||||
...(autoOpen ? {} : { autoOpen: false }),
|
||||
};
|
||||
}
|
||||
|
||||
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
|
||||
/** Dispatched on the window when a link opened after start-up has restored. */
|
||||
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
|
||||
|
||||
/** The armed link's toast, naming the OPEN button as the panel shows it. */
|
||||
function armedToast() {
|
||||
return t('Street View is ready: press {open} to load it', {
|
||||
open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a link's photo stays shut: its source has no working key here. */
|
||||
function keyToast(result) {
|
||||
return t(
|
||||
"{source} cannot open this link's street-level photo without a working key",
|
||||
{ source: translateKnown(result.providerName || 'Street Level') },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app show a view: style and map, exactly the view's layers,
|
||||
* annotations, the camera, and the followed entity. Each step runs through the
|
||||
@@ -173,13 +229,20 @@ function flyTo(viewer, camera) {
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
{
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen = true,
|
||||
retryMs = FOLLOW_RETRY_MS,
|
||||
},
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
const act = async (name, args, options) => {
|
||||
let result = null;
|
||||
try {
|
||||
result = await run(name, args);
|
||||
result = await run(name, args, options);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error?.message || String(error) };
|
||||
}
|
||||
@@ -198,6 +261,8 @@ export async function applyView(
|
||||
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
||||
}
|
||||
for (const layerId of view.layers) {
|
||||
// The street-level action lights its source first, then the layer.
|
||||
if (view.street_level && layerId === STREET_LEVEL_LAYER) continue;
|
||||
if (!dataManager.isEnabled(layerId))
|
||||
await act('set_layer_visibility', { layerId, enabled: true });
|
||||
}
|
||||
@@ -207,6 +272,29 @@ export async function applyView(
|
||||
await act('clear_annotations', {});
|
||||
if (view.annotations.length)
|
||||
await act('annotate_map', { annotations: [...view.annotations] });
|
||||
if (view.street_level) {
|
||||
// The photos frame the camera; when the action fails before flying, the
|
||||
// view's own camera still applies.
|
||||
// A posted view replays someone else's view, as a link does: the source
|
||||
// it lights is not saved as the visitor's own choice.
|
||||
const result = await act(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
if (result?.ok !== true) {
|
||||
// Overtaken (a click on the map, a newer request): hands off.
|
||||
if (result?.cancelled || result?.superseded) return steps;
|
||||
// An earlier view's photo must not stay over a place this view left.
|
||||
// From the imagery stage on the action has settled the panel itself
|
||||
// (a photo it could not load keeps its error).
|
||||
if (result?.stage !== 'imagery')
|
||||
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
|
||||
if (!result?.location)
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
if (!view.follow) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
return steps;
|
||||
@@ -250,18 +338,115 @@ export function installViews({
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
terrain = null,
|
||||
location = globalThis.location,
|
||||
windowRef = globalThis.window,
|
||||
}) {
|
||||
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
||||
const linked = annotationsFromParams(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
const hashParams = new URLSearchParams(
|
||||
String(location?.hash || '').replace(/^#/, ''),
|
||||
);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
// The link's own steps run first, one at a time, and posted views queue
|
||||
// behind them; one failing step does not take the next with it. A passive
|
||||
// link never flies over someone who took the camera: its steps are skipped
|
||||
// when a newer link superseded it, when its flight never landed (the
|
||||
// recipient dragged or searched), or after any navigation since the
|
||||
// generation its restore flight owned.
|
||||
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
|
||||
const restoreGeneration = (outcome) =>
|
||||
Number.isInteger(outcome?.navigationToken)
|
||||
? outcome.navigationToken
|
||||
: shell._navigationGeneration;
|
||||
let generationAtRestore = null;
|
||||
let startSuperseded = false;
|
||||
let queue = ready.then((outcome) => {
|
||||
generationAtRestore = restoreGeneration(outcome);
|
||||
startSuperseded =
|
||||
outcome?.status === 'superseded' ||
|
||||
(Boolean(outcome?.share) && !cameraLanded(outcome));
|
||||
});
|
||||
const navigatedSince = (generation) =>
|
||||
Number.isFinite(generation) && shell._navigationGeneration !== generation;
|
||||
const linkStep = (work) =>
|
||||
queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
await work();
|
||||
} catch {
|
||||
/* reported by the action itself */
|
||||
}
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
const linked = annotationsFromParams(hashParams);
|
||||
if (linked.length)
|
||||
queue = linkStep(() => run('annotate_map', { annotations: linked }));
|
||||
// A link's street-level photos open once it has restored. A billed source
|
||||
// only arms, except for the panel page's own link to a result issued just
|
||||
// now. A link that follows an entity keeps the camera on it.
|
||||
const linkedStreetLevel = (params) =>
|
||||
followFromParams(params) ? null : streetLevelFromParams(params);
|
||||
const openLinkedStreetLevel = async (
|
||||
streetLevel,
|
||||
{ generation, autoOpen = false },
|
||||
) => {
|
||||
if (navigatedSince(generation)) return;
|
||||
// The link replays the sender's view: nothing it switches becomes the
|
||||
// recipient's own preference.
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
// A source without a working key changes nothing: say why, or the link
|
||||
// looks broken. In a frame the host reads the result instead.
|
||||
if (result?.stage === 'key' && !isEmbedded(location)) {
|
||||
shell._showToast?.(keyToast(result));
|
||||
return;
|
||||
}
|
||||
// OPEN must be in view, but embed mode hides the rail that holds it: in
|
||||
// a frame a posted view is the way to open a photo.
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
// Opened for this link only: the recipient's saved layout stays theirs.
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
persist: false,
|
||||
});
|
||||
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
|
||||
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
shell._showToast?.(armedToast());
|
||||
}
|
||||
};
|
||||
const startStreetLevel = linkedStreetLevel(hashParams);
|
||||
if (startStreetLevel)
|
||||
queue = linkStep(() =>
|
||||
startSuperseded
|
||||
? undefined
|
||||
: openLinkedStreetLevel(startStreetLevel, {
|
||||
generation: generationAtRestore,
|
||||
autoOpen: isFreshPanelResult(),
|
||||
}),
|
||||
);
|
||||
// A link pasted into the open tab: its photos follow once its restore
|
||||
// settles, under the same rules.
|
||||
const onNavigatedRestore = (event) => {
|
||||
if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail))
|
||||
return;
|
||||
const streetLevel = linkedStreetLevel(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (!streetLevel) return;
|
||||
const generation = restoreGeneration(event.detail);
|
||||
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
|
||||
};
|
||||
windowRef?.addEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
const stopNavigatedRestores = () =>
|
||||
windowRef?.removeEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
if (!isEmbedded(location)) return stopNavigatedRestores;
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
@@ -278,7 +463,6 @@ export function installViews({
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
const onMessage = (event) => {
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
@@ -302,19 +486,82 @@ export function installViews({
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
// A panel's host can hand back old results on a remount: only one issued
|
||||
// just now may open a billed photo. A framing page's view is its own.
|
||||
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
|
||||
// Photos asked for without a point open where the camera looks, measured
|
||||
// from the ground under it, as show_in_gods_eye_view places them.
|
||||
const asked = event.data.view?.street_level;
|
||||
const unplaced =
|
||||
asked &&
|
||||
typeof asked === 'object' &&
|
||||
asked.lat == null &&
|
||||
asked.lon == null;
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
if (unplaced && view.street_level) {
|
||||
// A terrain service that does not answer soon leaves the view's
|
||||
// own estimate.
|
||||
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
|
||||
const owner = watchCameraOwner(viewer, shell);
|
||||
const point = await groundedLookAt(
|
||||
view.camera,
|
||||
terrain,
|
||||
signal ? AbortSignal.any([signal, wait]) : wait,
|
||||
)
|
||||
.catch(() => null)
|
||||
.finally(() => owner.stop());
|
||||
if (signal?.aborted) return;
|
||||
// Whoever takes the camera while the terrain answers (a drag, a
|
||||
// search, the assistant) keeps it: the view stands down.
|
||||
if (owner.taken()) {
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
steps: [{ step: 'street_level', ok: false, superseded: true }],
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (point)
|
||||
view = createView({
|
||||
...event.data.view,
|
||||
street_level: { ...asked, ...point },
|
||||
});
|
||||
}
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
})
|
||||
// A view that fails is answered, and the views behind it still apply.
|
||||
.catch((error) =>
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
},
|
||||
replyOrigin,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
void ready.then(() => {
|
||||
@@ -322,6 +569,7 @@ export function installViews({
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
stopNavigatedRestores();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
+901
-3
@@ -8,7 +8,7 @@ import {
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
import { createView, viewToParams } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
const options = [];
|
||||
const run = async (name, args, runOptions) => {
|
||||
calls.push([name, args]);
|
||||
options.push([name, runOptions]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
return { calls, run, options };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
@@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
|
||||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||||
});
|
||||
|
||||
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
|
||||
const { options, run } = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 48.86, lon: 2.29 },
|
||||
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
|
||||
}),
|
||||
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
|
||||
);
|
||||
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
|
||||
assert.equal(runOptions?.origin, 'programmatic');
|
||||
});
|
||||
|
||||
test('a followed entity is retried until its layer has it', async () => {
|
||||
let tries = 0;
|
||||
const { run } = recorder({
|
||||
@@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => {
|
||||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||||
});
|
||||
|
||||
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
|
||||
const { calls, run } = recorder();
|
||||
const asked = [];
|
||||
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
|
||||
const terrain = {
|
||||
getHeights: async (points) => {
|
||||
asked.push(points);
|
||||
return points.map(() => ({ ellipsoid: 1590 }));
|
||||
},
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
terrain,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: parent,
|
||||
origin: 'https://host.example',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 1,
|
||||
view: {
|
||||
camera: {
|
||||
lat: 39.74,
|
||||
lon: -104.99,
|
||||
altitude_m: 1909,
|
||||
pitch_deg: -30,
|
||||
heading_deg: 0,
|
||||
},
|
||||
street_level: { provider: 'mapillary' },
|
||||
},
|
||||
},
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
remove();
|
||||
const [, args] = calls.find(([name]) => name === 'show_street_level');
|
||||
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
|
||||
// ellipsoid altitude would give.
|
||||
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
|
||||
assert.ok(
|
||||
args.latitude > 39.744 && args.latitude < 39.746,
|
||||
String(args.latitude),
|
||||
);
|
||||
assert.equal(args.longitude, -104.99);
|
||||
});
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const targets = [];
|
||||
@@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = () => {};
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const { calls, run } = recorder();
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
const view = {
|
||||
camera: { lat: 48.8584, lon: 2.2945 },
|
||||
map: 'photoreal',
|
||||
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
|
||||
};
|
||||
for (const [id, fresh] of [
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, undefined],
|
||||
]) {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id, view, fresh },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.filter(([name]) => name === 'show_street_level')
|
||||
.map(([, args]) => args.autoOpen ?? true),
|
||||
[true, false, false],
|
||||
);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
@@ -393,3 +520,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it'
|
||||
/no cockpit view/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a view with street-level photos lets the action fly and open them', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const view = createView({
|
||||
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(([name]) => name),
|
||||
[
|
||||
'set_map_stack',
|
||||
'stop_tracking',
|
||||
'clear_annotations',
|
||||
'show_street_level',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(calls.at(-1)[1], {
|
||||
latitude: 38.5799,
|
||||
longitude: -121.4937,
|
||||
provider: 'google',
|
||||
fly: true,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.equal(steps.at(-1).step, 'show_street_level');
|
||||
});
|
||||
|
||||
test('a link to street-level photos opens them after it restores', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 38.58,
|
||||
longitude: -121.49,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[[], []],
|
||||
'an opened photo needs no toast or panel nudge',
|
||||
);
|
||||
// A failure (no key) is not an armed state either: it says why instead.
|
||||
const failed = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: failed.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[
|
||||
[
|
||||
"Street Level cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
[],
|
||||
],
|
||||
'it says why, and nothing to press opens',
|
||||
);
|
||||
});
|
||||
|
||||
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const params = viewToParams(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
follow: { kind: 'satellite', id: '25544' },
|
||||
}),
|
||||
);
|
||||
params.set('sl', 'mapillary,1.00000,2.00000');
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: `#${params}` },
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a failing link step does not take the next one with it', async () => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map') throw new Error('marks failed');
|
||||
return { ok: true, armed: false, ...args };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
|
||||
for (const embed of [false, true]) {
|
||||
const toasts = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
provider: 'google',
|
||||
providerName: 'Google Street View',
|
||||
error: 'Google Street View needs its key',
|
||||
}),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
if (embed) {
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
}
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed() {
|
||||
throw new Error('nothing to arm');
|
||||
},
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: embed ? '?embed=1' : '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][0], 'show_street_level');
|
||||
assert.deepEqual(
|
||||
toasts,
|
||||
embed
|
||||
? []
|
||||
: [
|
||||
"Google Street View cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
const revealed = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (id, collapsed, options) =>
|
||||
panels.push([id, collapsed, options]),
|
||||
revealPanel: (id) => revealed.push(id),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][1].autoOpen, false);
|
||||
assert.deepEqual(toasts, [
|
||||
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
panels,
|
||||
[['street-level-panel', false, { explicit: true, persist: false }]],
|
||||
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
|
||||
);
|
||||
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
|
||||
// Adding ?embed=1 to a published link does not lift the gate: the page is
|
||||
// still a plain top-level load.
|
||||
const framedToasts = [];
|
||||
const framedPanels = [];
|
||||
const framed = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => framedToasts.push(text),
|
||||
setPanelCollapsed: (...args) => framedPanels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: framed.run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(framed.calls[0][1].autoOpen, false);
|
||||
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
|
||||
assert.deepEqual([framedToasts, framedPanels], [[], []]);
|
||||
// Only the panel page's own link for a result issued just now is the
|
||||
// client's request: a host that mounts the panel again hands back an old
|
||||
// result, and that one only arms, like any link.
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
for (const fresh of [true, false]) {
|
||||
globalThis.GEV_EMBED_FRESH = fresh;
|
||||
const inline = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
const inlineWindow = new EventTarget();
|
||||
inlineWindow.document = { body: { classList: { add() {} } } };
|
||||
inlineWindow.postMessage = () => {};
|
||||
// The inline panel keeps rendering from a timer; give it inert clocks.
|
||||
Object.assign(inlineWindow, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const stop = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: inline.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: inlineWindow,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
|
||||
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
|
||||
stop();
|
||||
}
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
delete globalThis.GEV_EMBED_FRESH;
|
||||
}
|
||||
// applyView (a posted view) opens too.
|
||||
const posted = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
street_level: { provider: 'google' },
|
||||
}),
|
||||
{
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: posted.run,
|
||||
},
|
||||
);
|
||||
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
|
||||
});
|
||||
|
||||
test('a view whose photos fail before any flight still gets its camera', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 1, 'the view flies itself');
|
||||
assert.deepEqual(
|
||||
steps.map((step) => [step.step, step.ok]),
|
||||
[
|
||||
['set_map_stack', true],
|
||||
['stop_tracking', true],
|
||||
['clear_annotations', true],
|
||||
['show_street_level', false],
|
||||
['camera', true],
|
||||
],
|
||||
);
|
||||
assert.ok(calls.some(([name]) => name === 'show_street_level'));
|
||||
// A flight that happened (the action reports where) is not repeated.
|
||||
const flown = fakeViewer();
|
||||
const { run: flownRun } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
location: 'Somewhere',
|
||||
error: 'no imagery',
|
||||
}),
|
||||
});
|
||||
await applyView(view, {
|
||||
viewer: flown,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: flownRun,
|
||||
});
|
||||
assert.equal(flown.flights.length, 0);
|
||||
});
|
||||
|
||||
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
|
||||
const log = [];
|
||||
let releaseStreetLevel = null;
|
||||
let releaseMarks = null;
|
||||
const run = async (name, args) => {
|
||||
log.push(`start:${name}`);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
if (name === 'show_street_level')
|
||||
await new Promise((resolve) => {
|
||||
releaseStreetLevel = resolve;
|
||||
});
|
||||
log.push(`end:${name}`);
|
||||
return {
|
||||
ok: true,
|
||||
...(name === 'show_street_level' ? { location: 'x' } : {}),
|
||||
};
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
// A framing page: answers go to it, not to the app's own window.
|
||||
windowRef.parent = {
|
||||
postMessage: (message) => log.push(`post:${message.type}`),
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// A view arrives while the link's photos are still opening.
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('message'), {
|
||||
source: windowRef.parent,
|
||||
origin: 'null',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 'v1',
|
||||
view: { camera: { lat: 3, lon: 4 } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
|
||||
assert.deepEqual(
|
||||
actions(),
|
||||
['start:annotate_map'],
|
||||
'the photos wait for the marks',
|
||||
);
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(actions().slice(0, 3), [
|
||||
'start:annotate_map',
|
||||
'end:annotate_map',
|
||||
'start:show_street_level',
|
||||
]);
|
||||
assert.ok(
|
||||
!log.includes('start:stop_tracking'),
|
||||
'the posted view waits for the link',
|
||||
);
|
||||
releaseStreetLevel();
|
||||
for (
|
||||
let tick = 0;
|
||||
tick < 20 && !log.includes('post:gev:view-applied');
|
||||
tick++
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
|
||||
true,
|
||||
);
|
||||
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
|
||||
});
|
||||
|
||||
test('an aborted install never runs the link photos', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
signal: controller.signal,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
|
||||
const calls = [];
|
||||
let releaseMarks = null;
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 7,
|
||||
};
|
||||
const run = async (name) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map']);
|
||||
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
['annotate_map'],
|
||||
'the link did not fly over the newer navigation',
|
||||
);
|
||||
|
||||
// Unchanged navigation: the photos open as before.
|
||||
const quiet = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 3,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: async (name) => {
|
||||
quiet.push(name);
|
||||
return { ok: true };
|
||||
},
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(options, [
|
||||
['show_street_level', { origin: 'programmatic' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a start link whose restore a newer link superseded opens nothing', async () => {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
|
||||
});
|
||||
|
||||
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
|
||||
for (const [camera, expected] of [
|
||||
['cancelled', 0],
|
||||
['skipped', 0],
|
||||
['applied', 1],
|
||||
]) {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({
|
||||
status: 'settled',
|
||||
share: { camera },
|
||||
}),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, expected, camera);
|
||||
}
|
||||
});
|
||||
|
||||
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
const windowRef = new EventTarget();
|
||||
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 1,
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location,
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'the start link has no photos');
|
||||
const settled = (status, camera = 'applied') =>
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('gev:share-navigation-restore-settled'), {
|
||||
detail: { status, share: { camera } },
|
||||
}),
|
||||
);
|
||||
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
|
||||
shell._navigationGeneration = 2; // the restore's own flight
|
||||
settled('failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'a failed restore opens nothing');
|
||||
// The recipient dragged or searched during the restore flight: the camera
|
||||
// is theirs, and the link does not pull it back.
|
||||
shell._navigationGeneration = 3;
|
||||
for (const camera of ['cancelled', 'superseded']) {
|
||||
settled('settled', camera);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[],
|
||||
`an interrupted restore (${camera}) opens nothing`,
|
||||
);
|
||||
}
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 48.8874,
|
||||
longitude: 2.3495,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(options.at(-1), [
|
||||
'show_street_level',
|
||||
{ origin: 'programmatic' },
|
||||
]);
|
||||
remove();
|
||||
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1, 'removed with the install');
|
||||
});
|
||||
|
||||
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
|
||||
// Photo B, opened by a click while the posted view looked up its photo.
|
||||
const street = { open: false, imageId: null };
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
[
|
||||
'street-level',
|
||||
{
|
||||
module: {
|
||||
closeViewer: () =>
|
||||
Object.assign(street, { open: false, imageId: null }),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => {
|
||||
Object.assign(street, { open: true, imageId: 'b' });
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
cancelled: true,
|
||||
superseded: true,
|
||||
error: 'The camera moved on before the photo opened',
|
||||
};
|
||||
},
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const steps = await applyView(view, { viewer, dataManager, run });
|
||||
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
|
||||
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
|
||||
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
|
||||
});
|
||||
|
||||
test('a view whose photo was found but could not load keeps its error in view', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
imageryError: true,
|
||||
location: '1.00000, 2.00000',
|
||||
error: 'Image could not be opened',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
await applyView(view, { viewer: fakeViewer(), dataManager, run });
|
||||
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
|
||||
});
|
||||
|
||||
test('a view whose photos fail closes the photo an earlier view opened', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
await applyView(view, { viewer, dataManager, run });
|
||||
assert.equal(closed.length, 1, 'closed before the view flies away');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const opened = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
await applyView(view, { viewer, dataManager, run: opened.run });
|
||||
assert.equal(closed.length, 1, 'an opened photo stays');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installViews } from './embed.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
|
||||
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
// A camera taken while a posted view waits on terrain stays with whoever took
|
||||
// it: the stale view stands down whole, so it neither stops a newer follow nor
|
||||
// frames its own marks. Offline: real installViews, action runner and
|
||||
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
|
||||
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function scenario(t, effect) {
|
||||
document.body.className = '';
|
||||
const entered = deferred();
|
||||
const terrain = deferred();
|
||||
const viewer = fakeCesiumViewer();
|
||||
viewer.clock = { onTick: new Cesium.Event() };
|
||||
viewer.canvas = viewer.scene.canvas;
|
||||
// A real perspective frustum looking down at 0,0; the old view's mark
|
||||
// at 48N,2E is outside it. No override of the engine's visibility logic.
|
||||
Object.assign(viewer.camera, {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
up: new Cesium.Cartesian3(0, 0, 1),
|
||||
heading: 0,
|
||||
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
|
||||
});
|
||||
const entity = { id: 'fixture-aircraft' };
|
||||
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
|
||||
let stopCalls = 0;
|
||||
const flights = {
|
||||
findByQuery: () => aircraft,
|
||||
trackById: () => { viewer.trackedEntity = entity; return true; },
|
||||
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
|
||||
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
|
||||
};
|
||||
const dataManager = {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
getAll: () => [{ id: 'flights', enabled: true }],
|
||||
isEnabled: id => id === 'flights',
|
||||
setLayerParams: () => true,
|
||||
};
|
||||
const shell = {
|
||||
_navigationGeneration: 1,
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
|
||||
};
|
||||
const annotations = createAnnotationEngine({ viewer,
|
||||
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
|
||||
});
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
|
||||
const calls = [];
|
||||
const run = async (name, args, options) => {
|
||||
calls.push(name);
|
||||
return runner(name, args, options);
|
||||
};
|
||||
const replies = [];
|
||||
const peer = { postMessage: message => replies.push(message) };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = peer;
|
||||
windowRef.document = document;
|
||||
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
|
||||
});
|
||||
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
|
||||
await tick();
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
|
||||
type: 'gev:view', id: 1, view: {
|
||||
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
|
||||
layers: ['flights'],
|
||||
street_level: { provider: 'mapillary' },
|
||||
annotations: effect === 'annotations'
|
||||
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
|
||||
},
|
||||
} });
|
||||
windowRef.dispatchEvent(event);
|
||||
await entered.promise;
|
||||
if (effect === 'follow') {
|
||||
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
|
||||
assert.equal(follow.ok, true, 'new follow starts through the real runner');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
} else {
|
||||
viewer.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
}
|
||||
terrain.resolve([{ ellipsoid: 200 }]);
|
||||
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
|
||||
const reply = replies.find(reply => reply.id === 1);
|
||||
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
|
||||
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
|
||||
assert.ok(reply, 'host receives completion');
|
||||
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
|
||||
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
|
||||
if (effect === 'follow') {
|
||||
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
|
||||
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
|
||||
} else {
|
||||
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
|
||||
}
|
||||
}
|
||||
|
||||
for (const effect of ['follow', 'annotations']) {
|
||||
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
|
||||
}
|
||||
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
|
||||
* Load God's Eye View into this page from the app's panel build, in
|
||||
* inline embed mode, starting at the first view as its link would.
|
||||
*/
|
||||
async function startApp(url, view) {
|
||||
async function startApp(url, view, fresh = false) {
|
||||
window.GEV_EMBED_INLINE = true;
|
||||
// Whether this result was just issued, not handed back by a host that
|
||||
// mounted the panel again: only then may its link open a billed photo.
|
||||
window.GEV_EMBED_FRESH = fresh;
|
||||
window.GEV_APP_BASE_URL = config.appBaseUrl;
|
||||
try {
|
||||
history.replaceState(
|
||||
@@ -551,13 +554,21 @@ export function panelRuntime(config) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tool results: the first view starts the app, later ones move it.
|
||||
// Tool results: the first view starts the app, later ones move it. A
|
||||
// result issued longer ago than this was handed back on a remount.
|
||||
const FRESH_RESULT_MS = 5 * 60 * 1000;
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let queuedFresh = false;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
// Every view carries whether its result was issued just now: only those may
|
||||
// open a billed photo, whichever result on this mount they came in.
|
||||
const postView = (view, fresh) =>
|
||||
window.postMessage(
|
||||
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
|
||||
'*',
|
||||
);
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
@@ -565,13 +576,18 @@ export function panelRuntime(config) {
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
const issuedAt = Number(data.issued_at);
|
||||
const fresh =
|
||||
Number.isFinite(issuedAt) &&
|
||||
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
|
||||
if (ready) return postView(view, fresh);
|
||||
queued = view;
|
||||
queuedFresh = fresh;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
startApp(url, view, fresh).catch((error) =>
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
@@ -592,7 +608,7 @@ export function panelRuntime(config) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
if (queued) postView(queued, queuedFresh);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { panelRuntime } from './globePanelRuntime.js';
|
||||
|
||||
const element = () => ({
|
||||
isConnected: true,
|
||||
hidden: true,
|
||||
textContent: '',
|
||||
addEventListener() {},
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
},
|
||||
});
|
||||
|
||||
test('every view the panel posts says whether its result was issued just now', async () => {
|
||||
const savedWindow = globalThis.window;
|
||||
const savedDocument = globalThis.document;
|
||||
const views = [];
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
Object.assign(windowRef, {
|
||||
parent,
|
||||
postMessage: (message) => {
|
||||
if (message?.type === 'gev:view') views.push(message);
|
||||
},
|
||||
});
|
||||
const elements = { status: element(), open: element(), expand: element() };
|
||||
globalThis.window = windowRef;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
addEventListener() {},
|
||||
baseURI: 'https://host.example/panel',
|
||||
body: { clientWidth: 600 },
|
||||
};
|
||||
const deliver = (source, data) => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
const result = (issuedAt) => ({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
structuredContent: {
|
||||
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
|
||||
issued_at: issuedAt,
|
||||
view: { camera: { lat: 48.8584, lon: 2.2945 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
panelRuntime({
|
||||
appBaseUrl: 'http://localhost:4173/',
|
||||
panelBase: '/panel/',
|
||||
protocolVersion: '2025-06-18',
|
||||
panelHeight: 480,
|
||||
});
|
||||
deliver(windowRef, { type: 'gev:ready' });
|
||||
deliver(parent, result(Date.now()));
|
||||
// A host replaying an old result on the same mount.
|
||||
deliver(parent, result(Date.now() - 60 * 60 * 1000));
|
||||
deliver(parent, result(undefined));
|
||||
} finally {
|
||||
globalThis.window = savedWindow;
|
||||
globalThis.document = savedDocument;
|
||||
}
|
||||
assert.deepEqual(
|
||||
views.map((message) => message.fresh),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
+21
-2
@@ -1,6 +1,11 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import {
|
||||
claimPointer,
|
||||
isPointerFree,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
@@ -241,9 +246,23 @@ export function createApplicationTools({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
run: (name, args, options = {}) =>
|
||||
voiceCommands.runner(name, args, { ...options, signal }),
|
||||
signal,
|
||||
terrain: operations.requests.terrain,
|
||||
}),
|
||||
);
|
||||
return { sceneDirector, annotations, voiceCommands };
|
||||
// An application's own tool takes the pointer as Draw does, and its own
|
||||
// click handlers yield while a tool holds it, as the layers' do
|
||||
// (data/inputOwnership.js).
|
||||
return {
|
||||
sceneDirector,
|
||||
annotations,
|
||||
voiceCommands,
|
||||
pointer: {
|
||||
claim: claimPointer,
|
||||
release: releasePointer,
|
||||
isFree: isPointerFree,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||
|
||||
const FULL_RECORD = {
|
||||
id: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RECORD);
|
||||
assert.deepEqual(r, {
|
||||
id: 'EVER GIVEN',
|
||||
vesselId: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
||||
assert.equal(r.name, null);
|
||||
});
|
||||
|
||||
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
|
||||
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
|
||||
assert.equal(shared.vesselId, 'provider-a');
|
||||
assert.equal(shared.mmsi, '353136000');
|
||||
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
|
||||
assert.equal(noMmsi.vesselId, 'provider-b');
|
||||
assert.equal(noMmsi.mmsi, null);
|
||||
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
|
||||
});
|
||||
|
||||
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||
assert.equal(r.mmsi, null);
|
||||
|
||||
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 456,
|
||||
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
lastMessageAt: 789,
|
||||
rawRowCount: 1,
|
||||
acceptedRowCount: 0,
|
||||
selectedMmsi: record.mmsi,
|
||||
trailMmsi: record.mmsi,
|
||||
selectedId: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositionCount: 1,
|
||||
sessionId: _getVesselFeedStateForTest().sessionId,
|
||||
firstConnectPhase: 'idle',
|
||||
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
records: [replacement],
|
||||
selectedRecord: replacement,
|
||||
trail: replacementTrail,
|
||||
trailMmsi: replacement.mmsi,
|
||||
trailId: replacement.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 200,
|
||||
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
let feed = _getVesselFeedStateForTest();
|
||||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
||||
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
|
||||
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||||
await replacementLoad;
|
||||
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
assert.equal(feed.lastUpdate, 200);
|
||||
assert.equal(feed.error, 'awaiting first AIS message…');
|
||||
assert.equal(feed.stale, true);
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
assert.equal(feed.trailId, replacement.mmsi);
|
||||
assert.equal(replacementTrail.clearCalls, 0);
|
||||
} finally {
|
||||
globalThis.fetch = priorFetch;
|
||||
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
|
||||
|
||||
test('vessel selection: empty-space click requests deselection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
|
||||
{ action: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
||||
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
|
||||
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '367123450',
|
||||
selectedId: '353136000',
|
||||
pickedId: '367123450',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'select' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '353136000',
|
||||
selectedId: '353136000',
|
||||
pickedId: '353136000',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'none' },
|
||||
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
|
||||
|
||||
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
|
||||
|
||||
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
const record = {
|
||||
name: 'EVER GIVEN',
|
||||
mmsi: '353136000',
|
||||
type: 'Container Ship',
|
||||
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
|
||||
billboard: null,
|
||||
...overrides,
|
||||
};
|
||||
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeTrailSpy() {
|
||||
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory,
|
||||
interactionKeyTarget: keyTarget,
|
||||
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
||||
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
||||
selectedRecord: harness.record,
|
||||
enabled: false,
|
||||
trail: harness.trail,
|
||||
trailMmsi: harness.record.mmsi,
|
||||
trailId: harness.record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||
interactionKeyTarget: harness.keyTarget,
|
||||
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
|
||||
aisLiveVesselsLayer.clearSelection();
|
||||
assert.equal(harness.trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 1,
|
||||
});
|
||||
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
});
|
||||
try {
|
||||
_reconcileVesselsForTest({}, []);
|
||||
assert.equal(trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 0,
|
||||
});
|
||||
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
|
||||
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
|
||||
const harness = installWireHarness(
|
||||
{ id: idOnly },
|
||||
{ records: [idOnly], selectedRecord: null, trailId: null },
|
||||
);
|
||||
const requests = [];
|
||||
harness.windowTarget.addEventListener(
|
||||
WORLD_FOCUS_REQUEST_EVENT,
|
||||
(event) => requests.push(event.detail),
|
||||
);
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
|
||||
assert.equal(requests[0]?.id, 'provider-7');
|
||||
assert.match(harness.hud.readout.text, /MMSI: -- /);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Overlay-host recorder whose hitTest answers with one card id. The real host
|
||||
* only publishes rects for `interactive` entries, so a stub returning a hit is
|
||||
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||||
test('vessel card policy: only keyed cards publish a hit rect', () => {
|
||||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||
assert.equal(keyed.interactive, true);
|
||||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
||||
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
|
||||
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
|
||||
assert.equal(idOnly.id, 'vessel:provider-7');
|
||||
});
|
||||
|
||||
test('vessel cards are keyed by id and show the MMSI only when known', () => {
|
||||
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
|
||||
assert.equal(shared.id, 'vessel:hull-b');
|
||||
assert.equal(shared.actionable, true);
|
||||
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
|
||||
const unknown = buildSelectedVesselCard(
|
||||
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
|
||||
);
|
||||
assert.equal(unknown.actionable, true);
|
||||
assert.match(unknown.details.at(-1), /^MMSI -- · /);
|
||||
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
|
||||
});
|
||||
|
||||
test('vessel interaction wire: deselecting never moves the camera', () => {
|
||||
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
@@ -1802,3 +1843,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'planespotters',
|
||||
html:
|
||||
'Aircraft photos: ' +
|
||||
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
|
||||
'· each photo is © its photographer, credited beside it',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -566,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:
|
||||
@@ -632,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';
|
||||
@@ -43,6 +44,13 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('Planespotters is credited with a plain followable link', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters');
|
||||
assert.ok(credit, 'the tracked aircraft photos come from Planespotters');
|
||||
assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/);
|
||||
assert.doesNotMatch(credit.html, /nofollow/);
|
||||
});
|
||||
|
||||
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||
// UCS database manual, section 3: references name the version (the Excel
|
||||
// file's name) and link www.ucsusa.org/satellite_database.
|
||||
@@ -182,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/);
|
||||
|
||||
+109
-4
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -47,6 +50,8 @@ import {
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
@@ -107,6 +112,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -263,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC;
|
||||
let _lastNonOffMode = MODE_BALANCED;
|
||||
/** @type {LabelArbiter} Shared stable callout selector. */
|
||||
const _labelArbiter = new LabelArbiter();
|
||||
/**
|
||||
* Hides the bracket and callout of a depth-tested contact (a street vehicle)
|
||||
* that the scene drew something in front of. `_occlusionProbe` asks the GPU
|
||||
* about each such contact without waiting for the answer; `_surfaceOcclusion`
|
||||
* remembers the answers and decides, every frame, who is drawn.
|
||||
*/
|
||||
const _occlusionProbe = new SceneOcclusionProbe();
|
||||
const _surfaceOcclusion = new SurfaceOcclusion(_occlusionProbe);
|
||||
/**
|
||||
* Keys of callouts the label arbiter still holds for contacts last seen
|
||||
* hidden. A key stays here until its contact is seen drawn again or the
|
||||
* arbiter lets the callout go, so the callout cannot come back at its old
|
||||
* place just because its contact has since left the view.
|
||||
*/
|
||||
const _surfaceSuppressedKeys = new Set();
|
||||
let _surfaceDepthFallbackReported = false;
|
||||
let _lastLabelSolveAt = 0;
|
||||
let _labelSolveDirty = true;
|
||||
let _lastDiagnostics = null;
|
||||
@@ -367,6 +390,9 @@ export function destroyDetection() {
|
||||
_suspended = false;
|
||||
_suspendReason = '';
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.destroy();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastDiagnostics = null;
|
||||
@@ -576,7 +602,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -691,6 +718,9 @@ function _applyModeState() {
|
||||
_syncSurfaceVisibility();
|
||||
_setLanesActive(false);
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.reset();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = {
|
||||
@@ -1077,6 +1107,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1136,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1169,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1254,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1316,6 +1361,9 @@ function _drawOverlay(frame) {
|
||||
// every host lane. Detection retains the manual scalar projection below.
|
||||
const occluder = frame.occluder;
|
||||
const camPos = frame.cameraPosition;
|
||||
// Answers to the occlusion tests started on earlier frames land here.
|
||||
_surfaceOcclusion.beginFrame(now);
|
||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||
const keyhole = frame.keyhole;
|
||||
// Read the OUTSIDE setting ONCE per paint, from the same module state the
|
||||
// host's own keyhole alpha comes from, so a bracket and its callout can never
|
||||
@@ -1426,6 +1474,29 @@ function _drawOverlay(frame) {
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
// Something the scene drew in front of a depth-tested contact hides its
|
||||
// bracket and callout too, on every frame, not only on solves.
|
||||
if (obj.depthTested === true) {
|
||||
const testedLayerId = obj._layerId || 'unknown';
|
||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||
const hidden = _surfaceOcclusion.isHidden(
|
||||
testedLayerId,
|
||||
testedSourceId,
|
||||
obj.position,
|
||||
);
|
||||
// If the arbiter holds a callout for this contact it would keep painting
|
||||
// it until the next solve, and then fade it out where it last stood;
|
||||
// see `_surfaceSuppressedKeys`. Only held contacts cost a key.
|
||||
if (
|
||||
(hidden || _surfaceSuppressedKeys.size > 0) &&
|
||||
renderIdentities.get(testedLayerId)?.has(testedSourceId)
|
||||
) {
|
||||
const key = _detectionKey(testedLayerId, testedSourceId);
|
||||
if (hidden) _surfaceSuppressedKeys.add(key);
|
||||
else _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
if (hidden) continue;
|
||||
}
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
@@ -1526,6 +1597,8 @@ function _drawOverlay(frame) {
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
// Start this frame's occlusion tests; nothing here waits for them.
|
||||
_occlusionProbe.endFrame();
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
@@ -1536,6 +1609,13 @@ function _drawOverlay(frame) {
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
_surfaceOcclusion.prune();
|
||||
if (_occlusionProbe.mode === 'sync' && !_surfaceDepthFallbackReported) {
|
||||
_surfaceDepthFallbackReported = true;
|
||||
console.info(
|
||||
'[detection] No occlusion queries here (WebGL 1); street-vehicle label occlusion reads the frame depth back synchronously, about 8 times a second.',
|
||||
);
|
||||
}
|
||||
const quotas = allocateLayerQuotas(
|
||||
demandByLayer,
|
||||
ambientBudget,
|
||||
@@ -1622,7 +1702,25 @@ function _drawOverlay(frame) {
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
}
|
||||
|
||||
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
let renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
// A callout whose contact has gone behind something goes with it at once:
|
||||
// no carry-over to the next solve and no fade painted on the facade.
|
||||
if (_surfaceSuppressedKeys.size > 0) {
|
||||
const allEntries = renderEntries;
|
||||
renderEntries = allEntries.filter(
|
||||
(entry) => !_surfaceSuppressedKeys.has(entry.candidate.key),
|
||||
);
|
||||
// The arbiter has let some of them go: forget those.
|
||||
if (
|
||||
allEntries.length - renderEntries.length !==
|
||||
_surfaceSuppressedKeys.size
|
||||
) {
|
||||
const held = new Set();
|
||||
for (const entry of allEntries) held.add(entry.candidate.key);
|
||||
for (const key of _surfaceSuppressedKeys)
|
||||
if (!held.has(key)) _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
const fadingCount = countFadingRenderEntries(renderEntries);
|
||||
// Demand counts fades in BOTH directions; `fadingCount` above stays the
|
||||
// fade-OUT tail because that is what the published diagnostics have always
|
||||
@@ -1677,6 +1775,13 @@ function _drawOverlay(frame) {
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
calloutOcclusionCount: calloutOcclusionRects.length,
|
||||
surfaceTestedCount: _surfaceOcclusion.testedCount,
|
||||
surfaceHiddenCount: _surfaceOcclusion.hiddenCount,
|
||||
surfaceDepthMode: _occlusionProbe.mode,
|
||||
// Tests started on the last frame, and what collecting answers and
|
||||
// drawing them cost it.
|
||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||
};
|
||||
} else {
|
||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||
|
||||
@@ -20,15 +20,28 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
import {
|
||||
createFakeDepthScene,
|
||||
createFakeGl,
|
||||
depthImage,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -579,6 +592,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
@@ -1190,3 +1375,289 @@ test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
|
||||
/**
|
||||
* Give the host environment a scene that keeps frame depth: the camera 10,000
|
||||
* km up the +Z axis looking down it, and one depth range in which the whole
|
||||
* frame is a surface `surfaceDistance()` metres in front of the camera.
|
||||
*/
|
||||
function installDepthScene(env, surfaceDistance, { webgl2 = true } = {}) {
|
||||
const near = 1;
|
||||
const far = 100_000_000;
|
||||
const gl = createFakeGl({ webgl2 });
|
||||
const camera = env.viewer.camera;
|
||||
camera.viewMatrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -10_000_000));
|
||||
camera.frustum.fovy = Math.PI / 2;
|
||||
camera.frustum.aspectRatio = 800 / 600;
|
||||
const paint = () =>
|
||||
depthImage(8, 6, () => viewDistanceToDepth(surfaceDistance(), near, far, true));
|
||||
const image = paint();
|
||||
const scene = createFakeDepthScene({ gl, ranges: [{ near, far, image }] });
|
||||
Object.assign(env.viewer.scene, scene, { camera });
|
||||
return {
|
||||
gl,
|
||||
/** Draw the next frame's depth. */
|
||||
redraw() {
|
||||
image.data.set(paint().data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Street contacts 3,643 km and 2,000 km below the camera, and a flight. */
|
||||
function occlusionLayers() {
|
||||
return [
|
||||
{
|
||||
id: 'traffic',
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0, 6_356_752), id: 'VEH-0001', type: 'VEH', depthTested: true },
|
||||
{ position: new Cesium.Cartesian3(0.4, 0, 8_000_000), id: 'VEH-0002', type: 'VEH', depthTested: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
// The same nearer surface is in front of it, but it is not depth-tested.
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0.3, 6_356_752), sourceId: 'f1', id: 'AIR1', metric: 'FL300', type: 'AIR' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function paintedText(env) {
|
||||
return env.ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text);
|
||||
}
|
||||
|
||||
/** One rendered frame 16 ms on; `settleFrame` is one 250 ms on (a solve). */
|
||||
function nextFrame(env) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
}
|
||||
|
||||
test('a depth-tested contact behind drawn geometry loses its bracket and callout, tested on the GPU without anything read back', () => {
|
||||
const env = installEnvironment();
|
||||
// A roof 3,000 km below the camera: in front of VEH-0001, behind VEH-0002.
|
||||
let surface = 3_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
let diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'async');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2, 'only the depth-tested contacts');
|
||||
assert.equal(diagnostics.surfaceProbeTests, 2);
|
||||
// New to the view, so held back for the frame or two the first answer
|
||||
// takes: neither is drawn yet. The flight is.
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 2, 'no answer has arrived yet');
|
||||
assert.equal(diagnostics.visibleCount, 1);
|
||||
assert.equal(depth.gl.counts.queries, 3, 'a control point and one query per contact');
|
||||
|
||||
// Frames before the GPU answers start nothing new and wait for nothing.
|
||||
nextFrame(env);
|
||||
nextFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 3);
|
||||
assert.equal(depth.gl.counts.resultReads, 0);
|
||||
|
||||
// The answers land. The contact above the roof is drawn from then on; the
|
||||
// one under it never is.
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'));
|
||||
assert.ok(paintedText(env).includes('VEH-0002'));
|
||||
|
||||
// It stays gone, frame after frame and solve after solve.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2, 'the hidden contact has no bracket');
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0002'));
|
||||
assert.ok(!diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
assert.ok(diagnostics.labeledKeys.includes('flights:f1'));
|
||||
assert.ok(paintedText(env).includes('AIR1'));
|
||||
assert.equal(depth.gl.counts.readPixels, 0, 'depth never left the GPU');
|
||||
|
||||
// The roof goes away: the bracket returns once visible answers have kept
|
||||
// coming for the reappear time, and the callout once a solve re-admits it.
|
||||
surface = 5_000_000;
|
||||
depth.redraw();
|
||||
for (let i = 0; i < 80; i++) {
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a labelled contact that goes behind something loses its callout at once, and does not get it back by leaving the view', () => {
|
||||
const env = installEnvironment();
|
||||
// To begin with the drawn surface is below both vehicles.
|
||||
let surface = 5_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
const layers = occlusionLayers();
|
||||
const everyVehicle = layers[0].getDetectableObjects;
|
||||
const answerAndSolve = (count) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, layers, () => {});
|
||||
setMode('DENSE');
|
||||
answerAndSolve(6);
|
||||
assert.ok(getDetectionDiagnostics().labeledKeys.includes('traffic:VEH-0001'));
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(paintedText(env).includes('VEH-0001'));
|
||||
|
||||
// A roof is drawn over VEH-0001. Its callout goes on the frame the answer
|
||||
// lands, which is not a solve: no carry-over, no fade.
|
||||
surface = 3_000_000;
|
||||
depth.redraw();
|
||||
let gone = -1;
|
||||
for (let i = 0; i < 30 && gone < 0; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
if (!paintedText(env).includes('VEH-0001')) gone = i;
|
||||
}
|
||||
assert.ok(gone >= 0, 'the callout went');
|
||||
// The layer stops reporting it (it drove out of view). The arbiter still
|
||||
// remembers where its callout was; it must not paint it there again.
|
||||
layers[0].getDetectableObjects = () => everyVehicle().slice(1);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('without scene depth every contact keeps its bracket and callout', () => {
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2);
|
||||
assert.equal(diagnostics.surfaceProbeTests, 0);
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene depth that goes away puts hidden contacts back', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
// The scene stops keeping pick depth (a 2D view, depth picking turned off).
|
||||
depth.gl.finishGpu();
|
||||
env.viewer.scene.useDepthPicking = false;
|
||||
// The hidden time runs on painted frames, so paint a second's worth.
|
||||
for (let i = 0; i < 70; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning detection off drops every test in flight; tearing it down frees the GPU objects', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.liveQueries.size, 3);
|
||||
// Off keeps the GPU objects (the shader is not compiled again on the next
|
||||
// on) but the answers in flight are no longer wanted.
|
||||
setMode('OFF');
|
||||
assert.equal(depth.gl.counts.deleted, 0);
|
||||
depth.gl.finishGpu();
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 6, 'both contacts are tested afresh');
|
||||
assert.equal(depth.gl.liveQueries.size, 3, 'on the same queries, reused');
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
assert.equal(getDetectionDiagnostics().visibleCount, 2);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
// Tearing detection down releases everything.
|
||||
assert.equal(depth.gl.liveQueries.size, 0);
|
||||
assert.equal(depth.gl.counts.deleted, 5);
|
||||
});
|
||||
|
||||
test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000, { webgl2: false });
|
||||
const originalInfo = console.info;
|
||||
const reported = [];
|
||||
console.info = (...args) => reported.push(args.join(' '));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 30; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'sync');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2);
|
||||
// Half a second of frames: a read about every eighth of one, not one per
|
||||
// frame and not one per contact.
|
||||
assert.ok(depth.gl.counts.readPixels >= 3 && depth.gl.counts.readPixels <= 6, `${depth.gl.counts.readPixels} reads`);
|
||||
assert.equal(reported.length, 1);
|
||||
assert.match(reported[0], /synchronously/);
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Scene-geometry occlusion for detection contacts.
|
||||
*
|
||||
* The overlay's own horizon test only knows about the Earth's curve. A contact
|
||||
* whose marker the scene depth-tests (a street vehicle's dot) can still sit
|
||||
* behind a building, a 3D tile or a hill, and painting its bracket and callout
|
||||
* there puts a label on the facade in front of it.
|
||||
*
|
||||
* The fix compares each such contact with what the renderer actually drew: if
|
||||
* the drawn surface along the contact's ray is nearer than the contact by more
|
||||
* than a small margin, something stands in front of it and its bracket and
|
||||
* callout are not drawn. `sceneOcclusionProbe.js` runs that comparison on the
|
||||
* GPU, against the frame's own depth, and hands back a yes or no per contact a
|
||||
* frame or two later without ever waiting on the GPU. This file holds the
|
||||
* arithmetic both sides share and the per-contact memory of the answers.
|
||||
*
|
||||
* Only contacts that opt in with `depthTested: true` are tested: the ones whose
|
||||
* marker the scene itself hides behind geometry. Everything else keeps its
|
||||
* label exactly as before, which covers contacts high above the ground
|
||||
* (aircraft, satellites): their markers are drawn on top of the scene by
|
||||
* design, their 3D models can be scaled up to a minimum pixel size and so fill
|
||||
* the very pixel being read, and a label that hid while its marker stayed on
|
||||
* screen would be the worse mismatch.
|
||||
*
|
||||
* This file is plain arithmetic; it never touches the renderer.
|
||||
*/
|
||||
|
||||
/** Never less than this: covers a dot drawn a little above or into its road. */
|
||||
export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3;
|
||||
/** The most the margin grows with range: 2% of it. */
|
||||
export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02;
|
||||
/**
|
||||
* How far above a contact a drawn surface on its line of sight must stand to
|
||||
* count as in front of it, when the camera looks down on the contact.
|
||||
*/
|
||||
export const SURFACE_OCCLUSION_RISE_M = 2;
|
||||
|
||||
/**
|
||||
* How much nearer than the contact the drawn surface must be before it counts
|
||||
* as standing in front of it.
|
||||
*
|
||||
* The margin absorbs a contact placed a little above or below the drawn
|
||||
* ground: a vehicle's position and the scene's road surface are not the same
|
||||
* height to the metre. Looking down at the contact, a point on its line of
|
||||
* sight `s` metres in front of it stands `s * sinElevation` metres above it,
|
||||
* so a surface there only hides the contact once `s` passes
|
||||
* `SURFACE_OCCLUSION_RISE_M / sinElevation`. That keeps the margin to a few
|
||||
* metres from above, where a fixed share of the range would let a wall,
|
||||
* awning or tree 10 to 20 m in front of a street at 1 km pass as "the road".
|
||||
* At grazing angles, or looking up, the margin is 2% of the range at most
|
||||
* (never under 3 m); depth precision is far finer than that at every range
|
||||
* the overlay labels.
|
||||
*
|
||||
* @param {number} contactDistance Camera-to-contact distance in metres.
|
||||
* @param {number} [sinElevation] Sine of the camera's elevation seen from the
|
||||
* contact (1 straight overhead, 0 level, negative below); unknown when
|
||||
* omitted.
|
||||
* @returns {number} Margin in metres.
|
||||
*/
|
||||
export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) {
|
||||
const distance = Number(contactDistance);
|
||||
if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M;
|
||||
let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION;
|
||||
const rise = Number(sinElevation);
|
||||
if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise);
|
||||
return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a drawn surface hides a contact.
|
||||
* @param {number} contactDistance Camera-to-contact distance in metres.
|
||||
* @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance
|
||||
* along the contact's ray; undefined or non-finite when nothing was drawn.
|
||||
* @param {number} [sinElevation] See `surfaceOcclusionMargin`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function surfaceHidesContact(
|
||||
contactDistance,
|
||||
surfaceDistance,
|
||||
sinElevation = 0,
|
||||
) {
|
||||
if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance))
|
||||
return false;
|
||||
return (
|
||||
surfaceDistance <
|
||||
contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack a depth the renderer stored across the four bytes of an RGBA8 texel
|
||||
* (most significant byte first, each byte a base-255 digit).
|
||||
* @param {ArrayLike<number>} bytes
|
||||
* @param {number} offset Index of the texel's red byte.
|
||||
* @returns {number} Window-space depth in [0, 1].
|
||||
*/
|
||||
export function unpackDepth(bytes, offset) {
|
||||
return (
|
||||
bytes[offset] / 255 +
|
||||
bytes[offset + 1] / 65025 +
|
||||
bytes[offset + 2] / 16581375 +
|
||||
bytes[offset + 3] / 4228250625
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a window-space depth to the distance in front of the camera, measured
|
||||
* along the view direction, for one depth range ("frustum") of the frame.
|
||||
*
|
||||
* Logarithmic depth stores `log2(z - near + 1) / log2(far - near + 1)`.
|
||||
* Standard perspective depth stores `far * (z - near) / (z * (far - near))`.
|
||||
*
|
||||
* @param {number} depth Window-space depth in [0, 1].
|
||||
* @param {number} near Near plane of the range, metres.
|
||||
* @param {number} far Far plane of the range, metres.
|
||||
* @param {boolean} logDepth Whether the frame was drawn with logarithmic depth.
|
||||
* @returns {number} Metres along the view direction, or NaN where the range
|
||||
* drew nothing (depth at or outside the ends of [0, 1]).
|
||||
*/
|
||||
export function depthToViewDistance(depth, near, far, logDepth) {
|
||||
if (!(depth > 0) || !(depth < 1)) return Number.NaN;
|
||||
if (!(near > 0) || !(far > near)) return Number.NaN;
|
||||
if (logDepth)
|
||||
return near + Math.pow(2, depth * Math.log2(far - near + 1)) - 1;
|
||||
return (near * far) / (far - depth * (far - near));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a world position falls in a frame, and how near a drawn surface has to
|
||||
* be there to hide it.
|
||||
*
|
||||
* A surface hides the contact when its distance along the contact's ray is
|
||||
* under the contact's own distance less the margin. Along one ray, distance
|
||||
* and view distance (depth in front of the camera) are in a fixed ratio, so
|
||||
* the same rule reads: the surface's view distance is under
|
||||
* `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`,
|
||||
* and comparing a decoded depth with it is all the GPU has to do.
|
||||
*
|
||||
* The margin depends on how steeply the camera looks down on the contact
|
||||
* (see `surfaceOcclusionMargin`). "Up" at the contact is taken as the
|
||||
* direction from the world origin to it, which is the Earth's centre in the
|
||||
* Earth-fixed frame the scene is drawn in; it differs from the true vertical
|
||||
* by a fraction of a degree at most.
|
||||
*
|
||||
* @param {ArrayLike<number>} view World-to-camera matrix, column-major, the
|
||||
* camera looking down its own -Z axis.
|
||||
* @param {number} tanHalfFovX
|
||||
* @param {number} tanHalfFovY
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @param {{u: number, v: number, threshold: number}} out `u`, `v` in [0, 1)
|
||||
* from the bottom-left corner of the frame.
|
||||
* @returns {boolean} False when the position is behind the camera or outside
|
||||
* the frame; `out` is then untouched.
|
||||
*/
|
||||
export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) {
|
||||
const { x, y, z } = position;
|
||||
const viewDistance = -(view[2] * x + view[6] * y + view[10] * z + view[14]);
|
||||
if (!(viewDistance > 0)) return false;
|
||||
const ex = view[0] * x + view[4] * y + view[8] * z + view[12];
|
||||
const ey = view[1] * x + view[5] * y + view[9] * z + view[13];
|
||||
const u = (ex / (viewDistance * tanHalfFovX)) * 0.5 + 0.5;
|
||||
const v = (ey / (viewDistance * tanHalfFovY)) * 0.5 + 0.5;
|
||||
if (!(u >= 0 && u < 1 && v >= 0 && v < 1)) return false;
|
||||
const contactDistance = Math.sqrt(
|
||||
ex * ex + ey * ey + viewDistance * viewDistance,
|
||||
);
|
||||
// Up at the contact, turned into camera axes, against the direction from
|
||||
// the contact to the camera (-ex, -ey, viewDistance in camera axes).
|
||||
const radius = Math.sqrt(x * x + y * y + z * z);
|
||||
let sinElevation = 0;
|
||||
if (radius > 0) {
|
||||
const ux = (view[0] * x + view[4] * y + view[8] * z) / radius;
|
||||
const uy = (view[1] * x + view[5] * y + view[9] * z) / radius;
|
||||
const uz = (view[2] * x + view[6] * y + view[10] * z) / radius;
|
||||
sinElevation = (-ux * ex - uy * ey + uz * viewDistance) / contactDistance;
|
||||
}
|
||||
out.u = u;
|
||||
out.v = v;
|
||||
out.threshold =
|
||||
viewDistance *
|
||||
(1 -
|
||||
surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of depth read back to memory, with the camera it was drawn from.
|
||||
* Only the synchronous fallback uses this (see `sceneOcclusionProbe.js`); the
|
||||
* normal path never copies depth off the GPU.
|
||||
*
|
||||
* `bytes` holds `frustums.length` stacked images of `width` x `height` RGBA8
|
||||
* texels, nearest depth range first, rows bottom to top (the order the
|
||||
* graphics API reads them back in).
|
||||
*/
|
||||
export class DepthSnapshot {
|
||||
/**
|
||||
* @param {{
|
||||
* width: number, height: number, bytes: Uint8Array,
|
||||
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
|
||||
* view: ArrayLike<number>, tanHalfFovX: number, tanHalfFovY: number,
|
||||
* }} fields
|
||||
*/
|
||||
constructor(fields) {
|
||||
this.width = fields.width;
|
||||
this.height = fields.height;
|
||||
this.bytes = fields.bytes;
|
||||
this.frustums = fields.frustums;
|
||||
this.logDepth = fields.logDepth === true;
|
||||
this.view = fields.view;
|
||||
this.tanHalfFovX = fields.tanHalfFovX;
|
||||
this.tanHalfFovY = fields.tanHalfFovY;
|
||||
this._projected = { u: 0, v: 0, threshold: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance along the view direction to the nearest surface drawn at a texel.
|
||||
* @param {number} column
|
||||
* @param {number} row Counted from the bottom of the frame.
|
||||
* @returns {number} Metres, or NaN where nothing was drawn.
|
||||
*/
|
||||
surfaceViewDistance(column, row) {
|
||||
const { width, height, bytes, frustums, logDepth } = this;
|
||||
const texel = (row * width + column) * 4;
|
||||
const layer = width * height * 4;
|
||||
for (let i = 0; i < frustums.length; i++) {
|
||||
const distance = depthToViewDistance(
|
||||
unpackDepth(bytes, i * layer + texel),
|
||||
frustums[i].near,
|
||||
frustums[i].far,
|
||||
logDepth,
|
||||
);
|
||||
if (distance === distance) return distance;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the frame drew something in front of a world position.
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean|null} Null when the position is outside the frame.
|
||||
*/
|
||||
hides(position) {
|
||||
const projected = this._projected;
|
||||
if (
|
||||
!projectContact(
|
||||
this.view,
|
||||
this.tanHalfFovX,
|
||||
this.tanHalfFovY,
|
||||
position,
|
||||
projected,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
this.surfaceViewDistance(
|
||||
Math.floor(projected.u * this.width),
|
||||
Math.floor(projected.v * this.height),
|
||||
) < projected.threshold
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact is tested again at most this long, and at least half this long,
|
||||
* after its last test was started, so a view full of contacts spreads its
|
||||
* tests over about ten frames instead of repeating all of them on every one.
|
||||
* With more contacts in view than the probe's per-frame budget can serve at
|
||||
* that pace, the interval stretches (see `SurfaceOcclusion.beginFrame`).
|
||||
*/
|
||||
export const SURFACE_RETEST_MS = 200;
|
||||
|
||||
/**
|
||||
* A contact stays hidden at least this long after the last test that found it
|
||||
* hidden, and never less than three retest intervals.
|
||||
*
|
||||
* Going behind something takes effect as soon as the answer arrives. Coming
|
||||
* back takes several visible answers in a row, so a contact on a silhouette
|
||||
* edge, which tests either way from one frame to the next while the camera
|
||||
* orbits, stays off instead of blinking. It is also the clean failure: when
|
||||
* answers stop arriving (no depth to test against) every contact is drawn
|
||||
* again within this time, exactly as before this test existed.
|
||||
*/
|
||||
export const SURFACE_REAPPEAR_MS = 600;
|
||||
|
||||
/**
|
||||
* A contact that has never had an answer is held back this long, at most,
|
||||
* while a working probe gets it one.
|
||||
*
|
||||
* A contact new to the view would otherwise be drawn for the frame or two its
|
||||
* first test takes, and then vanish if it turned out to be behind something:
|
||||
* a camera orbiting a city would flash a bracket for every vehicle that
|
||||
* swings into the frame behind a building. With no working probe nothing is
|
||||
* held back, and past this time the contact is drawn whatever happens.
|
||||
*/
|
||||
export const SURFACE_FIRST_ANSWER_MS = 400;
|
||||
|
||||
/**
|
||||
* The most one frame advances the occlusion clock.
|
||||
*
|
||||
* Every interval above is measured on a clock that only runs while the
|
||||
* overlay is painting: each painted frame moves it on by the time since the
|
||||
* last one, but never by more than this. Answers only arrive while frames are
|
||||
* painted, so on a wall clock a pause (a hidden tab, a scene with nothing to
|
||||
* redraw) would run out every "hidden" at once and the first frame afterwards
|
||||
* would paint every label over the buildings again until fresh answers came.
|
||||
* On this clock a pause of any length counts as one slow frame.
|
||||
*/
|
||||
export const SURFACE_MAX_FRAME_STEP_MS = 50;
|
||||
|
||||
/** A contact not drawn for this long is forgotten. */
|
||||
export const SURFACE_FORGET_MS = 2000;
|
||||
|
||||
/**
|
||||
* What is remembered about one contact.
|
||||
* @typedef {{lastHiddenAt: number, hiddenUntil: number, appliedHiddenAt: number,
|
||||
* queuedAt: number, pending: boolean, answered: boolean, firstSeenAt: number,
|
||||
* seenAt: number}} SurfaceContact Times are on the occlusion clock.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-contact occlusion answers, and the schedule that keeps them fresh.
|
||||
*
|
||||
* `probe` is whatever tests a contact against the drawn scene. It has
|
||||
* `add(contact, position)`, which starts a test (setting `contact.pending`)
|
||||
* and returns false when it cannot take one now, and it reports back by
|
||||
* clearing `contact.pending`, setting `contact.answered` and, when the
|
||||
* contact was hidden, setting `contact.lastHiddenAt`. `probe.maxPerFrame`, if
|
||||
* set, is how many tests it takes in one frame; `probe.mode` reads `'async'`
|
||||
* while answers come a frame or two after the test.
|
||||
*/
|
||||
export class SurfaceOcclusion {
|
||||
/**
|
||||
* @param {{add: (contact: SurfaceContact, position: object) => boolean}} probe
|
||||
*/
|
||||
constructor(probe) {
|
||||
this.probe = probe;
|
||||
/** @type {Map<string, Map<*, SurfaceContact>>} Layer id -> source id. */
|
||||
this.contacts = new Map();
|
||||
/**
|
||||
* The occlusion clock, in milliseconds of painted time (see
|
||||
* `SURFACE_MAX_FRAME_STEP_MS`). The probe must date its answers by it.
|
||||
*/
|
||||
this.now = 0;
|
||||
this._wall = Number.NEGATIVE_INFINITY;
|
||||
/** Depth-tested contacts drawn or hidden this frame. */
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
this.retestMs = SURFACE_RETEST_MS;
|
||||
this.reappearMs = SURFACE_REAPPEAR_MS;
|
||||
this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS;
|
||||
this._phase = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a frame and move the occlusion clock on.
|
||||
* @param {number} wallNow Monotonic milliseconds.
|
||||
*/
|
||||
beginFrame(wallNow) {
|
||||
// Contacts arrive in the same order every frame and the probe takes only
|
||||
// so many, so if more fell due per frame than it takes, the ones at the
|
||||
// end of the order would never be tested. Pace the retests to what the
|
||||
// last frame showed the probe can keep up with: all of the contacts, once
|
||||
// per interval, with a third of the budget to spare.
|
||||
const elapsed = wallNow - this._wall;
|
||||
const frameMs =
|
||||
elapsed > 0 ? Math.min(SURFACE_MAX_FRAME_STEP_MS, elapsed) : 0;
|
||||
this._wall = wallNow;
|
||||
const budget = Number(this.probe.maxPerFrame) || Number.POSITIVE_INFINITY;
|
||||
this.retestMs = Math.max(
|
||||
SURFACE_RETEST_MS,
|
||||
(this.testedCount / budget) * frameMs * 2,
|
||||
);
|
||||
this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3);
|
||||
// At a stretched pace a crowd of new contacts takes longer to get its
|
||||
// first answers; the hold-back stretches with it.
|
||||
this.firstAnswerMs = Math.max(SURFACE_FIRST_ANSWER_MS, this.retestMs * 2);
|
||||
this.now += frameMs;
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a contact's bracket and callout should be left out of this frame.
|
||||
* Starts a fresh test of the contact when one is due.
|
||||
* @param {string} layerId
|
||||
* @param {*} sourceId
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHidden(layerId, sourceId, position) {
|
||||
const now = this.now;
|
||||
let layer = this.contacts.get(layerId);
|
||||
if (!layer) {
|
||||
layer = new Map();
|
||||
this.contacts.set(layerId, layer);
|
||||
}
|
||||
let contact = layer.get(sourceId);
|
||||
if (!contact || now - contact.seenAt > this.retestMs) {
|
||||
// New, or back in view after longer than a retest interval. A returning
|
||||
// contact's last answer is older than any contact that stayed in view
|
||||
// runs on, and the view has probably turned since: it is held back
|
||||
// like a new one until a fresh answer comes, or orbiting flashes it
|
||||
// behind the building it went behind while out of view. It gets a new
|
||||
// record, so a test still in flight from before answers the old one
|
||||
// and cannot end the hold-back with what the previous view showed. A
|
||||
// "hidden" it already had keeps running.
|
||||
contact = {
|
||||
lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||
hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY,
|
||||
appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||
queuedAt: Number.NEGATIVE_INFINITY,
|
||||
pending: false,
|
||||
answered: false,
|
||||
firstSeenAt: now,
|
||||
seenAt: now,
|
||||
};
|
||||
layer.set(sourceId, contact);
|
||||
}
|
||||
contact.seenAt = now;
|
||||
this.testedCount++;
|
||||
if (
|
||||
!contact.pending &&
|
||||
now - contact.queuedAt >= this.retestMs &&
|
||||
this.probe.add(contact, position)
|
||||
) {
|
||||
// Contacts that came into view together would otherwise all fall due on
|
||||
// the same frames for ever. Each test backdates itself by a different
|
||||
// part of half the interval, which spreads them out within a second.
|
||||
contact.queuedAt = now - this.retestMs * 0.5 * this._phase;
|
||||
this._phase = (this._phase + 0.618034) % 1;
|
||||
}
|
||||
// A new "hidden" answer sets when the contact may come back, once, with
|
||||
// the pace in force now. A later change of pace must not bring back a
|
||||
// "hidden" that has already run out.
|
||||
if (contact.lastHiddenAt !== contact.appliedHiddenAt) {
|
||||
contact.appliedHiddenAt = contact.lastHiddenAt;
|
||||
contact.hiddenUntil = contact.lastHiddenAt + this.reappearMs;
|
||||
}
|
||||
const hidden =
|
||||
now < contact.hiddenUntil ||
|
||||
// Not yet answered: held back while a working probe finds out.
|
||||
(!contact.answered &&
|
||||
this.probe.mode === 'async' &&
|
||||
now - contact.firstSeenAt < this.firstAnswerMs);
|
||||
if (hidden) this.hiddenCount++;
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/** Forget contacts that are no longer drawn. */
|
||||
prune() {
|
||||
for (const [layerId, layer] of this.contacts) {
|
||||
for (const [sourceId, contact] of layer) {
|
||||
if (this.now - contact.seenAt > SURFACE_FORGET_MS)
|
||||
layer.delete(sourceId);
|
||||
}
|
||||
if (layer.size === 0) this.contacts.delete(layerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget every contact (mode off, viewer swap). */
|
||||
clear() {
|
||||
this.contacts.clear();
|
||||
this.testedCount = 0;
|
||||
this.hiddenCount = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DepthSnapshot,
|
||||
SURFACE_FIRST_ANSWER_MS,
|
||||
SURFACE_FORGET_MS,
|
||||
SURFACE_MAX_FRAME_STEP_MS,
|
||||
SURFACE_OCCLUSION_MIN_MARGIN_M,
|
||||
SURFACE_REAPPEAR_MS,
|
||||
SURFACE_RETEST_MS,
|
||||
SurfaceOcclusion,
|
||||
depthToViewDistance,
|
||||
projectContact,
|
||||
surfaceHidesContact,
|
||||
surfaceOcclusionMargin,
|
||||
unpackDepth,
|
||||
} from './detectionOcclusion.js';
|
||||
import {
|
||||
depthImage,
|
||||
packDepth,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => {
|
||||
assert.equal(surfaceOcclusionMargin(10), SURFACE_OCCLUSION_MIN_MARGIN_M);
|
||||
assert.equal(surfaceOcclusionMargin(150), 3);
|
||||
assert.equal(surfaceOcclusionMargin(500), 10);
|
||||
assert.equal(surfaceOcclusionMargin(5000), 100);
|
||||
assert.equal(surfaceOcclusionMargin(0), 3);
|
||||
assert.equal(surfaceOcclusionMargin(Number.NaN), 3);
|
||||
});
|
||||
|
||||
test('looking down on a contact, the margin is what a 2 m rise on its line of sight takes', () => {
|
||||
// 1 km away, seen from 30 degrees up: 2 m of rise is 4 m along the ray.
|
||||
assert.ok(Math.abs(surfaceOcclusionMargin(1000, 0.5) - 4) < 1e-9);
|
||||
// Straight down: the 3 m floor.
|
||||
assert.equal(surfaceOcclusionMargin(1000, 1), SURFACE_OCCLUSION_MIN_MARGIN_M);
|
||||
// Grazing, level or from below: never more than 2% of the range.
|
||||
assert.equal(surfaceOcclusionMargin(1000, 0.01), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, 0), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, -0.3), 20);
|
||||
assert.equal(surfaceOcclusionMargin(1000, Number.NaN), 20);
|
||||
assert.equal(surfaceOcclusionMargin(100, 0.05), 3);
|
||||
// A low wall 15 m in front of a street 1 km off, seen from 25 degrees up,
|
||||
// stands about 6 m above the vehicle's line of sight: it hides it.
|
||||
const sin25 = Math.sin((25 * Math.PI) / 180);
|
||||
assert.equal(surfaceHidesContact(1000, 985, sin25), true);
|
||||
assert.equal(surfaceHidesContact(1000, 985), false, 'unknown angle: 2%');
|
||||
assert.equal(surfaceHidesContact(1000, 996, sin25), false, 'its own road');
|
||||
});
|
||||
|
||||
test('a surface hides a contact only when nearer by more than the margin', () => {
|
||||
// 500 m away: margin 10 m.
|
||||
assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front');
|
||||
assert.equal(surfaceHidesContact(500, 491), false, 'its own road or dot');
|
||||
assert.equal(surfaceHidesContact(500, 500), false);
|
||||
assert.equal(surfaceHidesContact(500, 520), false, 'the ground behind it');
|
||||
// Close range keeps the 3 m floor.
|
||||
assert.equal(surfaceHidesContact(60, 56), true);
|
||||
assert.equal(surfaceHidesContact(60, 58), false);
|
||||
});
|
||||
|
||||
test('nothing drawn, or no distance, never hides a contact', () => {
|
||||
assert.equal(surfaceHidesContact(500, undefined), false);
|
||||
assert.equal(surfaceHidesContact(500, Number.NaN), false);
|
||||
assert.equal(surfaceHidesContact(Number.NaN, 10), false);
|
||||
});
|
||||
|
||||
test('packed depth unpacks most significant byte first', () => {
|
||||
assert.equal(unpackDepth([0, 0, 0, 0], 0), 0);
|
||||
assert.equal(unpackDepth([255, 0, 0, 0], 0), 1);
|
||||
assert.equal(unpackDepth([9, 9, 51, 0, 0, 0], 2), 0.2);
|
||||
for (const depth of [0.000123, 0.25, 0.5, 0.987654321]) {
|
||||
const round = unpackDepth(packDepth(depth), 0);
|
||||
assert.ok(Math.abs(round - depth) < 1e-9, `${depth} -> ${round}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('logarithmic depth decodes to metres in front of the camera', () => {
|
||||
const near = 0.1;
|
||||
const far = 10_000_000;
|
||||
// depth = log2(z - near + 1) / log2(far - near + 1)
|
||||
for (const z of [0.5, 12, 480, 25_000, 3_000_000]) {
|
||||
const depth = Math.log2(z - near + 1) / Math.log2(far - near + 1);
|
||||
const decoded = depthToViewDistance(depth, near, far, true);
|
||||
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('standard perspective depth decodes to metres in front of the camera', () => {
|
||||
const near = 1;
|
||||
const far = 5000;
|
||||
// depth = far * (z - near) / (z * (far - near))
|
||||
for (const z of [1.5, 40, 900, 4999]) {
|
||||
const depth = (far * (z - near)) / (z * (far - near));
|
||||
const decoded = depthToViewDistance(depth, near, far, false);
|
||||
assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(depthToViewDistance(0.5, 1, 5000, false) - 10000 / 5001) < 1e-9,
|
||||
'half depth sits just past twice the near plane',
|
||||
);
|
||||
});
|
||||
|
||||
test('the ends of the depth range, and a bad range, mean nothing was drawn', () => {
|
||||
for (const log of [true, false]) {
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(1, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(Number.NaN, 1, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0.5, 0, 100, log)));
|
||||
assert.ok(Number.isNaN(depthToViewDistance(0.5, 100, 100, log)));
|
||||
}
|
||||
});
|
||||
|
||||
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
/** A point `distance` metres ahead, `side` of the way to the frame edge. */
|
||||
const ahead = (distance, side = 0, up = 0) => ({
|
||||
x: side * distance,
|
||||
y: up * distance,
|
||||
z: -distance,
|
||||
});
|
||||
|
||||
test('a contact projects to its place in the frame, from the bottom-left corner', () => {
|
||||
const out = { u: -1, v: -1, threshold: -1 };
|
||||
// 90 degree field of view: the frame edge is as far out as the point is ahead.
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500), out), true);
|
||||
assert.deepEqual([out.u, out.v], [0.5, 0.5]);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, ahead(500, -0.5, 0.5), out),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual([out.u, out.v], [0.25, 0.75]);
|
||||
// A wider horizontal field of view pulls the same point towards the middle.
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 2, 1, ahead(500, -0.5, 0.5), out),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual([out.u, out.v], [0.375, 0.75]);
|
||||
});
|
||||
|
||||
test('the threshold is the margin rule restated as a view distance', () => {
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
// Straight ahead, distance and view distance are the same thing.
|
||||
projectContact(IDENTITY, 1, 1, ahead(500), out);
|
||||
assert.ok(Math.abs(out.threshold - 490) < 1e-9, '500 m less 2%');
|
||||
projectContact(IDENTITY, 1, 1, ahead(60), out);
|
||||
assert.ok(Math.abs(out.threshold - 57) < 1e-9, '60 m less the 3 m floor');
|
||||
// Off axis the ray is longer than the view distance; the rule holds along
|
||||
// the ray, so a surface at view distance d sits d * range / viewDistance out.
|
||||
const corner = ahead(400, -0.9, 0.9);
|
||||
const range = Math.hypot(corner.x, corner.y, corner.z);
|
||||
projectContact(IDENTITY, 1, 1, corner, out);
|
||||
for (const surfaceView of [out.threshold - 0.01, out.threshold + 0.01]) {
|
||||
const alongRay = (surfaceView * range) / 400;
|
||||
assert.equal(
|
||||
surfaceHidesContact(range, alongRay),
|
||||
surfaceView < out.threshold,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9);
|
||||
});
|
||||
|
||||
test('the threshold uses how steeply the camera looks down on the contact', () => {
|
||||
// A street on a sphere the size of the Earth, under a camera 1 km up and
|
||||
// 1 km back along the surface, looking straight at it: 45 degrees down.
|
||||
const R = 6_371_000;
|
||||
const street = { x: 0, y: 0, z: R };
|
||||
const camera = { x: 0, y: -1000, z: R + 1000 };
|
||||
// Camera axes: x along world x, looking along (0, 1, -1) / sqrt 2.
|
||||
const s = Math.SQRT1_2;
|
||||
const forward = [0, s, -s];
|
||||
const up = [0, s, s];
|
||||
const right = [1, 0, 0];
|
||||
const t = (axis) =>
|
||||
-(axis[0] * camera.x + axis[1] * camera.y + axis[2] * camera.z);
|
||||
// Rows: right, up, -forward (the camera looks down its -Z).
|
||||
const back = forward.map((c) => -c);
|
||||
const view = [
|
||||
right[0],
|
||||
up[0],
|
||||
back[0],
|
||||
0,
|
||||
right[1],
|
||||
up[1],
|
||||
back[1],
|
||||
0,
|
||||
right[2],
|
||||
up[2],
|
||||
back[2],
|
||||
0,
|
||||
t(right),
|
||||
t(up),
|
||||
t(back),
|
||||
1,
|
||||
];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
assert.equal(projectContact(view, 1, 1, street, out), true);
|
||||
assert.ok(Math.abs(out.u - 0.5) < 1e-9 && Math.abs(out.v - 0.5) < 1e-9);
|
||||
const range = Math.SQRT2 * 1000;
|
||||
// 2 m of rise at 45 degrees is 2.83 m along the ray: the 3 m floor, not
|
||||
// 2% of 1414 m (28 m).
|
||||
assert.ok(Math.abs(out.threshold - (range - 3)) < 1e-6, `${out.threshold}`);
|
||||
});
|
||||
|
||||
test('off the middle of the frame, with the camera turned, the threshold matches the world-space rule', () => {
|
||||
// Rotations about the three axes, then a camera placed and turned freely;
|
||||
// the threshold must equal the margin rule worked out in world space.
|
||||
const rot = (ax, ay, az) => {
|
||||
const [cx, sx, cy, sy, cz, sz] = [
|
||||
Math.cos(ax),
|
||||
Math.sin(ax),
|
||||
Math.cos(ay),
|
||||
Math.sin(ay),
|
||||
Math.cos(az),
|
||||
Math.sin(az),
|
||||
];
|
||||
// Rows of Rz * Ry * Rx.
|
||||
return [
|
||||
[cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
|
||||
[sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
|
||||
[-sy, cy * sx, cy * cx],
|
||||
];
|
||||
};
|
||||
const R = 6_371_000;
|
||||
let checked = 0;
|
||||
for (let k = 0; k < 200; k++) {
|
||||
// A deterministic spread of poses.
|
||||
const a = (n) => Math.sin(k * 12.9898 + n * 78.233) * 3;
|
||||
const m = rot(a(1), a(2), a(3)); // world-to-camera rotation
|
||||
const lat = a(4) / 3;
|
||||
const lon = a(5);
|
||||
const contact = {
|
||||
x: R * Math.cos(lat) * Math.cos(lon),
|
||||
y: R * Math.cos(lat) * Math.sin(lon),
|
||||
z: R * Math.sin(lat),
|
||||
};
|
||||
// Camera 300 to 2300 m from the contact, in a direction above or below it.
|
||||
const d = [a(6), a(7), a(8)];
|
||||
const dl = Math.hypot(...d) || 1;
|
||||
const dist = 1300 + 1000 * Math.sin(k);
|
||||
const cam = {
|
||||
x: contact.x + (d[0] / dl) * dist,
|
||||
y: contact.y + (d[1] / dl) * dist,
|
||||
z: contact.z + (d[2] / dl) * dist,
|
||||
};
|
||||
const t = m.map(
|
||||
(row) => -(row[0] * cam.x + row[1] * cam.y + row[2] * cam.z),
|
||||
);
|
||||
const view = [
|
||||
m[0][0],
|
||||
m[1][0],
|
||||
m[2][0],
|
||||
0,
|
||||
m[0][1],
|
||||
m[1][1],
|
||||
m[2][1],
|
||||
0,
|
||||
m[0][2],
|
||||
m[1][2],
|
||||
m[2][2],
|
||||
0,
|
||||
t[0],
|
||||
t[1],
|
||||
t[2],
|
||||
1,
|
||||
];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
if (!projectContact(view, 2, 2, contact, out)) continue;
|
||||
checked++;
|
||||
const toCam = [cam.x - contact.x, cam.y - contact.y, cam.z - contact.z];
|
||||
const range = Math.hypot(...toCam);
|
||||
const sinElevation =
|
||||
(toCam[0] * contact.x + toCam[1] * contact.y + toCam[2] * contact.z) /
|
||||
(range * R);
|
||||
const viewDistance = -(
|
||||
m[2][0] * contact.x +
|
||||
m[2][1] * contact.y +
|
||||
m[2][2] * contact.z +
|
||||
t[2]
|
||||
);
|
||||
const expected =
|
||||
viewDistance * (1 - surfaceOcclusionMargin(range, sinElevation) / range);
|
||||
assert.ok(
|
||||
Math.abs(out.threshold - expected) < 1e-6,
|
||||
`pose ${k}: ${out.threshold} vs ${expected}`,
|
||||
);
|
||||
}
|
||||
assert.ok(checked > 20, `${checked} poses in frame`);
|
||||
});
|
||||
|
||||
test('behind the camera or outside the frame there is nothing to test', () => {
|
||||
const out = { u: 7, v: 7, threshold: 7 };
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 500 }, out),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 0 }, out),
|
||||
false,
|
||||
);
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, -1.2), out), false);
|
||||
assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, 0, 1.01), out), false);
|
||||
assert.equal(
|
||||
projectContact(IDENTITY, 1, 1, ahead(500, 1), out),
|
||||
false,
|
||||
'u = 1',
|
||||
);
|
||||
assert.deepEqual(out, { u: 7, v: 7, threshold: 7 }, 'left untouched');
|
||||
});
|
||||
|
||||
test('a contact is projected with the camera it is given', () => {
|
||||
// Camera 1000 m up the +Z axis looking down -Z: view = translate(0, 0, -1000).
|
||||
const view = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -1000, 1];
|
||||
const out = { u: 0, v: 0, threshold: 0 };
|
||||
assert.equal(
|
||||
projectContact(view, 1, 1, { x: -250, y: 0, z: 500 }, out),
|
||||
true,
|
||||
);
|
||||
assert.equal(out.u, 0.25);
|
||||
assert.equal(projectContact(view, 1, 1, { x: 0, y: 0, z: 1500 }, out), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A snapshot from a camera at the origin looking down -Z with a 90 degree
|
||||
* field of view. By default one depth range in which the left half of the
|
||||
* frame is a wall `wall` metres away and the right half is ground `ground`
|
||||
* metres away.
|
||||
*/
|
||||
function snapshotWithWall({
|
||||
wall = 400,
|
||||
ground = 500,
|
||||
logDepth = true,
|
||||
size = 8,
|
||||
frustums,
|
||||
images,
|
||||
} = {}) {
|
||||
const ranges = frustums || [{ near: 1, far: 100_000 }];
|
||||
const layers = images || [
|
||||
depthImage(size, size, (column) =>
|
||||
viewDistanceToDepth(
|
||||
column < size / 2 ? wall : ground,
|
||||
ranges[0].near,
|
||||
ranges[0].far,
|
||||
logDepth,
|
||||
),
|
||||
),
|
||||
];
|
||||
const bytes = new Uint8Array(size * size * 4 * layers.length);
|
||||
layers.forEach((image, i) => bytes.set(image.data, i * size * size * 4));
|
||||
return new DepthSnapshot({
|
||||
width: size,
|
||||
height: size,
|
||||
bytes,
|
||||
frustums: ranges,
|
||||
logDepth,
|
||||
view: IDENTITY,
|
||||
tanHalfFovX: 1,
|
||||
tanHalfFovY: 1,
|
||||
});
|
||||
}
|
||||
|
||||
test('a contact behind a drawn wall is hidden; one on drawn ground, or in front of the wall, is not', () => {
|
||||
for (const logDepth of [true, false]) {
|
||||
const snapshot = snapshotWithWall({ logDepth });
|
||||
assert.equal(snapshot.hides(ahead(500, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(500, 0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(300, -0.5)), false);
|
||||
// 2% of 405 m is 8.1 m: 5 m behind the wall is still the wall's own face.
|
||||
assert.equal(snapshot.hides(ahead(405, -0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(412, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(500, -1.5)), null, 'outside the frame');
|
||||
}
|
||||
});
|
||||
|
||||
test('rows are read bottom to top', () => {
|
||||
const size = 8;
|
||||
const image = depthImage(size, size, (_column, row) =>
|
||||
viewDistanceToDepth(row < size / 2 ? 400 : 500, 1, 100_000, true),
|
||||
);
|
||||
const snapshot = snapshotWithWall({ images: [image] });
|
||||
assert.equal(snapshot.hides(ahead(500, 0, -0.5)), true, 'below centre');
|
||||
assert.equal(snapshot.hides(ahead(500, 0, 0.5)), false, 'above centre');
|
||||
});
|
||||
|
||||
test('the nearest depth range that drew something answers', () => {
|
||||
const size = 4;
|
||||
const frustums = [
|
||||
{ near: 1, far: 1000 },
|
||||
{ near: 999, far: 1_000_000 },
|
||||
];
|
||||
// Near range: a wall 400 m away on the left, nothing on the right.
|
||||
const nearImage = depthImage(size, size, (column) =>
|
||||
column < 2 ? viewDistanceToDepth(400, 1, 1000, true) : 1,
|
||||
);
|
||||
// Far range: ground 20 km away everywhere.
|
||||
const farImage = depthImage(size, size, () =>
|
||||
viewDistanceToDepth(20_000, 999, 1_000_000, true),
|
||||
);
|
||||
const snapshot = snapshotWithWall({
|
||||
size,
|
||||
frustums,
|
||||
images: [nearImage, farImage],
|
||||
});
|
||||
assert.ok(Math.abs(snapshot.surfaceViewDistance(0, 0) - 400) < 0.01);
|
||||
assert.ok(Math.abs(snapshot.surfaceViewDistance(3, 0) - 20_000) < 1);
|
||||
assert.equal(snapshot.hides(ahead(15_000, -0.5)), true);
|
||||
assert.equal(snapshot.hides(ahead(15_000, 0.5)), false);
|
||||
assert.equal(snapshot.hides(ahead(30_000, 0.5)), true);
|
||||
});
|
||||
|
||||
test('where nothing was drawn a contact is visible', () => {
|
||||
const size = 4;
|
||||
const snapshot = snapshotWithWall({
|
||||
size,
|
||||
images: [depthImage(size, size, () => 1)],
|
||||
});
|
||||
assert.ok(Number.isNaN(snapshot.surfaceViewDistance(1, 1)));
|
||||
assert.equal(snapshot.hides(ahead(500)), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A probe that answers when told to. `hidden` decides each test's answer at
|
||||
* the time it is resolved.
|
||||
*/
|
||||
function manualProbe() {
|
||||
const inFlight = [];
|
||||
return {
|
||||
accepting: true,
|
||||
started: 0,
|
||||
inFlight,
|
||||
add(contact) {
|
||||
if (!this.accepting) return false;
|
||||
this.started++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
/** Deliver every answer in flight. */
|
||||
answer(hidden, now) {
|
||||
for (const contact of inFlight.splice(0)) {
|
||||
contact.pending = false;
|
||||
contact.answered = true;
|
||||
if (hidden) contact.lastHiddenAt = now;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const anywhere = { x: 0, y: 0, z: -500 };
|
||||
|
||||
test('a contact is drawn until an answer says it is hidden, then hidden at once', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(1000);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
assert.equal(probe.started, 1, 'a new contact is tested straight away');
|
||||
occlusion.beginFrame(1016);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
assert.equal(probe.started, 1, 'one test in flight per contact');
|
||||
probe.answer(true, occlusion.now);
|
||||
occlusion.beginFrame(1032);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
assert.equal(occlusion.testedCount, 1);
|
||||
assert.equal(occlusion.hiddenCount, 1);
|
||||
// The same source id on another layer is another contact.
|
||||
assert.equal(occlusion.isHidden('transit', 'a', anywhere), false);
|
||||
});
|
||||
|
||||
test('with a working probe a new contact is held back until its first answer, and no longer than the limit', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
// New to the view: tested at once, and not drawn while the answer is out.
|
||||
assert.equal(occlusion.isHidden('traffic', 'street', anywhere), true);
|
||||
assert.equal(occlusion.isHidden('traffic', 'roofed', anywhere), true);
|
||||
assert.equal(occlusion.isHidden('traffic', 'unanswered', anywhere), true);
|
||||
assert.equal(probe.started, 3);
|
||||
const [street, roofed] = probe.inFlight.splice(0, 2);
|
||||
street.pending = false;
|
||||
street.answered = true;
|
||||
roofed.pending = false;
|
||||
roofed.answered = true;
|
||||
roofed.lastHiddenAt = occlusion.now;
|
||||
occlusion.beginFrame(16);
|
||||
assert.equal(
|
||||
occlusion.isHidden('traffic', 'street', anywhere),
|
||||
false,
|
||||
'drawn from its first answer',
|
||||
);
|
||||
assert.equal(
|
||||
occlusion.isHidden('traffic', 'roofed', anywhere),
|
||||
true,
|
||||
'never drawn at all',
|
||||
);
|
||||
// The third answer never comes. It is drawn once the limit has passed.
|
||||
const firstSeen = occlusion.now - 16;
|
||||
const drawn = [];
|
||||
for (let wall = 32; wall < 1000; wall += 16) {
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push([
|
||||
occlusion.now - firstSeen,
|
||||
!occlusion.isHidden('traffic', 'unanswered', anywhere),
|
||||
]);
|
||||
}
|
||||
for (const [age, shown] of drawn)
|
||||
assert.equal(shown, age >= SURFACE_FIRST_ANSWER_MS, `at ${age} ms`);
|
||||
|
||||
// No working probe: a new contact is drawn at once, as before.
|
||||
for (const mode of ['off', 'sync', undefined]) {
|
||||
const idle = manualProbe();
|
||||
idle.mode = mode;
|
||||
const plain = new SurfaceOcclusion(idle);
|
||||
plain.beginFrame(0);
|
||||
assert.equal(plain.isHidden('traffic', 'a', anywhere), false, String(mode));
|
||||
}
|
||||
});
|
||||
|
||||
test('a contact is retested at most every retest interval and at least every half of it', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const starts = [];
|
||||
let before = 0;
|
||||
for (let now = 0; now <= 2000; now += 4) {
|
||||
probe.answer(false, now);
|
||||
occlusion.beginFrame(now);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
if (probe.started !== before) {
|
||||
starts.push(now);
|
||||
before = probe.started;
|
||||
}
|
||||
}
|
||||
const gaps = starts.slice(1).map((at, i) => at - starts[i]);
|
||||
assert.ok(gaps.length >= 9);
|
||||
assert.ok(
|
||||
gaps.every((gap) => gap <= SURFACE_RETEST_MS),
|
||||
`${gaps}`,
|
||||
);
|
||||
assert.ok(
|
||||
gaps.every((gap) => gap >= SURFACE_RETEST_MS / 2),
|
||||
`${gaps}`,
|
||||
);
|
||||
assert.ok(new Set(gaps).size > 3, 'not all on the same beat');
|
||||
});
|
||||
|
||||
test('contacts that appear together drift apart instead of all testing on the same frames', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const perFrame = [];
|
||||
for (let frame = 0; frame < 240; frame++) {
|
||||
const now = frame * 16;
|
||||
probe.answer(false, now);
|
||||
occlusion.beginFrame(now);
|
||||
const before = probe.started;
|
||||
for (let id = 0; id < 600; id++)
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
perFrame.push(probe.started - before);
|
||||
}
|
||||
assert.equal(perFrame[0], 600, 'every new contact is tested at once');
|
||||
const settled = perFrame.slice(120);
|
||||
assert.ok(Math.max(...settled) < 200, `peak ${Math.max(...settled)}`);
|
||||
assert.ok(Math.min(...settled) > 0);
|
||||
});
|
||||
|
||||
test('with more contacts than the probe takes per frame, the retests slow down and none is starved', () => {
|
||||
// A probe that takes 256 tests a frame, 2,400 contacts in view, 30 fps.
|
||||
const inFlight = [];
|
||||
let taken = 0;
|
||||
const probe = {
|
||||
maxPerFrame: 256,
|
||||
add(contact) {
|
||||
if (taken >= 256) return false;
|
||||
taken++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const tests = new Array(2400).fill(0);
|
||||
const lastStart = new Array(2400).fill(0);
|
||||
let longestGap = 0;
|
||||
for (let frame = 0; frame < 300; frame++) {
|
||||
const now = frame * 33;
|
||||
for (const contact of inFlight.splice(0)) contact.pending = false;
|
||||
taken = 0;
|
||||
occlusion.beginFrame(now);
|
||||
for (let id = 0; id < 2400; id++) {
|
||||
const before = taken;
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
if (taken !== before) {
|
||||
tests[id]++;
|
||||
if (frame > 60) longestGap = Math.max(longestGap, now - lastStart[id]);
|
||||
lastStart[id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
Math.min(...tests) >= 8,
|
||||
`fewest tests for one contact: ${Math.min(...tests)}`,
|
||||
);
|
||||
assert.ok(occlusion.retestMs > SURFACE_RETEST_MS);
|
||||
assert.ok(longestGap <= occlusion.retestMs + 33, `longest gap ${longestGap}`);
|
||||
assert.ok(occlusion.reappearMs >= 3 * occlusion.retestMs);
|
||||
// A view the budget covers easily keeps the normal pace.
|
||||
const few = new SurfaceOcclusion(probe);
|
||||
for (let frame = 0; frame < 10; frame++) {
|
||||
taken = 0;
|
||||
few.beginFrame(frame * 16);
|
||||
for (let id = 0; id < 300; id++) few.isHidden('traffic', id, anywhere);
|
||||
}
|
||||
assert.equal(few.retestMs, SURFACE_RETEST_MS);
|
||||
assert.equal(few.reappearMs, SURFACE_REAPPEAR_MS);
|
||||
});
|
||||
|
||||
test('a hidden contact comes back only after the reappear time without a hidden answer', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
const hiddenAt = occlusion.now;
|
||||
probe.answer(true, hiddenAt);
|
||||
// Visible answers from now on; the contact still waits out the time.
|
||||
let reappeared = null;
|
||||
for (let wall = 16; wall < 2000 && reappeared === null; wall += 16) {
|
||||
probe.answer(false, occlusion.now);
|
||||
occlusion.beginFrame(wall);
|
||||
if (!occlusion.isHidden('traffic', 'a', anywhere))
|
||||
reappeared = occlusion.now;
|
||||
}
|
||||
assert.ok(reappeared - hiddenAt >= SURFACE_REAPPEAR_MS);
|
||||
assert.ok(reappeared - hiddenAt < SURFACE_REAPPEAR_MS + 17);
|
||||
});
|
||||
|
||||
test('a contact on an edge, answering either way in turn, stays hidden', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
const drawn = [];
|
||||
let answers = 0;
|
||||
for (let wall = 0; wall < 4000; wall += 16) {
|
||||
if (probe.inFlight.length > 0)
|
||||
probe.answer(answers++ % 2 === 0, occlusion.now);
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push(!occlusion.isHidden('traffic', 'a', anywhere));
|
||||
}
|
||||
// Drawn only until the first answer arrives.
|
||||
assert.equal(drawn.lastIndexOf(true), 0);
|
||||
});
|
||||
|
||||
test('when the probe stops answering, hidden contacts are drawn again and stay drawn', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
const hiddenAt = occlusion.now;
|
||||
probe.answer(true, hiddenAt);
|
||||
// No depth any more: the probe takes no tests and answers nothing.
|
||||
probe.accepting = false;
|
||||
const drawn = [];
|
||||
for (let wall = 16; wall < 3000; wall += 16) {
|
||||
occlusion.beginFrame(wall);
|
||||
drawn.push([
|
||||
occlusion.now - hiddenAt,
|
||||
!occlusion.isHidden('traffic', 'a', anywhere),
|
||||
]);
|
||||
}
|
||||
for (const [age, shown] of drawn)
|
||||
assert.equal(shown, age >= SURFACE_REAPPEAR_MS, `at ${age} ms`);
|
||||
// A probe that never accepted anything hides nothing.
|
||||
const dead = new SurfaceOcclusion({ add: () => false });
|
||||
dead.beginFrame(0);
|
||||
assert.equal(dead.isHidden('traffic', 'a', anywhere), false);
|
||||
});
|
||||
|
||||
test('a change of pace does not bring back a hidden that has run out', () => {
|
||||
// 2,400 contacts with a 256-a-frame probe: the hide time is longer at 33 ms
|
||||
// frames than at 16 ms ones.
|
||||
const inFlight = [];
|
||||
let taken = 0;
|
||||
const probe = {
|
||||
maxPerFrame: 256,
|
||||
add(contact) {
|
||||
if (taken >= 256) return false;
|
||||
taken++;
|
||||
contact.pending = true;
|
||||
inFlight.push(contact);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
const paint = (step) => {
|
||||
wall += step;
|
||||
for (const contact of inFlight.splice(0)) contact.pending = false;
|
||||
taken = 0;
|
||||
occlusion.beginFrame(wall);
|
||||
let watched = false;
|
||||
for (let id = 0; id < 2400; id++) {
|
||||
const hidden = occlusion.isHidden('traffic', id, anywhere);
|
||||
if (id === 0) watched = hidden;
|
||||
}
|
||||
return watched;
|
||||
};
|
||||
for (let i = 0; i < 30; i++) paint(16);
|
||||
// One hidden answer for contact 0, then visible answers only.
|
||||
occlusion.contacts.get('traffic').get(0).lastHiddenAt = occlusion.now;
|
||||
const states = [];
|
||||
for (let i = 0; i < 80; i++) states.push(paint(16));
|
||||
assert.equal(states[0], true);
|
||||
assert.equal(states.at(-1), false, 'ran out at the fast pace');
|
||||
// The frames slow down, which lengthens the hide time for new answers.
|
||||
const slow = [];
|
||||
for (let i = 0; i < 40; i++) slow.push(paint(33));
|
||||
assert.ok(occlusion.reappearMs > SURFACE_REAPPEAR_MS);
|
||||
assert.ok(
|
||||
slow.every((hidden) => hidden === false),
|
||||
'and stays out',
|
||||
);
|
||||
});
|
||||
|
||||
test('a pause in painting does not run the hidden time out', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
probe.answer(true, occlusion.now);
|
||||
occlusion.beginFrame(16);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
// Nothing is painted for a minute (a hidden tab, an idle scene). On the
|
||||
// first frame back the contact is still hidden: no answer could have come.
|
||||
const before = occlusion.now;
|
||||
occlusion.beginFrame(60_016);
|
||||
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
// A clock that jumps backwards moves nothing.
|
||||
occlusion.beginFrame(100);
|
||||
assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS);
|
||||
});
|
||||
|
||||
test('prune forgets contacts that are no longer drawn; clear forgets everything', () => {
|
||||
const probe = manualProbe();
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
/** Paint frames for `ms` of painted time, drawing the given contacts. */
|
||||
const paint = (ms, ids) => {
|
||||
for (const end = occlusion.now + ms; occlusion.now < end;) {
|
||||
wall += 50;
|
||||
occlusion.beginFrame(wall);
|
||||
for (const id of ids) occlusion.isHidden('traffic', id, anywhere);
|
||||
}
|
||||
};
|
||||
paint(100, ['gone', 'here']);
|
||||
paint(SURFACE_FORGET_MS + 50, ['here']);
|
||||
occlusion.prune();
|
||||
assert.deepEqual([...occlusion.contacts.get('traffic').keys()], ['here']);
|
||||
paint(SURFACE_FORGET_MS + 50, []);
|
||||
occlusion.prune();
|
||||
assert.equal(occlusion.contacts.size, 0);
|
||||
|
||||
occlusion.isHidden('traffic', 'again', anywhere);
|
||||
occlusion.clear();
|
||||
assert.equal(occlusion.contacts.size, 0);
|
||||
assert.equal(occlusion.testedCount, 0);
|
||||
});
|
||||
|
||||
test('a contact back in view after a while waits for a fresh answer, like a new one', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
let wall = 0;
|
||||
const step = () => occlusion.beginFrame((wall += 16));
|
||||
step();
|
||||
occlusion.isHidden('traffic', 'a', anywhere);
|
||||
probe.answer(false, occlusion.now);
|
||||
step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
// Out of view for a moment shorter than a retest: drawn straight away.
|
||||
for (let i = 0; i < 5; i++) step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false);
|
||||
// Out of view for longer (the camera swung away and back): the old
|
||||
// "visible" may now be behind a building. Held back, and tested at once.
|
||||
const started = probe.started;
|
||||
for (let i = 0; i < 30; i++) step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
assert.equal(probe.started, started + 1);
|
||||
probe.answer(true, occlusion.now);
|
||||
step();
|
||||
assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true);
|
||||
|
||||
// It leaves with a test in flight, and comes back. That test's answer was
|
||||
// about the previous view: when it lands, it must not end the hold-back.
|
||||
const other = new SurfaceOcclusion(probe);
|
||||
probe.answer(false, 0);
|
||||
other.beginFrame((wall += 16));
|
||||
other.isHidden('traffic', 'b', anywhere);
|
||||
assert.equal(probe.inFlight.length, 1, 'tested, unanswered');
|
||||
for (let i = 0; i < 30; i++) other.beginFrame((wall += 16));
|
||||
const before = probe.started;
|
||||
assert.equal(other.isHidden('traffic', 'b', anywhere), true);
|
||||
assert.equal(probe.started, before + 1, 'tested afresh at once');
|
||||
const [stale] = probe.inFlight.splice(0, 1);
|
||||
stale.pending = false;
|
||||
stale.answered = true;
|
||||
other.beginFrame((wall += 16));
|
||||
assert.equal(
|
||||
other.isHidden('traffic', 'b', anywhere),
|
||||
true,
|
||||
'the old answer did not end the hold-back',
|
||||
);
|
||||
probe.answer(false, other.now);
|
||||
other.beginFrame((wall += 16));
|
||||
assert.equal(other.isHidden('traffic', 'b', anywhere), false);
|
||||
});
|
||||
|
||||
test('at a stretched retest pace, new contacts are held back longer for their first answer', () => {
|
||||
const probe = manualProbe();
|
||||
probe.mode = 'async';
|
||||
probe.maxPerFrame = 100;
|
||||
const occlusion = new SurfaceOcclusion(probe);
|
||||
occlusion.beginFrame(0);
|
||||
for (let id = 0; id < 2000; id++) occlusion.isHidden('traffic', id, anywhere);
|
||||
occlusion.beginFrame(33);
|
||||
// 2000 contacts at 100 a frame and 33 ms frames: a retest every 1.3 s.
|
||||
assert.ok(occlusion.retestMs > 1000, `${occlusion.retestMs}`);
|
||||
assert.equal(occlusion.firstAnswerMs, occlusion.retestMs * 2);
|
||||
assert.ok(occlusion.firstAnswerMs > SURFACE_FIRST_ANSWER_MS);
|
||||
// A contact the probe has not reached yet is still held back past the
|
||||
// usual limit, and drawn once the stretched one has run out.
|
||||
probe.accepting = false;
|
||||
let wall = 33;
|
||||
const ages = [];
|
||||
const firstSeen = occlusion.now;
|
||||
occlusion.isHidden('traffic', 'late', anywhere);
|
||||
while (occlusion.now - firstSeen < occlusion.firstAnswerMs + 100) {
|
||||
occlusion.beginFrame((wall += 33));
|
||||
for (let id = 0; id < 2000; id++)
|
||||
occlusion.isHidden('traffic', id, anywhere);
|
||||
ages.push([
|
||||
occlusion.now - firstSeen,
|
||||
occlusion.isHidden('traffic', 'late', anywhere),
|
||||
]);
|
||||
}
|
||||
const limit = occlusion.firstAnswerMs;
|
||||
assert.ok(
|
||||
ages.some(([age, hidden]) => age > SURFACE_FIRST_ANSWER_MS + 50 && hidden),
|
||||
'held past 400 ms',
|
||||
);
|
||||
for (const [age, hidden] of ages)
|
||||
if (age >= limit) assert.equal(hidden, false, `at ${age} ms`);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
|
||||
const SIBLING_PICKS = [
|
||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
||||
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||
['satellites', '25544', { id: 25544 }],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* A stand-in scene and WebGL context for tests of the occlusion probe: just
|
||||
* the members `sceneOcclusionProbe.js` uses, with depth images the test
|
||||
* controls.
|
||||
*
|
||||
* The fake context cannot run GLSL. When the probe draws a point inside a
|
||||
* query, the fake decides whether the point draws by applying the same rule
|
||||
* to the same inputs the vertex shader gets (the uploaded attributes, the
|
||||
* bound depth textures and the uniforms), using the CPU decode from
|
||||
* `detectionOcclusion.js`. That checks everything around the shader; the
|
||||
* shader itself is checked against `scene.pickPosition` in a real browser.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { depthToViewDistance, unpackDepth } from '../detectionOcclusion.js';
|
||||
|
||||
/** Inverse of `unpackDepth`: four base-255 digits, most significant first. */
|
||||
export function packDepth(depth) {
|
||||
const bytes = [];
|
||||
let rest = Math.min(Math.max(depth, 0), 1);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
rest *= 255;
|
||||
const digit = Math.min(255, Math.floor(rest));
|
||||
bytes.push(digit);
|
||||
rest -= digit;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Inverse of `depthToViewDistance`. */
|
||||
export function viewDistanceToDepth(distance, near, far, logDepth) {
|
||||
if (logDepth)
|
||||
return Math.log2(distance - near + 1) / Math.log2(far - near + 1);
|
||||
return (far * (distance - near)) / (distance * (far - near));
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGBA8 depth image filled from a function of the texel.
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {(column: number, row: number) => number} depthAt Window depth; rows
|
||||
* count from the bottom.
|
||||
*/
|
||||
export function depthImage(width, height, depthAt) {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
for (let row = 0; row < height; row++)
|
||||
for (let column = 0; column < width; column++)
|
||||
data.set(packDepth(depthAt(column, row)), (row * width + column) * 4);
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
const GL = {
|
||||
FRAMEBUFFER: 0x8d40,
|
||||
READ_FRAMEBUFFER: 0x8ca8,
|
||||
DRAW_FRAMEBUFFER: 0x8ca9,
|
||||
FRAMEBUFFER_BINDING: 0x8ca6,
|
||||
DRAW_FRAMEBUFFER_BINDING: 0x8ca6,
|
||||
READ_FRAMEBUFFER_BINDING: 0x8caa,
|
||||
TEXTURE_BINDING_2D: 0x8069,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE0: 0x84c0,
|
||||
ACTIVE_TEXTURE: 0x84e0,
|
||||
COLOR_ATTACHMENT0: 0x8ce0,
|
||||
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
ARRAY_BUFFER_BINDING: 0x8894,
|
||||
VERTEX_ARRAY_BINDING: 0x85b5,
|
||||
CURRENT_PROGRAM: 0x8b8d,
|
||||
STREAM_DRAW: 0x88e0,
|
||||
SCISSOR_TEST: 0x0c11,
|
||||
VIEWPORT: 0x0ba2,
|
||||
POINTS: 0x0000,
|
||||
FLOAT: 0x1406,
|
||||
RGBA: 0x1908,
|
||||
RGBA8: 0x8058,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ANY_SAMPLES_PASSED_CONSERVATIVE: 0x8d6a,
|
||||
QUERY_RESULT: 0x8866,
|
||||
QUERY_RESULT_AVAILABLE: 0x8867,
|
||||
};
|
||||
|
||||
/**
|
||||
* A recording WebGL context. `webgl2: false` leaves out queries, vertex
|
||||
* arrays and immutable textures, as a WebGL 1 context does. Query results
|
||||
* stay unavailable until `finishGpu()`.
|
||||
*/
|
||||
export function createFakeGl({ webgl2 = true } = {}) {
|
||||
const state = {
|
||||
read: null,
|
||||
draw: null,
|
||||
units: [null, null, null, null, null, null, null, null],
|
||||
unit: 0,
|
||||
array: null,
|
||||
vertexArray: null,
|
||||
program: null,
|
||||
scissor: false,
|
||||
viewport: [0, 0, 1920, 1080],
|
||||
lost: false,
|
||||
/** Whether a point that should draw does (false: a broken draw path). */
|
||||
drawsLand: true,
|
||||
/** Whether shaders compile: a boolean, or a function of the shader type. */
|
||||
compiles: true,
|
||||
};
|
||||
const counts = {
|
||||
readPixels: 0,
|
||||
queries: 0,
|
||||
draws: 0,
|
||||
resultReads: 0,
|
||||
deleted: 0,
|
||||
};
|
||||
const liveQueries = new Set();
|
||||
const liveShaders = new Set();
|
||||
let activeQuery = null;
|
||||
const gl = {
|
||||
...GL,
|
||||
state,
|
||||
counts,
|
||||
liveQueries,
|
||||
liveShaders,
|
||||
isContextLost: () => state.lost,
|
||||
getParameter(name) {
|
||||
if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read;
|
||||
if (name === GL.DRAW_FRAMEBUFFER_BINDING) return state.draw;
|
||||
if (name === GL.TEXTURE_BINDING_2D) return state.units[state.unit];
|
||||
if (name === GL.ACTIVE_TEXTURE) return GL.TEXTURE0 + state.unit;
|
||||
if (name === GL.ARRAY_BUFFER_BINDING) return state.array;
|
||||
if (name === GL.VERTEX_ARRAY_BINDING) return state.vertexArray;
|
||||
if (name === GL.CURRENT_PROGRAM) return state.program;
|
||||
if (name === GL.VIEWPORT) return Int32Array.from(state.viewport);
|
||||
return null;
|
||||
},
|
||||
isEnabled: (cap) => cap === GL.SCISSOR_TEST && state.scissor,
|
||||
enable(cap) {
|
||||
if (cap === GL.SCISSOR_TEST) state.scissor = true;
|
||||
},
|
||||
disable(cap) {
|
||||
if (cap === GL.SCISSOR_TEST) state.scissor = false;
|
||||
},
|
||||
createFramebuffer: () => ({ image: null }),
|
||||
createTexture: () => ({ image: null }),
|
||||
createBuffer: () => ({ data: null }),
|
||||
deleteFramebuffer() {
|
||||
counts.deleted++;
|
||||
},
|
||||
deleteTexture() {
|
||||
counts.deleted++;
|
||||
},
|
||||
deleteBuffer() {
|
||||
counts.deleted++;
|
||||
},
|
||||
activeTexture(unit) {
|
||||
state.unit = unit - GL.TEXTURE0;
|
||||
},
|
||||
bindTexture(_target, texture) {
|
||||
state.units[state.unit] = texture;
|
||||
},
|
||||
bindFramebuffer(target, framebuffer) {
|
||||
if (target !== GL.DRAW_FRAMEBUFFER) state.read = framebuffer;
|
||||
if (target !== GL.READ_FRAMEBUFFER) state.draw = framebuffer;
|
||||
},
|
||||
bindBuffer(_target, buffer) {
|
||||
state.array = buffer;
|
||||
},
|
||||
readPixels(x, y, width, height, _format, _type, destination) {
|
||||
counts.readPixels++;
|
||||
const image = state.read.image;
|
||||
for (let row = 0; row < height; row++) {
|
||||
const from = ((y + row) * image.width + x) * 4;
|
||||
destination.set(
|
||||
image.data.subarray(from, from + width * 4),
|
||||
row * width * 4,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (webgl2) {
|
||||
Object.assign(gl, {
|
||||
texStorage2D(_target, _levels, _format, width, height) {
|
||||
state.units[state.unit].image = {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8Array(width * height * 4),
|
||||
};
|
||||
},
|
||||
framebufferTexture2D(_target, _attachment, _kind, texture) {
|
||||
state.draw.image = texture.image;
|
||||
},
|
||||
checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE,
|
||||
createShader(type) {
|
||||
const shader = { type };
|
||||
liveShaders.add(shader);
|
||||
return shader;
|
||||
},
|
||||
shaderSource() {},
|
||||
compileShader() {},
|
||||
getShaderParameter: (shader) =>
|
||||
typeof state.compiles === 'function'
|
||||
? state.compiles(shader.type)
|
||||
: state.compiles,
|
||||
deleteShader(shader) {
|
||||
liveShaders.delete(shader);
|
||||
},
|
||||
createProgram: () => ({ uniforms: {} }),
|
||||
attachShader() {},
|
||||
linkProgram() {},
|
||||
getProgramParameter: () => true,
|
||||
deleteProgram() {
|
||||
counts.deleted++;
|
||||
},
|
||||
getUniformLocation: (_program, name) => name,
|
||||
useProgram(program) {
|
||||
state.program = program;
|
||||
},
|
||||
viewport(x, y, width, height) {
|
||||
state.viewport = [x, y, width, height];
|
||||
},
|
||||
uniform1i(name, value) {
|
||||
state.program.uniforms[name] = value;
|
||||
},
|
||||
uniform4f(name, x, y, z, w) {
|
||||
state.program.uniforms[name] = [x, y, z, w];
|
||||
},
|
||||
createVertexArray: () => ({ buffer: null }),
|
||||
deleteVertexArray() {
|
||||
counts.deleted++;
|
||||
},
|
||||
bindVertexArray(vertexArray) {
|
||||
state.vertexArray = vertexArray;
|
||||
},
|
||||
enableVertexAttribArray() {},
|
||||
vertexAttribPointer() {
|
||||
state.vertexArray.buffer = state.array;
|
||||
},
|
||||
bufferData(_target, data) {
|
||||
state.array.data = Float32Array.from(data);
|
||||
},
|
||||
createQuery() {
|
||||
const query = { drew: false, available: false, ended: false };
|
||||
liveQueries.add(query);
|
||||
return query;
|
||||
},
|
||||
deleteQuery(query) {
|
||||
liveQueries.delete(query);
|
||||
},
|
||||
beginQuery(_target, query) {
|
||||
counts.queries++;
|
||||
query.drew = false;
|
||||
query.available = false;
|
||||
query.ended = false;
|
||||
activeQuery = query;
|
||||
},
|
||||
endQuery() {
|
||||
activeQuery.ended = true;
|
||||
activeQuery = null;
|
||||
},
|
||||
/** The vertex shader's rule, applied to what the shader would be given. */
|
||||
drawArrays(_mode, first) {
|
||||
counts.draws++;
|
||||
const uniforms = state.program.uniforms;
|
||||
const data = state.vertexArray.buffer.data;
|
||||
const u = data[first * 3];
|
||||
const v = data[first * 3 + 1];
|
||||
const threshold = data[first * 3 + 2];
|
||||
let surface = -1;
|
||||
for (let i = 0; i < uniforms.u_count && surface < 0; i++) {
|
||||
const image = state.units[uniforms[`u_depth${i}`]].image;
|
||||
const column = Math.min(image.width - 1, Math.floor(u * image.width));
|
||||
const row = Math.min(image.height - 1, Math.floor(v * image.height));
|
||||
const distance = depthToViewDistance(
|
||||
unpackDepth(image.data, (row * image.width + column) * 4),
|
||||
uniforms.u_near[i],
|
||||
uniforms.u_far[i],
|
||||
uniforms.u_logDepth === 1,
|
||||
);
|
||||
if (distance === distance) surface = distance;
|
||||
}
|
||||
const hidden = surface > 0 && surface < threshold;
|
||||
// A point at the middle of the viewport lands only if that is the
|
||||
// target's one pixel and the scissor test is not in the way. A
|
||||
// sampler left on a unit with no texture fails the whole draw.
|
||||
const samplersBound = [0, 1, 2, 3].every(
|
||||
(i) => state.units[uniforms[`u_depth${i}`]]?.image,
|
||||
);
|
||||
const lands =
|
||||
state.drawsLand &&
|
||||
samplersBound &&
|
||||
!state.scissor &&
|
||||
state.draw?.image?.width === 1 &&
|
||||
state.viewport.join() === '0,0,1,1';
|
||||
if (activeQuery && !hidden && lands) activeQuery.drew = true;
|
||||
},
|
||||
getQueryParameter(query, name) {
|
||||
if (name === GL.QUERY_RESULT_AVAILABLE) return query.available;
|
||||
counts.resultReads++;
|
||||
return query.available && query.drew ? 1 : 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
/** Let every query issued so far finish. */
|
||||
gl.finishGpu = () => {
|
||||
for (const query of liveQueries) if (query.ended) query.available = true;
|
||||
};
|
||||
return gl;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scene drawn in the given depth ranges, nearest first.
|
||||
* @param {{
|
||||
* gl?: object,
|
||||
* ranges: Array<{near: number, far: number, image: object}>,
|
||||
* logDepth?: boolean, fovy?: number, aspectRatio?: number,
|
||||
* viewMatrix?: Cesium.Matrix4,
|
||||
* }} options
|
||||
*/
|
||||
export function createFakeDepthScene({
|
||||
gl = createFakeGl(),
|
||||
ranges,
|
||||
logDepth = true,
|
||||
fovy = Math.PI / 2,
|
||||
aspectRatio = 1,
|
||||
viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
}) {
|
||||
return {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
useDepthPicking: true,
|
||||
opaqueFrustumNearOffset: 0.9999,
|
||||
context: { _gl: gl, depthTexture: true, webgl2: !!gl.createQuery },
|
||||
_environmentState: { useGlobeDepthFramebuffer: true },
|
||||
frameState: { useLogDepth: logDepth },
|
||||
camera: { frustum: { fovy, aspectRatio }, viewMatrix },
|
||||
view: {
|
||||
frustumCommandsList: ranges.map(({ near, far }) => ({ near, far })),
|
||||
pickDepths: ranges.map(({ image }) => {
|
||||
const texture = { image };
|
||||
return {
|
||||
framebuffer: {
|
||||
_framebuffer: { image },
|
||||
getColorTexture: () => ({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
_texture: texture,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
|
||||
flightsLayer.testing._driveFleetModelHandoffForTest;
|
||||
export const _ensureFleetModelForTest =
|
||||
flightsLayer.testing._ensureFleetModelForTest;
|
||||
export const _resetEnrichmentForTest =
|
||||
flightsLayer.testing._resetEnrichmentForTest;
|
||||
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
||||
export default flightsLayer;
|
||||
+293
-2
@@ -1,7 +1,7 @@
|
||||
// src/data/flights.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
import { test } from 'node:test';
|
||||
import { after, afterEach, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
@@ -14,21 +14,53 @@ import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
_floorGroundedDisplayPositionForTest,
|
||||
_clearDisplayFloorStateForTest,
|
||||
_resetEnrichmentForTest,
|
||||
mapAnalystRecord,
|
||||
} from './flights.js';
|
||||
import { setMilitaryLayerActive } from './militaryRegistry.js';
|
||||
import {
|
||||
isMilitaryLayerActive,
|
||||
registerMilitaryIcaos,
|
||||
setMilitaryLayerActive,
|
||||
} from './militaryRegistry.js';
|
||||
import {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
reportMeshFloorCell,
|
||||
reportValidatedMeshFloorCell,
|
||||
setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
} from './groundFloor.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
|
||||
// Unit tests never reach the network. A test that tracks a plane queues its
|
||||
// enrichment (type, route, photo), so every test ends on an empty queue, and a
|
||||
// fetch of any absolute URL fails the test it happened in. A test that needs
|
||||
// answers installs its own fetch and restores this one.
|
||||
const nodeFetch = globalThis.fetch;
|
||||
const networkRequests = [];
|
||||
before(() => {
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url?.url ?? url);
|
||||
if (/^https?:\/\//i.test(href)) networkRequests.push(href);
|
||||
throw new TypeError(`Failed to parse URL from ${href}`);
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
_resetEnrichmentForTest();
|
||||
assert.deepEqual(
|
||||
networkRequests.splice(0),
|
||||
[],
|
||||
'no test reaches the network',
|
||||
);
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = nodeFetch;
|
||||
});
|
||||
|
||||
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
|
||||
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
assert.equal(
|
||||
@@ -402,6 +434,232 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
}
|
||||
});
|
||||
|
||||
test('a tracked plane whose photo lookup failed asks again on the first poll after the cool-down', async (t) => {
|
||||
const icao24 = 'b2c3d4';
|
||||
const realNow = Date.now.bind(Date);
|
||||
let offsetMs = 0;
|
||||
t.mock.method(Date, 'now', () => realNow() + offsetMs);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity,
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
meta: {
|
||||
callsign: 'UAL9',
|
||||
altitude: 9_000,
|
||||
renderAltitudeM: 9_050,
|
||||
velocity: 180,
|
||||
true_track: 80,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.2,
|
||||
rawLon: -97.7,
|
||||
},
|
||||
});
|
||||
let callsign = 'UAL9 ';
|
||||
const photoRequests = [];
|
||||
const routeRequests = [];
|
||||
const guardFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
url = String(url);
|
||||
if (url.startsWith('https://api.planespotters.net/')) {
|
||||
photoRequests.push(url);
|
||||
return { ok: false, status: 503, json: async () => ({}) };
|
||||
}
|
||||
if (url.startsWith('/api/adsbdb/route/UAL')) routeRequests.push(url);
|
||||
if (!url.startsWith('/api/flights'))
|
||||
return { ok: true, status: 200, json: async () => ({ found: false }) };
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
time: nowSec,
|
||||
states: [
|
||||
[
|
||||
icao24,
|
||||
callsign,
|
||||
'United States',
|
||||
nowSec,
|
||||
nowSec,
|
||||
-97.6,
|
||||
30.3,
|
||||
10_668,
|
||||
false,
|
||||
250,
|
||||
95,
|
||||
5,
|
||||
null,
|
||||
10_700,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
],
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
// Lookups drip out one per 200 ms. Wait for what must arrive (with a
|
||||
// generous deadline, so a loaded machine cannot fail it), then a little
|
||||
// longer so anything that must NOT arrive would have.
|
||||
const until = async (ready) => {
|
||||
const deadline = realNow() + 5_000;
|
||||
while (!ready() && realNow() < deadline)
|
||||
await new Promise((done) => setTimeout(done, 20));
|
||||
await new Promise((done) => setTimeout(done, 450));
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => photoRequests.length >= 1 && routeRequests.length >= 1);
|
||||
assert.equal(photoRequests.length, 1, 'the first poll asks for the photo');
|
||||
assert.deepEqual(routeRequests, ['/api/adsbdb/route/UAL9']);
|
||||
|
||||
// Well inside the 60 s cool-down, and well past it below, so real time
|
||||
// spent between polls cannot move either side across the line.
|
||||
offsetMs += 30_000;
|
||||
callsign = 'UAL10 ';
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => routeRequests.length >= 2);
|
||||
assert.equal(photoRequests.length, 1, 'not within the cool-down');
|
||||
assert.deepEqual(
|
||||
routeRequests,
|
||||
['/api/adsbdb/route/UAL9', '/api/adsbdb/route/UAL10'],
|
||||
'the route follows the callsign the poll just recorded',
|
||||
);
|
||||
|
||||
offsetMs += 60_000;
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => photoRequests.length >= 2);
|
||||
assert.deepEqual(
|
||||
photoRequests,
|
||||
[
|
||||
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
|
||||
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
|
||||
],
|
||||
'asked again on the first poll after the cool-down',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = guardFetch;
|
||||
// The moved clock dated this layer's dispatch and cool-downs into the
|
||||
// future; reset before the real clock comes back.
|
||||
_resetEnrichmentForTest();
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a known-military aircraft tracked in the flights layer is never looked up and shows no photo', async () => {
|
||||
const icao24 = 'ae5e01';
|
||||
// The registry has no way to forget one hex (only a whole-registry reset,
|
||||
// which would also drop its source), so the test uses a hex no other test
|
||||
// reads and restores the layer-active flag it changes.
|
||||
const militaryWasActive = isMilitaryLayerActive();
|
||||
registerMilitaryIcaos([icao24]);
|
||||
setMilitaryLayerActive(false);
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity: { gevLabelModel: { title: 'OLD', details: [] } },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
meta: {
|
||||
callsign: 'RCH123',
|
||||
altitude: 9_000,
|
||||
renderAltitudeM: 9_050,
|
||||
velocity: 180,
|
||||
true_track: 80,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.2,
|
||||
rawLon: -97.7,
|
||||
// Even a photo already on the record is never shown.
|
||||
photo: {
|
||||
src: 'https://t.plnspttrs.net/1/ae5e01_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/x',
|
||||
},
|
||||
},
|
||||
});
|
||||
const photoRequests = [];
|
||||
const routeRequests = [];
|
||||
const guardFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
url = String(url);
|
||||
if (url.startsWith('https://api.planespotters.net/')) {
|
||||
photoRequests.push(url);
|
||||
return { ok: false, status: 503, json: async () => ({}) };
|
||||
}
|
||||
if (url.startsWith('/api/adsbdb/route/')) routeRequests.push(url);
|
||||
if (!url.startsWith('/api/flights'))
|
||||
return { ok: true, status: 200, json: async () => ({ found: false }) };
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
time: nowSec,
|
||||
states: [
|
||||
[
|
||||
icao24,
|
||||
'RCH123 ',
|
||||
'United States',
|
||||
nowSec,
|
||||
nowSec,
|
||||
-97.6,
|
||||
30.3,
|
||||
10_668,
|
||||
false,
|
||||
250,
|
||||
95,
|
||||
5,
|
||||
null,
|
||||
10_700,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
],
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
// A photo lookup would go out ahead of the route; wait for the route.
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!routeRequests.length && Date.now() < deadline)
|
||||
await new Promise((done) => setTimeout(done, 20));
|
||||
assert.deepEqual(routeRequests, ['/api/adsbdb/route/RCH123']);
|
||||
assert.deepEqual(photoRequests, [], 'no photo lookup for a military hex');
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.icao24, icao24);
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.photo, null);
|
||||
assert.equal(flightsLayer.getTrackedSubject()?.photo, null);
|
||||
} finally {
|
||||
globalThis.fetch = guardFetch;
|
||||
setMilitaryLayerActive(militaryWasActive);
|
||||
}
|
||||
});
|
||||
|
||||
test('real civil track path creates no native label and publishes every cached host line', async () => {
|
||||
const icao24 = 'civ001';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
|
||||
@@ -1181,6 +1439,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6);
|
||||
});
|
||||
|
||||
test('display floor: reads the reading nearest the contact, not the cell centre', async () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
try {
|
||||
await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
// A hangar roof over the cell's centre; the apron where the contact is.
|
||||
assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100);
|
||||
const out = _floorGroundedDisplayPositionForTest(
|
||||
{ onGround: true },
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
const c = _floorCarto(out);
|
||||
assert.ok(
|
||||
Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05,
|
||||
`height ${c.height} should be the apron's 121 + lift`,
|
||||
);
|
||||
_clearMeshFloorCellsForTest();
|
||||
_clearDisplayFloorStateForTest();
|
||||
});
|
||||
|
||||
test('display floor: an AIRBORNE contact over the same cell is never lifted', () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
|
||||
@@ -22,6 +22,7 @@ export const {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
@@ -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
|
||||
@@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
_clearMeshFloorCellsForTest, reportValidatedMeshFloorCell,
|
||||
} from './groundFloor.js';
|
||||
|
||||
/** Distinct site per test — terrainHeights' cache is module-global and warm
|
||||
@@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', ()
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
|
||||
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
|
||||
});
|
||||
|
||||
test('sampler: the probe goes where the point is, not to the cell centre', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
const asked = [];
|
||||
const scene = fakeScene((carto) => {
|
||||
asked.push(Cesium.Math.toDegrees(carto.longitude));
|
||||
return 122.0;
|
||||
});
|
||||
const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 };
|
||||
sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(asked.length, 1);
|
||||
assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`);
|
||||
});
|
||||
|
||||
test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A viaduct deck 9 m up along one line of the cell, the street elsewhere.
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
const street = { lat: cell.lat - 0.0003, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
sampleMeshFloorCells(scene, [street], opts);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck');
|
||||
assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street');
|
||||
assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts);
|
||||
assert.equal(calls, 2, 'a point next to one already probed is not probed again');
|
||||
});
|
||||
|
||||
test('sampler: a roof over the cell centre does not lift a bus on the street', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A roof 22 m up covering the cell's centre; the street 30 m west of it.
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0);
|
||||
const bus = { lat: cell.lat, lon: cell.lon - 0.0003 };
|
||||
sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0);
|
||||
});
|
||||
|
||||
test('sampler: a second reading at the same spot keeps the lower one', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it');
|
||||
});
|
||||
|
||||
test('sampler: a cell with a reading is probed a few times at most', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 20; i++)
|
||||
sampleMeshFloorCells(
|
||||
scene,
|
||||
[{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }],
|
||||
opts,
|
||||
);
|
||||
assert.equal(calls, 8);
|
||||
});
|
||||
|
||||
test('sampler: a cell with no accepted reading is probed again on every call', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 138.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm');
|
||||
});
|
||||
|
||||
test('sampler: a spot whose reading is refused does not use up the cell', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A tower 260 m up where the first vehicle waits; the street elsewhere and
|
||||
// a deck 9 m up along one line of the cell.
|
||||
const towerLon = cell.lon + 0.0004;
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) => {
|
||||
if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time');
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts);
|
||||
assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed');
|
||||
});
|
||||
|
||||
test('ground floor: a cell keeps at most sixteen readings', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lat = cell.lat - 0.00045 + (i % 8) * 0.0001;
|
||||
const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002;
|
||||
reportValidatedMeshFloorCell(lat, lon, 121.0 + i);
|
||||
}
|
||||
// Readings 0..15 are kept; reading 39 (at the far corner) is not, so that
|
||||
// spot answers from the nearest kept one.
|
||||
assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0);
|
||||
assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => {
|
||||
const { createGroundFloor } = await import('../services/groundFloor.js');
|
||||
const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js');
|
||||
let prior = 120.0;
|
||||
const terrain = {
|
||||
cachedRealEllipsoidalGround: () => prior,
|
||||
resolveEllipsoidalGround: async () => {},
|
||||
};
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
const sampler = createMeshFloorSampler({ groundFloor });
|
||||
const cell = { lat: 41.5, lon: -97.66 };
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0);
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
prior = null; // the cell's DEM entry is evicted; the street reading stays
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0);
|
||||
prior = 120.0;
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
@@ -425,7 +425,7 @@ test('a dependency the user adopts stays on when Contacts turns off', async () =
|
||||
assert.deepEqual(
|
||||
released,
|
||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
||||
'the adopted layer is the user\'s now; the rest are still released',
|
||||
"the adopted layer is the user's now; the rest are still released",
|
||||
);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
@@ -1118,6 +1118,92 @@ test('production military tracked-subject label falls back to registration befor
|
||||
}
|
||||
});
|
||||
|
||||
test('the tracked airliner’s photo reaches the Contacts panel when it lands after selection', async () => {
|
||||
const id = 'a1f2c3';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
const sourceViewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
const meta = {
|
||||
callsign: 'AAL100',
|
||||
altitude: 10_668,
|
||||
renderAltitudeM: 10_700,
|
||||
velocity: 250,
|
||||
true_track: 95,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.21,
|
||||
rawLon: -97.71,
|
||||
photo: null,
|
||||
};
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, color: Cesium.Color.WHITE, show: true },
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: sourceViewer,
|
||||
meta,
|
||||
});
|
||||
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
|
||||
replaceMethod(
|
||||
militaryFlightsLayer,
|
||||
'getTrackedSubject',
|
||||
() => null,
|
||||
restores,
|
||||
);
|
||||
replaceMethod(flightsLayer, 'trackById', () => true, restores);
|
||||
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
|
||||
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
await nextTurn();
|
||||
assert.equal(runtime.panel().view?.kind, 'results');
|
||||
assert.equal(runtime.panel().view.photo, null, 'no photo before it lands');
|
||||
|
||||
// An unchanged refresh stays quiet.
|
||||
const settledPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(runtime.panel().publishes, settledPublishes);
|
||||
|
||||
// The photo lands while the plane holds still: render-worthy on its own.
|
||||
meta.photo = {
|
||||
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/n100aa',
|
||||
};
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.deepEqual(runtime.panel().view.photo, {
|
||||
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
link: 'https://www.planespotters.net/photo/1/n100aa',
|
||||
credit: 'Photo © Jane Doe',
|
||||
});
|
||||
|
||||
// And it repaints once, not on every refresh.
|
||||
const shownPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(runtime.panel().publishes, shownPublishes);
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer: sourceViewer,
|
||||
meta: {},
|
||||
tracked: false,
|
||||
});
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a cached Context subject re-reads its label when enrichment lands after selection', async () => {
|
||||
// The subject snapshot is captured once at selection. Selecting a
|
||||
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
|
||||
@@ -1721,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'aisLiveVessels',
|
||||
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
],
|
||||
]) {
|
||||
assert.match(
|
||||
|
||||
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.mmsi,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
const id = item?.icao24 || item?.id || item?.mmsi;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
|
||||
+181
-8
@@ -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)
|
||||
|
||||
@@ -21,7 +21,7 @@ const _owners = new Map();
|
||||
*
|
||||
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
|
||||
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
|
||||
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
|
||||
* the vessel record OBJECT (identity = its `id`), and entity picks surface
|
||||
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
|
||||
* String so predicates match against one canonical form.
|
||||
*
|
||||
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
|
||||
const unwrap = (id) => {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
|
||||
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
||||
});
|
||||
|
||||
test('resolvePickId: AIS vessel record object → its mmsi', () => {
|
||||
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
|
||||
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
assert.equal(resolvePickId({ id: record }), '367123450');
|
||||
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
|
||||
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
|
||||
assert.equal(resolvePickId({ id: shared }), 'hull-b');
|
||||
});
|
||||
|
||||
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
|
||||
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
|
||||
assert.equal(resolvePickId(null), null);
|
||||
assert.equal(resolvePickId(undefined), null);
|
||||
assert.equal(resolvePickId({}), null);
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no id
|
||||
assert.equal(resolvePickId({ primitive: {} }), null);
|
||||
});
|
||||
|
||||
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
|
||||
try {
|
||||
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
||||
// Flights asking about a vessel pick (record object → mmsi)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
|
||||
// Flights asking about a vessel pick (record object → id)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
|
||||
// A layer never owns its own pick via the sibling scan
|
||||
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
||||
// Unknown ids belong to nobody
|
||||
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
|
||||
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
layerId === 'ais-live-vessels' && record.properties?.vesselId
|
||||
? String(record.properties.vesselId)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @module planespottersPhoto
|
||||
* @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
|
||||
* (`{found, thumbnail_large?, thumbnail: {src, width, height}, photographer,
|
||||
* link}`), and an
|
||||
* already-read photo (`{found, src, width, height, photographer, link}`).
|
||||
* Pure: no fetch, no DOM.
|
||||
*/
|
||||
|
||||
/** The image host: Planespotters' thumbnail CDN and its subdomains. */
|
||||
const IMAGE_HOST = 'plnspttrs.net';
|
||||
/** The only page host a photo may link to. */
|
||||
const PAGE_HOST = 'www.planespotters.net';
|
||||
|
||||
const NO_PHOTO = Object.freeze({ found: false });
|
||||
|
||||
const isObject = (value) => Boolean(value) && typeof value === 'object';
|
||||
|
||||
/** The parsed `https:` URL, or null for anything else or with userinfo/port. */
|
||||
function httpsUrl(value) {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
||||
return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
function imageSrc(value) {
|
||||
const url = httpsUrl(value);
|
||||
return url &&
|
||||
(url.hostname === IMAGE_HOST || url.hostname.endsWith(`.${IMAGE_HOST}`))
|
||||
? url.href
|
||||
: null;
|
||||
}
|
||||
|
||||
function pageLink(value) {
|
||||
const url = httpsUrl(value);
|
||||
return url && url.hostname === PAGE_HOST ? url.href : null;
|
||||
}
|
||||
|
||||
function dimension(value) {
|
||||
const number = Number(value);
|
||||
return value != null && Number.isFinite(number) && number > 0
|
||||
? Math.round(number)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** One candidate `{src, width, height, photographer, link}`, checked. */
|
||||
function checked({ src, width, height, photographer, link }) {
|
||||
const credit = typeof photographer === 'string' ? photographer.trim() : '';
|
||||
const image = imageSrc(src);
|
||||
const page = pageLink(link);
|
||||
if (!image || !page) return null;
|
||||
return {
|
||||
found: true,
|
||||
src: image,
|
||||
width: dimension(width),
|
||||
height: dimension(height),
|
||||
photographer: credit,
|
||||
link: page,
|
||||
};
|
||||
}
|
||||
|
||||
/** The upstream photo's candidates, large thumbnail first. */
|
||||
function upstreamCandidates(photo) {
|
||||
if (!photo || typeof photo !== 'object') return [];
|
||||
return [photo.thumbnail_large, photo.thumbnail]
|
||||
.filter((thumb) => thumb && typeof thumb === 'object')
|
||||
.map((thumb) => ({
|
||||
src: thumb.src,
|
||||
width: thumb.size?.width,
|
||||
height: thumb.size?.height,
|
||||
photographer: photo.photographer,
|
||||
link: photo.link,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Planespotters answer into the first photo that passes the checks.
|
||||
* @param {*} payload Upstream, cached-server, or already-read photo.
|
||||
* @returns {{found: true, src: string, width: ?number, height: ?number,
|
||||
* photographer: string, link: string} | {found: false}}
|
||||
*/
|
||||
export function parsePlanespottersPhoto(payload) {
|
||||
if (!payload || typeof payload !== 'object') return NO_PHOTO;
|
||||
let candidates;
|
||||
if (Array.isArray(payload.photos)) {
|
||||
candidates = payload.photos.flatMap(upstreamCandidates);
|
||||
} else if (payload.found !== true) {
|
||||
return NO_PHOTO;
|
||||
} else if (isObject(payload.thumbnail) || isObject(payload.thumbnail_large)) {
|
||||
// Large first, as for the upstream answer; each must pass the checks.
|
||||
candidates = [payload.thumbnail_large, payload.thumbnail]
|
||||
.filter(isObject)
|
||||
.map((thumb) => ({
|
||||
...thumb,
|
||||
photographer: payload.photographer,
|
||||
link: payload.link,
|
||||
}));
|
||||
} else {
|
||||
candidates = [payload];
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const photo = checked(candidate);
|
||||
if (photo) return photo;
|
||||
}
|
||||
return NO_PHOTO;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// src/data/planespottersPhoto.test.mjs — the Planespotters photo answer is read
|
||||
// into one credited thumbnail, and nothing outside Planespotters' own hosts is
|
||||
// ever shown or linked.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parsePlanespottersPhoto } from './planespottersPhoto.js';
|
||||
|
||||
const UPSTREAM = Object.freeze({
|
||||
photos: [
|
||||
{
|
||||
id: '1745410',
|
||||
thumbnail: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
size: { width: 200, height: 133 },
|
||||
},
|
||||
thumbnail_large: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
|
||||
size: { width: 420, height: 280 },
|
||||
},
|
||||
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
|
||||
photographer: 'Jan Seler',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const PHOTO = Object.freeze({
|
||||
found: true,
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
|
||||
width: 420,
|
||||
height: 280,
|
||||
photographer: 'Jan Seler',
|
||||
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
|
||||
});
|
||||
|
||||
const photo = (overrides) => ({
|
||||
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
|
||||
});
|
||||
|
||||
test('the upstream answer maps to the large thumbnail, its size, credit and link', () => {
|
||||
assert.deepEqual(parsePlanespottersPhoto(UPSTREAM), PHOTO);
|
||||
});
|
||||
|
||||
test('the small thumbnail stands in when the large one is missing', () => {
|
||||
assert.deepEqual(parsePlanespottersPhoto(photo({ thumbnail_large: null })), {
|
||||
...PHOTO,
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
});
|
||||
});
|
||||
|
||||
test('a server’s cached shape reads the same way', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: { src: PHOTO.src, width: 420, height: 280 },
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
PHOTO,
|
||||
);
|
||||
assert.deepEqual(parsePlanespottersPhoto({ found: false }), {
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a server’s cached shape prefers its large thumbnail', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
},
|
||||
thumbnail_large: { src: PHOTO.src, width: 420, height: 280 },
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
PHOTO,
|
||||
);
|
||||
});
|
||||
|
||||
test('a server’s cached shape falls back to its small thumbnail when the large one fails the checks', () => {
|
||||
const small = {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
};
|
||||
for (const thumbnail_large of [
|
||||
{ src: 'https://evil.example/a.jpg', width: 420, height: 280 },
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: small,
|
||||
thumbnail_large,
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
{ ...PHOTO, ...small },
|
||||
JSON.stringify(thumbnail_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: [] },
|
||||
{},
|
||||
null,
|
||||
undefined,
|
||||
'nope',
|
||||
{ photos: 'nope' },
|
||||
{ photos: [null] },
|
||||
photo({ link: '' }),
|
||||
photo({ thumbnail: null, thumbnail_large: null }),
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(payload),
|
||||
{ found: false },
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a src outside https *.plnspttrs.net is dropped', () => {
|
||||
for (const src of [
|
||||
'http://t.plnspttrs.net/a.jpg',
|
||||
'https://plnspttrs.net.evil.example/a.jpg',
|
||||
'https://evil.example/t.plnspttrs.net/a.jpg',
|
||||
'https://t.plnspttrs.net.evil.example/a.jpg',
|
||||
'https://[email protected]/a.jpg',
|
||||
'https://t.plnspttrs.net:8443/a.jpg',
|
||||
'javascript:alert(1)',
|
||||
'//t.plnspttrs.net/a.jpg',
|
||||
'not a url',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(
|
||||
photo({
|
||||
thumbnail_large: { src, size: { width: 420, height: 280 } },
|
||||
thumbnail: { src, size: { width: 200, height: 133 } },
|
||||
}),
|
||||
),
|
||||
{ found: false },
|
||||
src,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a link anywhere but https://www.planespotters.net is dropped', () => {
|
||||
for (const link of [
|
||||
'http://www.planespotters.net/photo/1',
|
||||
'https://planespotters.net/photo/1',
|
||||
'https://www.planespotters.net.evil.example/photo/1',
|
||||
'https://evil.example/?u=https://www.planespotters.net/',
|
||||
'https://user:[email protected]/photo/1',
|
||||
'https://www.planespotters.net:444/photo/1',
|
||||
'javascript:alert(1)',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(photo({ link })),
|
||||
{ found: false },
|
||||
link,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the first photo that passes the checks is the one shown', () => {
|
||||
const second = {
|
||||
...UPSTREAM.photos[0],
|
||||
photographer: 'Second Spotter',
|
||||
};
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
photos: [
|
||||
{ ...UPSTREAM.photos[0], link: 'https://evil.example/' },
|
||||
second,
|
||||
],
|
||||
}),
|
||||
{ ...PHOTO, photographer: 'Second Spotter' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing size is null and the credit is trimmed', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(
|
||||
photo({
|
||||
photographer: ' Jan Seler ',
|
||||
thumbnail_large: { src: PHOTO.src },
|
||||
}),
|
||||
),
|
||||
{ ...PHOTO, width: null, height: 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,796 @@
|
||||
/**
|
||||
* Asks the GPU, per contact, whether the scene drew something in front of it,
|
||||
* without waiting for the answer.
|
||||
*
|
||||
* The scene already keeps the depth of the frame it has just drawn (for
|
||||
* picking). `scene.pickPosition` reads one texel of it back and waits, a
|
||||
* fraction of a millisecond to over a millisecond per call; asking once per
|
||||
* labelled contact turns a 2 ms frame into a 40 ms one. Copying the whole
|
||||
* depth image back instead is no better where it matters: Chromium answers
|
||||
* `getBufferSubData` with a round trip to its GPU process even after a sync
|
||||
* object has signalled, measured here at 1.5 ms on a quiet machine and 15 to
|
||||
* 25 ms on a busy one.
|
||||
*
|
||||
* So nothing is read back at all. Each tested contact becomes one point drawn
|
||||
* into a private 1x1 target, inside an occlusion query. The vertex shader looks
|
||||
* up the frame's depth at the contact's pixel, decodes it, and throws the point
|
||||
* away when the drawn surface is nearer than the contact's threshold (see
|
||||
* `projectContact`). The query's "did anything draw" bit is the answer, and
|
||||
* asking whether a query result is in is the one question WebGL 2 lets a page
|
||||
* put to the GPU without waiting (Chromium publishes results to the page
|
||||
* through shared memory between frames): `_collect` just looks.
|
||||
*
|
||||
* - `beginFrame(scene, now)` collects the answers that have arrived.
|
||||
* - `add(contact, position)` queues a contact for testing against this frame.
|
||||
* - `endFrame()` draws the queued points. Answers land a frame or two later.
|
||||
*
|
||||
* A WebGL 1 context has no queries. There the probe falls back to reading the
|
||||
* frame's depth back synchronously, at most every `SYNC_READ_INTERVAL_MS`, and
|
||||
* answering on the CPU; `mode` then reads `'sync'`. Where the scene keeps no
|
||||
* pick depth, or is not a 3D perspective view, `mode` reads `'off'`, nothing
|
||||
* is tested and callers carry on as if no contact were hidden.
|
||||
*
|
||||
* Every read of renderer internals is in `readSceneDepthSource` below; the
|
||||
* rest of the file is plain WebGL on the handles that function returns.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { DepthSnapshot, projectContact } from './detectionOcclusion.js';
|
||||
|
||||
/**
|
||||
* Depth ranges tested, nearest first. Logarithmic depth draws nearly every
|
||||
* view in one or two; anything past the fourth is beyond where depth-tested
|
||||
* contacts are labelled.
|
||||
*/
|
||||
export const PROBE_MAX_FRUSTUMS = 4;
|
||||
|
||||
/** Contacts tested per frame; the rest wait for the next one. */
|
||||
export const PROBE_MAX_PER_FRAME = 256;
|
||||
|
||||
/** Painted frames a batch of queries may stay unanswered before it is dropped. */
|
||||
export const PROBE_MAX_PENDING_FRAMES = 120;
|
||||
|
||||
/** Batches in a row whose control point failed before the probe stands down. */
|
||||
export const PROBE_MAX_CONTROL_FAILURES = 3;
|
||||
|
||||
/** How long the probe stands down after a failure before trying again. */
|
||||
export const PROBE_RETRY_AFTER_MS = 10_000;
|
||||
|
||||
/** Shortest gap between two synchronous depth reads (WebGL 1 fallback). */
|
||||
export const SYNC_READ_INTERVAL_MS = 125;
|
||||
|
||||
/**
|
||||
* Everything this module takes from the renderer, in one place.
|
||||
*
|
||||
* RENDERER INTERNALS. Checked against cesium 1.138.0 (@cesium/engine 22.3.0);
|
||||
* re-check on an engine upgrade. Undocumented members used, and what each is
|
||||
* assumed to be:
|
||||
*
|
||||
* - `scene.context._gl`: the WebGL context. `scene.context.webgl2` says which.
|
||||
* - `scene.view.frustumCommandsList[i].near` / `.far`: the depth ranges the
|
||||
* last frame was drawn in, nearest first.
|
||||
* - `scene.view.pickDepths[i].framebuffer`: one framebuffer per depth range,
|
||||
* refreshed every frame while `scene.useDepthPicking` is on and
|
||||
* `scene._environmentState.useGlobeDepthFramebuffer` is true, whose first
|
||||
* colour attachment is an RGBA8 texture holding window-space depth packed
|
||||
* four bytes to a texel (`czm_packDepth`; see `unpackDepth`).
|
||||
* `framebuffer._framebuffer` is the raw WebGL framebuffer, and
|
||||
* `framebuffer.getColorTexture(0)` the texture (`._texture` its raw handle).
|
||||
* - Opaque geometry in every range but the nearest is drawn with its near
|
||||
* plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as
|
||||
* `scene.pickPosition` assumes when it decodes the same textures.
|
||||
* - `scene.frameState.useLogDepth`: whether depth is logarithmic (see
|
||||
* `depthToViewDistance`). Anything but a boolean there is "no depth".
|
||||
*
|
||||
* Any of these missing or shaped differently returns null, which callers treat
|
||||
* as "no depth".
|
||||
*
|
||||
* @param {Cesium.Scene} scene
|
||||
* @param {Float64Array} [viewScratch] Sixteen numbers to write the view matrix
|
||||
* into instead of allocating; the result's `view` is then this array.
|
||||
* @returns {null | {
|
||||
* gl: WebGLRenderingContext|WebGL2RenderingContext, webgl2: boolean,
|
||||
* width: number, height: number, framebuffers: WebGLFramebuffer[],
|
||||
* textures: WebGLTexture[],
|
||||
* frustums: Array<{near: number, far: number}>, logDepth: boolean,
|
||||
* view: Float64Array, tanHalfFovX: number, tanHalfFovY: number,
|
||||
* }}
|
||||
*/
|
||||
export function readSceneDepthSource(scene, viewScratch) {
|
||||
try {
|
||||
if (!scene || scene.mode !== Cesium.SceneMode.SCENE3D) return null;
|
||||
if (scene.useDepthPicking !== true) return null;
|
||||
const context = scene.context;
|
||||
const gl = context?._gl;
|
||||
if (!gl || context.depthTexture !== true) return null;
|
||||
if (scene._environmentState?.useGlobeDepthFramebuffer !== true) return null;
|
||||
|
||||
const camera = scene.camera;
|
||||
const frustum = camera?.frustum;
|
||||
const fovy = frustum?.fovy;
|
||||
const aspectRatio = frustum?.aspectRatio;
|
||||
if (!(fovy > 0 && fovy < Math.PI) || !(aspectRatio > 0)) return null;
|
||||
if (frustum.xOffset || frustum.yOffset) return null;
|
||||
|
||||
const view = scene.view;
|
||||
const ranges = view?.frustumCommandsList;
|
||||
const pickDepths = view?.pickDepths;
|
||||
if (!ranges?.length || !pickDepths?.length) return null;
|
||||
const count = Math.min(
|
||||
ranges.length,
|
||||
pickDepths.length,
|
||||
PROBE_MAX_FRUSTUMS,
|
||||
);
|
||||
const nearOffset = Number(scene.opaqueFrustumNearOffset) || 1;
|
||||
const framebuffers = [];
|
||||
const textures = [];
|
||||
const frustums = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const framebuffer = pickDepths[i]?.framebuffer;
|
||||
const raw = framebuffer?._framebuffer;
|
||||
const texture = framebuffer?.getColorTexture?.(0);
|
||||
const near = ranges[i]?.near * (i === 0 ? 1 : nearOffset);
|
||||
const far = ranges[i]?.far;
|
||||
if (!raw || !texture?._texture || !(near > 0) || !(far > near))
|
||||
return null;
|
||||
if (i === 0) {
|
||||
width = texture.width;
|
||||
height = texture.height;
|
||||
} else if (texture.width !== width || texture.height !== height) {
|
||||
return null;
|
||||
}
|
||||
framebuffers.push(raw);
|
||||
textures.push(texture._texture);
|
||||
frustums.push({ near, far });
|
||||
}
|
||||
if (!(width > 0) || !(height > 0)) return null;
|
||||
// Read as standard depth, logarithmic depth puts every surface a few
|
||||
// metres from the camera and would hide every contact: unknown is "no".
|
||||
const logDepth = scene.frameState?.useLogDepth;
|
||||
if (typeof logDepth !== 'boolean') return null;
|
||||
|
||||
const tanHalfFovY = Math.tan(fovy / 2);
|
||||
return {
|
||||
gl,
|
||||
webgl2: context.webgl2 === true,
|
||||
width,
|
||||
height,
|
||||
framebuffers,
|
||||
textures,
|
||||
frustums,
|
||||
logDepth,
|
||||
view: Cesium.Matrix4.toArray(
|
||||
camera.viewMatrix,
|
||||
viewScratch || new Float64Array(16),
|
||||
),
|
||||
tanHalfFovX: tanHalfFovY * aspectRatio,
|
||||
tanHalfFovY,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One point per contact. `a_contact` is (u, v, threshold): where the contact
|
||||
* is in the frame, and the view distance a drawn surface must be under to hide
|
||||
* it. A hidden contact's point is moved outside the clip volume, so nothing is
|
||||
* drawn and its query reports no samples. A visible one lands on the single
|
||||
* pixel of the 1x1 target (the viewport is set to match while these draw).
|
||||
*/
|
||||
export const PROBE_VERTEX_SHADER = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp sampler2D;
|
||||
layout(location = 0) in vec3 a_contact;
|
||||
uniform sampler2D u_depth0;
|
||||
uniform sampler2D u_depth1;
|
||||
uniform sampler2D u_depth2;
|
||||
uniform sampler2D u_depth3;
|
||||
uniform int u_count;
|
||||
uniform bool u_logDepth;
|
||||
uniform vec4 u_near;
|
||||
uniform vec4 u_far;
|
||||
|
||||
float viewDistance(sampler2D depthTexture, float near, float far) {
|
||||
ivec2 size = textureSize(depthTexture, 0);
|
||||
ivec2 texel = min(ivec2(a_contact.xy * vec2(size)), size - 1);
|
||||
float depth = dot(
|
||||
texelFetch(depthTexture, texel, 0),
|
||||
vec4(1.0, 1.0 / 255.0, 1.0 / 65025.0, 1.0 / 16581375.0));
|
||||
if (depth <= 0.0 || depth >= 1.0) return -1.0;
|
||||
if (u_logDepth) return near + exp2(depth * log2(far - near + 1.0)) - 1.0;
|
||||
return near * far / (far - depth * (far - near));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float surface = viewDistance(u_depth0, u_near.x, u_far.x);
|
||||
if (surface < 0.0 && u_count > 1) surface = viewDistance(u_depth1, u_near.y, u_far.y);
|
||||
if (surface < 0.0 && u_count > 2) surface = viewDistance(u_depth2, u_near.z, u_far.z);
|
||||
if (surface < 0.0 && u_count > 3) surface = viewDistance(u_depth3, u_near.w, u_far.w);
|
||||
bool hidden = surface > 0.0 && surface < a_contact.z;
|
||||
gl_Position = hidden ? vec4(2.0, 2.0, 2.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);
|
||||
gl_PointSize = 1.0;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `#version 300 es
|
||||
precision mediump float;
|
||||
out vec4 color;
|
||||
void main() { color = vec4(1.0); }
|
||||
`;
|
||||
|
||||
function canQuery(gl) {
|
||||
return (
|
||||
typeof gl.createQuery === 'function' &&
|
||||
typeof gl.beginQuery === 'function' &&
|
||||
typeof gl.getQueryParameter === 'function' &&
|
||||
typeof gl.createVertexArray === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function compile(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
/** Owns the GPU objects and the query batches in flight. */
|
||||
export class SceneOcclusionProbe {
|
||||
/**
|
||||
* @param {{
|
||||
* now?: () => number,
|
||||
* readSource?: typeof readSceneDepthSource,
|
||||
* }} [options]
|
||||
*/
|
||||
constructor({
|
||||
now = () => performance.now(),
|
||||
readSource = readSceneDepthSource,
|
||||
} = {}) {
|
||||
this._clock = now;
|
||||
this._readSource = readSource;
|
||||
/** Most tests one frame takes; read by whoever schedules them. */
|
||||
this.maxPerFrame = PROBE_MAX_PER_FRAME;
|
||||
/** `'async'` (queries), `'sync'` (blocking read), or `'off'`. */
|
||||
this.mode = 'off';
|
||||
/** Milliseconds the last frame spent collecting answers and drawing. */
|
||||
this.lastFrameMs = 0;
|
||||
/** Contact tests started in the last frame. */
|
||||
this.lastFrameTests = 0;
|
||||
this._scene = null;
|
||||
this._now = 0;
|
||||
this._source = undefined;
|
||||
this._gl = null;
|
||||
/** A context the probe's shader failed to build on; never retried. */
|
||||
this._unsupportedGl = null;
|
||||
this._resources = null;
|
||||
this._queryPool = [];
|
||||
this._batches = [];
|
||||
this._attributes = new Float32Array((PROBE_MAX_PER_FRAME + 1) * 3);
|
||||
this._queued = [];
|
||||
this._projected = { u: 0, v: 0, threshold: 0 };
|
||||
this._controlFailures = 0;
|
||||
this._retryAt = Number.NEGATIVE_INFINITY;
|
||||
this._frame = 0;
|
||||
this._view = new Float64Array(16);
|
||||
this._snapshot = null;
|
||||
this._snapshotAt = Number.NEGATIVE_INFINITY;
|
||||
this._spent = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a frame: take in the answers that have arrived. Call after the scene
|
||||
* has rendered (a post-render listener), before `add`.
|
||||
* @param {Cesium.Scene} scene
|
||||
* @param {number} now The caller's clock, in milliseconds. Answers are
|
||||
* dated by it, so it must be the clock the caller judges their age on.
|
||||
*/
|
||||
beginFrame(scene, now) {
|
||||
const started = this._clock();
|
||||
this._scene = scene;
|
||||
this._now = now;
|
||||
this._frame++;
|
||||
this._source = undefined;
|
||||
// A frame that never reached `endFrame` leaves its contacts untested.
|
||||
this._release(this._queued);
|
||||
this._queued.length = 0;
|
||||
this.lastFrameTests = 0;
|
||||
this._collect();
|
||||
this._spent = this._clock() - started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a contact for testing against the frame just drawn.
|
||||
* @param {{lastHiddenAt: number, pending: boolean, answered: boolean}}
|
||||
* contact Updated when the answer arrives: `pending` cleared, `answered`
|
||||
* set and, if the contact was hidden, `lastHiddenAt` set to the time of
|
||||
* the frame it was tested against.
|
||||
* @param {{x: number, y: number, z: number}} position
|
||||
* @returns {boolean} False when the contact cannot be tested now.
|
||||
*/
|
||||
add(contact, position) {
|
||||
if (this._source === undefined) this._openSource();
|
||||
const source = this._source;
|
||||
if (!source) return false;
|
||||
if (this.mode === 'sync') {
|
||||
const hidden = this._snapshot?.hides(position);
|
||||
if (hidden === null || hidden === undefined) return false;
|
||||
contact.answered = true;
|
||||
if (hidden) contact.lastHiddenAt = this._now;
|
||||
this.lastFrameTests++;
|
||||
return true;
|
||||
}
|
||||
const queued = this._queued;
|
||||
if (queued.length >= PROBE_MAX_PER_FRAME) return false;
|
||||
const projected = this._projected;
|
||||
if (
|
||||
!projectContact(
|
||||
source.view,
|
||||
source.tanHalfFovX,
|
||||
source.tanHalfFovY,
|
||||
position,
|
||||
projected,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
// Slot 0 is the control point; contacts start at 1.
|
||||
const at = (queued.length + 1) * 3;
|
||||
const attributes = this._attributes;
|
||||
attributes[at] = projected.u;
|
||||
attributes[at + 1] = projected.v;
|
||||
attributes[at + 2] = projected.threshold;
|
||||
queued.push(contact);
|
||||
contact.pending = true;
|
||||
this.lastFrameTests++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Close the frame: draw the queued contacts' queries. */
|
||||
endFrame() {
|
||||
const started = this._clock();
|
||||
if (this._queued.length > 0 && this._source && this.mode === 'async') {
|
||||
try {
|
||||
this._draw(this._source);
|
||||
} catch {
|
||||
this._release(this._queued);
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
this._queued.length = 0;
|
||||
this._source = undefined;
|
||||
this.lastFrameMs = this._spent + (this._clock() - started);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every test in flight but keep the GPU objects, so that switching the
|
||||
* overlay off and on again does not compile the shader a second time.
|
||||
*/
|
||||
reset() {
|
||||
const gl = this._gl;
|
||||
for (const batch of this._batches) this._release(batch.contacts);
|
||||
this._release(this._queued);
|
||||
try {
|
||||
if (gl && !gl.isContextLost?.()) {
|
||||
for (const batch of this._batches)
|
||||
for (let i = batch.cursor; i < batch.queries.length; i++)
|
||||
this._queryPool.push(batch.queries[i]);
|
||||
}
|
||||
} catch {
|
||||
// A context that is going away has already released them.
|
||||
}
|
||||
this._batches.length = 0;
|
||||
this._queued.length = 0;
|
||||
this._source = undefined;
|
||||
this._snapshot = null;
|
||||
this._snapshotAt = Number.NEGATIVE_INFINITY;
|
||||
this._controlFailures = 0;
|
||||
this._retryAt = Number.NEGATIVE_INFINITY;
|
||||
this.mode = 'off';
|
||||
}
|
||||
|
||||
/** Delete the GPU objects and drop every test in flight. */
|
||||
destroy() {
|
||||
this.reset();
|
||||
const gl = this._gl;
|
||||
try {
|
||||
if (gl && !gl.isContextLost?.()) {
|
||||
for (const query of this._queryPool) gl.deleteQuery(query);
|
||||
const resources = this._resources;
|
||||
if (resources) {
|
||||
gl.deleteProgram(resources.program);
|
||||
gl.deleteVertexArray(resources.vertexArray);
|
||||
gl.deleteBuffer(resources.buffer);
|
||||
gl.deleteFramebuffer(resources.framebuffer);
|
||||
gl.deleteTexture(resources.texture);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A context that is going away has already released them.
|
||||
}
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
this._gl = null;
|
||||
this._unsupportedGl = null;
|
||||
}
|
||||
|
||||
/** Tests that will never be answered stop counting as in flight. */
|
||||
_release(contacts) {
|
||||
for (const contact of contacts) if (contact) contact.pending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something in the query path broke: stand down for a while and keep
|
||||
* everything drawn, then try again in case the cause was passing.
|
||||
*/
|
||||
_fail() {
|
||||
this._retryAt = this._now + PROBE_RETRY_AFTER_MS;
|
||||
this._controlFailures = 0;
|
||||
this.mode = 'off';
|
||||
}
|
||||
|
||||
/** Read this frame's depth source, once, when the first contact asks. */
|
||||
_openSource() {
|
||||
this._source = null;
|
||||
if (this._now < this._retryAt) return;
|
||||
const source = this._readSource(this._scene, this._view);
|
||||
if (!source) {
|
||||
this.mode = 'off';
|
||||
return;
|
||||
}
|
||||
if (source.gl !== this._gl) {
|
||||
// A different context (a rebuilt viewer) orphans whatever was in flight.
|
||||
this.destroy();
|
||||
this._gl = source.gl;
|
||||
}
|
||||
try {
|
||||
if (source.gl.isContextLost?.()) {
|
||||
// Everything made on the lost context went with it.
|
||||
this.reset();
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
return;
|
||||
}
|
||||
if (source.gl === this._unsupportedGl) {
|
||||
// The shader did not build on this context: nothing is tested here.
|
||||
this.mode = 'off';
|
||||
return;
|
||||
}
|
||||
if (source.webgl2 && canQuery(source.gl)) {
|
||||
if (!this._resources && !this._createResources(source.gl)) {
|
||||
this._fail();
|
||||
return;
|
||||
}
|
||||
this.mode = 'async';
|
||||
} else {
|
||||
this.mode = 'sync';
|
||||
if (this._now - this._snapshotAt >= SYNC_READ_INTERVAL_MS) {
|
||||
this._snapshot = this._readSync(source);
|
||||
this._snapshotAt = this._now;
|
||||
}
|
||||
}
|
||||
this._source = source;
|
||||
} catch {
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the shader and make the 1x1 target. This is the one place the
|
||||
* probe does wait on the GPU process (compile, link and framebuffer status
|
||||
* are answered synchronously): once per WebGL context, on the first frame a
|
||||
* depth-tested contact is in view. `reset` keeps the result.
|
||||
*/
|
||||
_createResources(gl) {
|
||||
const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER);
|
||||
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
if (!vertex || !fragment) {
|
||||
if (vertex) gl.deleteShader(vertex);
|
||||
if (fragment) gl.deleteShader(fragment);
|
||||
// The same source will not compile on this context next time either.
|
||||
this._unsupportedGl = gl;
|
||||
return false;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertex);
|
||||
gl.attachShader(program, fragment);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertex);
|
||||
gl.deleteShader(fragment);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
this._unsupportedGl = gl;
|
||||
return false;
|
||||
}
|
||||
const uniform = (name) => gl.getUniformLocation(program, name);
|
||||
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
|
||||
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
|
||||
const previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);
|
||||
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
|
||||
|
||||
const vertexArray = gl.createVertexArray();
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindVertexArray(vertexArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(previousArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
|
||||
|
||||
const texture = gl.createTexture();
|
||||
const framebuffer = gl.createFramebuffer();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, previousTexture);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer);
|
||||
gl.framebufferTexture2D(
|
||||
gl.DRAW_FRAMEBUFFER,
|
||||
gl.COLOR_ATTACHMENT0,
|
||||
gl.TEXTURE_2D,
|
||||
texture,
|
||||
0,
|
||||
);
|
||||
const complete =
|
||||
gl.checkFramebufferStatus(gl.DRAW_FRAMEBUFFER) ===
|
||||
gl.FRAMEBUFFER_COMPLETE;
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
|
||||
if (!complete) {
|
||||
// Leave nothing half-made behind, so a later retry starts from scratch.
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteVertexArray(vertexArray);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteFramebuffer(framebuffer);
|
||||
gl.deleteTexture(texture);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._resources = {
|
||||
program,
|
||||
vertexArray,
|
||||
buffer,
|
||||
texture,
|
||||
framebuffer,
|
||||
depth: [0, 1, 2, 3].map((i) => uniform(`u_depth${i}`)),
|
||||
count: uniform('u_count'),
|
||||
logDepth: uniform('u_logDepth'),
|
||||
near: uniform('u_near'),
|
||||
far: uniform('u_far'),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one query per queued contact, preceded by a control point that must
|
||||
* always draw. The renderer caches the framebuffer, viewport and scissor
|
||||
* state it last set, so those are put back exactly as found, along with
|
||||
* every binding touched here. Depth, stencil, blending and the colour mask
|
||||
* need no care: the target has no depth or stencil attachment, and a query
|
||||
* counts samples whether or not they are written.
|
||||
*/
|
||||
_draw(source) {
|
||||
const gl = source.gl;
|
||||
const resources = this._resources;
|
||||
const contacts = this._queued.slice();
|
||||
const count = contacts.length + 1;
|
||||
const attributes = this._attributes;
|
||||
// The control point: mid-frame, with a threshold nothing can be under.
|
||||
attributes[0] = 0.5;
|
||||
attributes[1] = 0.5;
|
||||
attributes[2] = -1;
|
||||
const layers = source.textures.length;
|
||||
const frustums = source.frustums;
|
||||
|
||||
const previousProgram = gl.getParameter(gl.CURRENT_PROGRAM);
|
||||
const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
|
||||
const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING);
|
||||
const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING);
|
||||
const previousUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
|
||||
const previousViewport = gl.getParameter(gl.VIEWPORT);
|
||||
const previousTextures = [];
|
||||
const scissor = gl.isEnabled(gl.SCISSOR_TEST);
|
||||
const queries = [];
|
||||
const pool = this._queryPool;
|
||||
let open = false;
|
||||
try {
|
||||
for (let i = 0; i < layers; i++) {
|
||||
gl.activeTexture(gl.TEXTURE0 + i);
|
||||
previousTextures.push(gl.getParameter(gl.TEXTURE_BINDING_2D));
|
||||
gl.bindTexture(gl.TEXTURE_2D, source.textures[i]);
|
||||
}
|
||||
if (scissor) gl.disable(gl.SCISSOR_TEST);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resources.framebuffer);
|
||||
gl.viewport(0, 0, 1, 1);
|
||||
gl.useProgram(resources.program);
|
||||
// Samplers past the ranges in use point at the first range's texture:
|
||||
// every sampler a program declares must be drawable, read or not.
|
||||
for (let i = 0; i < PROBE_MAX_FRUSTUMS; i++)
|
||||
gl.uniform1i(resources.depth[i], i < layers ? i : 0);
|
||||
gl.uniform1i(resources.count, layers);
|
||||
gl.uniform1i(resources.logDepth, source.logDepth ? 1 : 0);
|
||||
gl.uniform4f(
|
||||
resources.near,
|
||||
frustums[0].near,
|
||||
frustums[1]?.near ?? 1,
|
||||
frustums[2]?.near ?? 1,
|
||||
frustums[3]?.near ?? 1,
|
||||
);
|
||||
gl.uniform4f(
|
||||
resources.far,
|
||||
frustums[0].far,
|
||||
frustums[1]?.far ?? 2,
|
||||
frustums[2]?.far ?? 2,
|
||||
frustums[3]?.far ?? 2,
|
||||
);
|
||||
gl.bindVertexArray(resources.vertexArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, resources.buffer);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
attributes.subarray(0, count * 3),
|
||||
gl.STREAM_DRAW,
|
||||
);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const query = pool.pop() || gl.createQuery();
|
||||
queries.push(query);
|
||||
gl.beginQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE, query);
|
||||
open = true;
|
||||
gl.drawArrays(gl.POINTS, i, 1);
|
||||
gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
|
||||
open = false;
|
||||
}
|
||||
} catch (error) {
|
||||
// Leave no query open and none unaccounted for; the caller stands down.
|
||||
try {
|
||||
if (open) gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE);
|
||||
for (const query of queries) gl.deleteQuery(query);
|
||||
} catch {
|
||||
// Nothing more can be done for them.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer);
|
||||
gl.bindVertexArray(previousArray);
|
||||
gl.useProgram(previousProgram);
|
||||
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw);
|
||||
if (previousViewport)
|
||||
gl.viewport(
|
||||
previousViewport[0],
|
||||
previousViewport[1],
|
||||
previousViewport[2],
|
||||
previousViewport[3],
|
||||
);
|
||||
if (scissor) gl.enable(gl.SCISSOR_TEST);
|
||||
for (let i = previousTextures.length - 1; i >= 0; i--) {
|
||||
gl.activeTexture(gl.TEXTURE0 + i);
|
||||
gl.bindTexture(gl.TEXTURE_2D, previousTextures[i]);
|
||||
}
|
||||
gl.activeTexture(previousUnit);
|
||||
}
|
||||
|
||||
this._batches.push({
|
||||
queries,
|
||||
contacts,
|
||||
at: this._now,
|
||||
frame: this._frame,
|
||||
cursor: 0,
|
||||
control: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take in whatever answers have arrived, oldest batch first, without
|
||||
* waiting for any that have not. An answer is dated to the frame its test
|
||||
* was drawn on, so one that arrives late (the overlay was not painting for
|
||||
* a while) is as stale as it is old.
|
||||
*/
|
||||
_collect() {
|
||||
const batches = this._batches;
|
||||
if (batches.length === 0) return;
|
||||
const gl = this._gl;
|
||||
try {
|
||||
if (gl.isContextLost?.()) {
|
||||
// Queries made on a lost context are gone with it.
|
||||
this.reset();
|
||||
this._queryPool.length = 0;
|
||||
this._resources = null;
|
||||
return;
|
||||
}
|
||||
while (batches.length > 0) {
|
||||
const batch = batches[0];
|
||||
const { queries, contacts } = batch;
|
||||
while (batch.cursor < queries.length) {
|
||||
const query = queries[batch.cursor];
|
||||
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
|
||||
const drew = !!gl.getQueryParameter(query, gl.QUERY_RESULT);
|
||||
if (batch.cursor === 0) {
|
||||
batch.control = drew;
|
||||
} else {
|
||||
const contact = contacts[batch.cursor - 1];
|
||||
contact.pending = false;
|
||||
// Without the control point's yes the batch means nothing: not a
|
||||
// "hidden", and not a first answer that ends a new contact's
|
||||
// hold-back either.
|
||||
if (batch.control) {
|
||||
contact.answered = true;
|
||||
if (!drew && batch.at > contact.lastHiddenAt)
|
||||
contact.lastHiddenAt = batch.at;
|
||||
}
|
||||
}
|
||||
this._queryPool.push(query);
|
||||
batch.cursor++;
|
||||
}
|
||||
if (batch.cursor < queries.length) break;
|
||||
batches.shift();
|
||||
if (batch.control) {
|
||||
this._controlFailures = 0;
|
||||
} else if (++this._controlFailures >= PROBE_MAX_CONTROL_FAILURES) {
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
// Whatever is still unanswered after too many frames is dropped,
|
||||
// wherever it stands in the queue, so its contacts can be tested again.
|
||||
const overdue = this._frame - PROBE_MAX_PENDING_FRAMES;
|
||||
for (let i = batches.length - 1; i >= 0; i--) {
|
||||
const batch = batches[i];
|
||||
if (batch.frame >= overdue) continue;
|
||||
for (let k = batch.cursor; k < batch.queries.length; k++)
|
||||
gl.deleteQuery(batch.queries[k]);
|
||||
for (
|
||||
let k = Math.max(0, batch.cursor - 1);
|
||||
k < batch.contacts.length;
|
||||
k++
|
||||
)
|
||||
batch.contacts[k].pending = false;
|
||||
batches.splice(i, 1);
|
||||
}
|
||||
} catch {
|
||||
for (const batch of batches) this._release(batch.contacts);
|
||||
batches.length = 0;
|
||||
this._fail();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No queries (WebGL 1): read each depth range at full size and wait for it.
|
||||
* Costs milliseconds per read; used only where nothing better exists.
|
||||
*/
|
||||
_readSync(source) {
|
||||
const gl = source.gl;
|
||||
const { width, height } = source;
|
||||
const layers = source.framebuffers.length;
|
||||
const layerBytes = width * height * 4;
|
||||
const byteLength = layerBytes * layers;
|
||||
const bytes =
|
||||
this._snapshot?.bytes.length === byteLength
|
||||
? this._snapshot.bytes
|
||||
: new Uint8Array(byteLength);
|
||||
const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING);
|
||||
for (let i = 0; i < layers; i++) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, source.framebuffers[i]);
|
||||
gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
bytes.subarray(i * layerBytes, (i + 1) * layerBytes),
|
||||
);
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
|
||||
return new DepthSnapshot({
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
frustums: source.frustums,
|
||||
logDepth: source.logDepth,
|
||||
// The source's matrix is a scratch array the next frame overwrites.
|
||||
view: Float64Array.from(source.view),
|
||||
tanHalfFovX: source.tanHalfFovX,
|
||||
tanHalfFovY: source.tanHalfFovY,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
PROBE_MAX_CONTROL_FAILURES,
|
||||
PROBE_MAX_FRUSTUMS,
|
||||
PROBE_MAX_PENDING_FRAMES,
|
||||
PROBE_MAX_PER_FRAME,
|
||||
PROBE_RETRY_AFTER_MS,
|
||||
PROBE_VERTEX_SHADER,
|
||||
SYNC_READ_INTERVAL_MS,
|
||||
SceneOcclusionProbe,
|
||||
readSceneDepthSource,
|
||||
} from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createFakeDepthScene,
|
||||
createFakeGl,
|
||||
depthImage,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
const NEAR = 1;
|
||||
const FAR = 100_000;
|
||||
|
||||
/** Left half a wall 400 m away, right half ground 500 m away. */
|
||||
function wallImage(width, height, logDepth = true, wall = 400) {
|
||||
return depthImage(width, height, (column) =>
|
||||
viewDistanceToDepth(column < width / 2 ? wall : 500, NEAR, FAR, logDepth),
|
||||
);
|
||||
}
|
||||
|
||||
function wallScene({ width = 64, height = 32, logDepth = true, gl } = {}) {
|
||||
return createFakeDepthScene({
|
||||
gl,
|
||||
logDepth,
|
||||
aspectRatio: width / height,
|
||||
ranges: [
|
||||
{ near: NEAR, far: FAR, image: wallImage(width, height, logDepth) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const behindWall = { x: -250, y: 0, z: -500 };
|
||||
const onStreet = { x: 250, y: 0, z: -500 };
|
||||
const contact = () => ({
|
||||
lastHiddenAt: Number.NEGATIVE_INFINITY,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
/** One frame: collect, queue the given contacts, draw. */
|
||||
function frame(probe, scene, now, tests = []) {
|
||||
probe.beginFrame(scene, now);
|
||||
const accepted = tests.map(([entry, position]) => probe.add(entry, position));
|
||||
probe.endFrame();
|
||||
return accepted;
|
||||
}
|
||||
|
||||
test('a test is drawn at once and answered on a later frame, without anything being read back', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 1000, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
]),
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(probe.mode, 'async');
|
||||
assert.equal(probe.lastFrameTests, 2);
|
||||
assert.equal(hidden.pending, true);
|
||||
assert.equal(
|
||||
gl.counts.queries,
|
||||
3,
|
||||
'the control point and one query per contact',
|
||||
);
|
||||
assert.equal(gl.counts.draws, 3);
|
||||
|
||||
// Frames pass before the GPU has answered: nothing changes, nothing waits.
|
||||
frame(probe, scene, 1016);
|
||||
frame(probe, scene, 1032);
|
||||
assert.equal(hidden.pending, true);
|
||||
assert.equal(hidden.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.counts.resultReads, 0);
|
||||
assert.equal(gl.counts.readPixels, 0);
|
||||
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 1048);
|
||||
assert.equal(hidden.pending, false);
|
||||
assert.equal(shown.pending, false);
|
||||
assert.equal(hidden.answered, true);
|
||||
assert.equal(shown.answered, true);
|
||||
assert.equal(hidden.lastHiddenAt, 1000, 'dated to its test frame');
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.counts.readPixels, 0, 'depth never leaves the GPU');
|
||||
});
|
||||
|
||||
test('queries are reused, and every depth range is tested nearest first', () => {
|
||||
const width = 64;
|
||||
const height = 32;
|
||||
const farNear = 900;
|
||||
const farFar = 5_000_000;
|
||||
const scene = createFakeDepthScene({
|
||||
aspectRatio: 2,
|
||||
ranges: [
|
||||
// Near range: the wall on the left, nothing drawn on the right.
|
||||
{
|
||||
near: NEAR,
|
||||
far: 1000,
|
||||
image: depthImage(width, height, (column) =>
|
||||
column < width / 2 ? viewDistanceToDepth(400, NEAR, 1000, true) : 1,
|
||||
),
|
||||
},
|
||||
// Far range: ground 20 km away, drawn with its near plane pulled in.
|
||||
{
|
||||
near: farNear,
|
||||
far: farFar,
|
||||
image: depthImage(width, height, () =>
|
||||
viewDistanceToDepth(20_000, farNear * 0.9999, farFar, true),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const cases = [
|
||||
[contact(), { x: -7500, y: 0, z: -15_000 }, true], // behind the near wall
|
||||
[contact(), { x: 7500, y: 0, z: -15_000 }, false], // above the far ground
|
||||
[contact(), { x: 15_000, y: 0, z: -30_000 }, true], // under the far ground
|
||||
[contact(), { x: -150, y: 0, z: -300 }, false], // in front of the wall
|
||||
];
|
||||
for (let round = 0; round < 3; round++) {
|
||||
frame(
|
||||
probe,
|
||||
scene,
|
||||
round * 100,
|
||||
cases.map(([entry, position]) => [entry, position]),
|
||||
);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, round * 100 + 16);
|
||||
for (const [entry, , hidden] of cases) {
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt === round * 100, hidden);
|
||||
}
|
||||
}
|
||||
assert.equal(gl.liveQueries.size, 5, 'five queries serve every round');
|
||||
const source = readSceneDepthSource(scene);
|
||||
assert.equal(source.frustums[1].near, farNear * 0.9999);
|
||||
});
|
||||
|
||||
test('standard depth scenes are tested too', () => {
|
||||
const scene = wallScene({ logDepth: false });
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
frame(probe, scene, 0, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
]);
|
||||
scene.context._gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(hidden.lastHiddenAt, 0);
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('every binding the renderer relies on is left as found', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const found = {
|
||||
read: { image: null },
|
||||
draw: { image: null },
|
||||
units: [{}, {}, {}, {}, {}, {}, {}, {}],
|
||||
unit: 5,
|
||||
array: {},
|
||||
vertexArray: {},
|
||||
program: { uniforms: {} },
|
||||
scissor: true,
|
||||
viewport: [12, 34, 640, 480],
|
||||
};
|
||||
Object.assign(gl.state, found, { units: [...found.units] });
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
frame(probe, scene, 0, [[hidden, behindWall]]);
|
||||
for (const key of [
|
||||
'read',
|
||||
'draw',
|
||||
'unit',
|
||||
'array',
|
||||
'vertexArray',
|
||||
'program',
|
||||
'scissor',
|
||||
])
|
||||
assert.equal(gl.state[key], found[key], key);
|
||||
assert.deepEqual(gl.state.units, found.units);
|
||||
assert.deepEqual(gl.state.viewport, [12, 34, 640, 480]);
|
||||
// The scissor test and the offset viewport would both have kept the points
|
||||
// off the target, so neither was in force for them: the control point drew,
|
||||
// and the answer counts.
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(hidden.lastHiddenAt, 0);
|
||||
assert.equal(gl.state.scissor, true);
|
||||
});
|
||||
|
||||
test('a contact outside the frame, or past the frame budget, is not taken', () => {
|
||||
const scene = wallScene();
|
||||
const probe = new SceneOcclusionProbe();
|
||||
probe.beginFrame(scene, 0);
|
||||
const outside = contact();
|
||||
assert.equal(probe.add(outside, { x: -1100, y: 0, z: -500 }), false);
|
||||
assert.equal(probe.add(outside, { x: 0, y: 0, z: 500 }), false);
|
||||
assert.equal(outside.pending, false);
|
||||
const taken = [];
|
||||
for (let i = 0; i < PROBE_MAX_PER_FRAME + 10; i++)
|
||||
taken.push(probe.add(contact(), onStreet));
|
||||
probe.endFrame();
|
||||
assert.equal(taken.filter(Boolean).length, PROBE_MAX_PER_FRAME);
|
||||
assert.equal(taken.lastIndexOf(true), PROBE_MAX_PER_FRAME - 1);
|
||||
assert.equal(scene.context._gl.counts.queries, PROBE_MAX_PER_FRAME + 1);
|
||||
});
|
||||
|
||||
test('a frame with nothing to test touches nothing', () => {
|
||||
let reads = 0;
|
||||
const probe = new SceneOcclusionProbe({
|
||||
readSource: () => {
|
||||
reads++;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
frame(probe, {}, 0);
|
||||
frame(probe, {}, 16);
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(probe.lastFrameTests, 0);
|
||||
});
|
||||
|
||||
test('no depth source: nothing is tested and nothing is in flight', () => {
|
||||
const broken = [
|
||||
null,
|
||||
{},
|
||||
{ ...wallScene(), mode: Cesium.SceneMode.SCENE2D },
|
||||
{ ...wallScene(), mode: Cesium.SceneMode.COLUMBUS_VIEW },
|
||||
{ ...wallScene(), useDepthPicking: false },
|
||||
{ ...wallScene(), _environmentState: { useGlobeDepthFramebuffer: false } },
|
||||
{ ...wallScene(), view: { frustumCommandsList: [], pickDepths: [] } },
|
||||
{ ...wallScene(), view: undefined },
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.context.depthTexture = false;
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.camera.frustum = { width: 1000, aspectRatio: 2 }; // orthographic
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.camera.frustum.xOffset = 10;
|
||||
return scene;
|
||||
})(),
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet
|
||||
return scene;
|
||||
})(),
|
||||
// Whether depth is logarithmic, unknown: decoding it the wrong way would
|
||||
// put every surface in front of every contact.
|
||||
{ ...wallScene(), frameState: {} },
|
||||
{ ...wallScene(), frameState: { useLogDepth: 1 } },
|
||||
{ ...wallScene(), frameState: undefined },
|
||||
(() => {
|
||||
const scene = wallScene();
|
||||
Object.defineProperty(scene, 'view', {
|
||||
get() {
|
||||
throw new Error('renderer changed shape');
|
||||
},
|
||||
});
|
||||
return scene;
|
||||
})(),
|
||||
];
|
||||
for (const scene of broken) {
|
||||
assert.equal(readSceneDepthSource(scene), null);
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(entry.pending, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('depth that comes and goes is picked up frame by frame', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
scene.useDepthPicking = false;
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
scene.useDepthPicking = true;
|
||||
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [true]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 32);
|
||||
assert.equal(entry.lastHiddenAt, 16);
|
||||
});
|
||||
|
||||
test('depth ranges past the limit are left out, nearest kept', () => {
|
||||
const image = wallImage(8, 8);
|
||||
const ranges = [];
|
||||
for (let i = 0; i < PROBE_MAX_FRUSTUMS + 2; i++)
|
||||
ranges.push({ near: 10 ** i, far: 10 ** (i + 1), image });
|
||||
const source = readSceneDepthSource(createFakeDepthScene({ ranges }));
|
||||
assert.equal(source.frustums.length, PROBE_MAX_FRUSTUMS);
|
||||
assert.equal(source.textures.length, PROBE_MAX_FRUSTUMS);
|
||||
assert.equal(source.frustums[0].near, 1);
|
||||
});
|
||||
|
||||
test('a control point that does not draw voids its batch; a run of them stands the probe down for a while', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
// Something outside the probe's reach stops every point from drawing, so
|
||||
// every query reads "nothing drew", which would otherwise mean "hidden".
|
||||
gl.state.drawsLand = false;
|
||||
let now = 0;
|
||||
for (let i = 0; i < PROBE_MAX_CONTROL_FAILURES; i++) {
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [true]);
|
||||
gl.finishGpu();
|
||||
now += 16;
|
||||
frame(probe, scene, now);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed');
|
||||
assert.notEqual(entry.answered, true, 'and not a first answer either');
|
||||
now += 16;
|
||||
}
|
||||
assert.equal(probe.mode, 'off');
|
||||
const stoodDownAt = now - 16;
|
||||
assert.deepEqual(frame(probe, scene, now, [[contact(), behindWall]]), [
|
||||
false,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, stoodDownAt + PROBE_RETRY_AFTER_MS - 1, [
|
||||
[contact(), behindWall],
|
||||
]),
|
||||
[false],
|
||||
);
|
||||
// The cause has passed: the probe picks up where it left off.
|
||||
gl.state.drawsLand = true;
|
||||
const entry = contact();
|
||||
const retryAt = stoodDownAt + PROBE_RETRY_AFTER_MS;
|
||||
assert.deepEqual(frame(probe, scene, retryAt, [[entry, behindWall]]), [true]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, retryAt + 16);
|
||||
assert.equal(entry.lastHiddenAt, retryAt);
|
||||
assert.equal(entry.answered, true);
|
||||
|
||||
// One good batch in between resets the count.
|
||||
const healthy = new SceneOcclusionProbe();
|
||||
const other = wallScene();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
other.context._gl.state.drawsLand = i % 2 === 1;
|
||||
frame(healthy, other, i * 32, [[contact(), onStreet]]);
|
||||
other.context._gl.finishGpu();
|
||||
frame(healthy, other, i * 32 + 16);
|
||||
}
|
||||
assert.equal(healthy.mode, 'async');
|
||||
});
|
||||
|
||||
test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => {
|
||||
let scene = wallScene();
|
||||
// Only the fragment shader fails: the vertex shader that did compile must
|
||||
// not be left behind.
|
||||
scene.context._gl.state.compiles = (type) =>
|
||||
type !== scene.context._gl.FRAGMENT_SHADER;
|
||||
let probe = new SceneOcclusionProbe();
|
||||
let entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(entry.pending, false);
|
||||
// Nothing half-made is kept, and the same context is never asked again:
|
||||
// compiling is a wait on the GPU process and would fail the same way.
|
||||
let gl = scene.context._gl;
|
||||
assert.equal(gl.liveShaders.size, 0, 'no shader left behind');
|
||||
let shaderCalls = 0;
|
||||
const createShader = gl.createShader;
|
||||
gl.createShader = (...args) => {
|
||||
shaderCalls++;
|
||||
return createShader(...args);
|
||||
};
|
||||
for (const now of [PROBE_RETRY_AFTER_MS + 16, 5 * PROBE_RETRY_AFTER_MS])
|
||||
assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [false]);
|
||||
assert.equal(shaderCalls, 0);
|
||||
assert.equal(probe.mode, 'off');
|
||||
|
||||
// A draw that throws part-way: bindings restored, no query left behind.
|
||||
scene = wallScene();
|
||||
gl = scene.context._gl;
|
||||
const found = {
|
||||
draw: { image: null },
|
||||
program: { uniforms: {} },
|
||||
viewport: [0, 0, 800, 600],
|
||||
};
|
||||
Object.assign(gl.state, found, { scissor: true });
|
||||
let draws = 0;
|
||||
const drawArrays = gl.drawArrays;
|
||||
gl.drawArrays = (...args) => {
|
||||
if (++draws === 2) throw new Error('driver');
|
||||
return drawArrays(...args);
|
||||
};
|
||||
probe = new SceneOcclusionProbe();
|
||||
entry = contact();
|
||||
const other = contact();
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 0, [
|
||||
[entry, behindWall],
|
||||
[other, onStreet],
|
||||
]),
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(entry.pending, false, 'released when the draw failed');
|
||||
assert.equal(other.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.state.draw, found.draw);
|
||||
assert.equal(gl.state.program, found.program);
|
||||
assert.deepEqual(gl.state.viewport, found.viewport);
|
||||
assert.equal(gl.state.scissor, true);
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [false]);
|
||||
|
||||
scene = wallScene();
|
||||
gl = scene.context._gl;
|
||||
probe = new SceneOcclusionProbe();
|
||||
entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.getQueryParameter = () => {
|
||||
throw new Error('driver');
|
||||
};
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
});
|
||||
|
||||
test('a frame that never reaches its end leaves no contact waiting for an answer', () => {
|
||||
const scene = wallScene();
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
probe.beginFrame(scene, 0);
|
||||
assert.equal(probe.add(entry, behindWall), true);
|
||||
assert.equal(entry.pending, true);
|
||||
// The painter threw before endFrame; the next frame starts clean.
|
||||
probe.beginFrame(scene, 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.add(entry, behindWall), true);
|
||||
probe.endFrame();
|
||||
assert.equal(
|
||||
scene.context._gl.counts.queries,
|
||||
2,
|
||||
'only the second frame drew',
|
||||
);
|
||||
});
|
||||
|
||||
test('an answer collected late is dated to its test, not to its arrival', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 1000, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
// The overlay paints nothing for 20 seconds, then resumes.
|
||||
frame(probe, scene, 21_000);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, 1000);
|
||||
// An older answer never overwrites a newer one.
|
||||
entry.lastHiddenAt = 30_000;
|
||||
frame(probe, scene, 22_000, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 22_016);
|
||||
assert.equal(entry.lastHiddenAt, 30_000);
|
||||
});
|
||||
|
||||
test('samplers for depth ranges not in use point at a texture that is bound', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
// Whatever the renderer left on the other units is not the probe's to read.
|
||||
gl.state.units = [null, null, null, null, null, null, null, null];
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(
|
||||
entry.lastHiddenAt,
|
||||
0,
|
||||
'the draw was valid, so the answer counts',
|
||||
);
|
||||
assert.deepEqual(gl.state.units, [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('reset drops tests in flight but keeps the GPU objects for next time', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
probe.reset();
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.counts.deleted, 0);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 16);
|
||||
assert.equal(
|
||||
entry.lastHiddenAt,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
'the dropped answer is not applied',
|
||||
);
|
||||
frame(probe, scene, 32, [[entry, behindWall]]);
|
||||
assert.equal(probe.mode, 'async');
|
||||
assert.equal(gl.liveQueries.size, 2, 'queries are reused, not recreated');
|
||||
});
|
||||
|
||||
test('a lost context drops what was in flight and starts over when it returns', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
gl.state.lost = true;
|
||||
assert.deepEqual(frame(probe, scene, 16, [[contact(), behindWall]]), [false]);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(probe.mode, 'off');
|
||||
gl.state.lost = false;
|
||||
assert.deepEqual(frame(probe, scene, 32, [[entry, behindWall]]), [true]);
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, 48);
|
||||
assert.equal(entry.lastHiddenAt, 32);
|
||||
});
|
||||
|
||||
test('queries that are never answered are abandoned', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
frame(probe, scene, 0, [[entry, behindWall]]);
|
||||
for (let i = 1; i <= PROBE_MAX_PENDING_FRAMES; i++)
|
||||
frame(probe, scene, i * 16);
|
||||
assert.equal(entry.pending, true);
|
||||
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + 1) * 16);
|
||||
assert.equal(entry.pending, false);
|
||||
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
assert.equal(probe.mode, 'async', 'and the next contact is tested as usual');
|
||||
});
|
||||
|
||||
test('every batch times out on its own, however many are queued ahead of it', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entries = [];
|
||||
// Ten frames each start a batch; the GPU answers none of them.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const entry = contact();
|
||||
entries.push(entry);
|
||||
frame(probe, scene, i * 16, [[entry, behindWall]]);
|
||||
}
|
||||
for (let i = 10; i <= PROBE_MAX_PENDING_FRAMES; i++)
|
||||
frame(probe, scene, i * 16);
|
||||
assert.ok(entries.every((entry) => entry.pending));
|
||||
// Ten more frames and all ten have been dropped, not one every 120 frames.
|
||||
for (let i = 1; i <= 10; i++)
|
||||
frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + i) * 16);
|
||||
assert.ok(entries.every((entry) => !entry.pending));
|
||||
assert.equal(gl.liveQueries.size, 0);
|
||||
});
|
||||
|
||||
test('a target that cannot be made leaves nothing behind, and the next try starts from scratch', () => {
|
||||
const scene = wallScene();
|
||||
const gl = scene.context._gl;
|
||||
const complete = gl.checkFramebufferStatus;
|
||||
gl.checkFramebufferStatus = () => 0;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const entry = contact();
|
||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||
assert.equal(probe.mode, 'off');
|
||||
assert.equal(gl.counts.deleted, 5, 'program, array, buffer, target');
|
||||
// Memory pressure passes; after the stand-down the probe builds it all again.
|
||||
gl.checkFramebufferStatus = complete;
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, PROBE_RETRY_AFTER_MS, [[entry, behindWall]]),
|
||||
[true],
|
||||
);
|
||||
assert.equal(probe.mode, 'async');
|
||||
gl.finishGpu();
|
||||
frame(probe, scene, PROBE_RETRY_AFTER_MS + 16);
|
||||
assert.equal(entry.lastHiddenAt, PROBE_RETRY_AFTER_MS);
|
||||
});
|
||||
|
||||
test('a new WebGL context, or destroy, releases everything', () => {
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const first = wallScene();
|
||||
const entry = contact();
|
||||
frame(probe, first, 0, [[entry, behindWall]]);
|
||||
const second = wallScene();
|
||||
const again = contact();
|
||||
assert.deepEqual(frame(probe, second, 16, [[again, behindWall]]), [true]);
|
||||
assert.equal(entry.pending, false, 'the first context will never answer');
|
||||
assert.equal(first.context._gl.liveQueries.size, 0);
|
||||
assert.equal(
|
||||
first.context._gl.counts.deleted,
|
||||
5,
|
||||
'program, array, buffer, target',
|
||||
);
|
||||
probe.destroy();
|
||||
assert.equal(again.pending, false);
|
||||
assert.equal(second.context._gl.liveQueries.size, 0);
|
||||
assert.equal(probe.mode, 'off');
|
||||
});
|
||||
|
||||
test('without queries (WebGL 1) depth is read back synchronously, at most every interval, and the mode says so', () => {
|
||||
const gl = createFakeGl({ webgl2: false });
|
||||
const scene = wallScene({ gl });
|
||||
assert.equal(scene.context.webgl2, false);
|
||||
const sceneFramebuffer = { image: null };
|
||||
gl.state.read = sceneFramebuffer;
|
||||
gl.state.draw = sceneFramebuffer;
|
||||
const probe = new SceneOcclusionProbe();
|
||||
const hidden = contact();
|
||||
const shown = contact();
|
||||
assert.deepEqual(
|
||||
frame(probe, scene, 1000, [
|
||||
[hidden, behindWall],
|
||||
[shown, onStreet],
|
||||
[contact(), { x: -1100, y: 0, z: -500 }],
|
||||
]),
|
||||
[true, true, false],
|
||||
);
|
||||
assert.equal(probe.mode, 'sync');
|
||||
assert.equal(gl.counts.readPixels, 1);
|
||||
assert.equal(gl.state.read, sceneFramebuffer);
|
||||
assert.equal(gl.state.draw, sceneFramebuffer);
|
||||
assert.equal(hidden.lastHiddenAt, 1000, 'answered on the spot');
|
||||
assert.equal(hidden.pending, false);
|
||||
assert.equal(shown.answered, true);
|
||||
assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY);
|
||||
// Frames inside the interval answer from the read already made.
|
||||
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS - 1, [[hidden, behindWall]]);
|
||||
assert.equal(gl.counts.readPixels, 1);
|
||||
assert.equal(hidden.lastHiddenAt, 1000 + SYNC_READ_INTERVAL_MS - 1);
|
||||
frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS, [[hidden, behindWall]]);
|
||||
assert.equal(gl.counts.readPixels, 2);
|
||||
});
|
||||
|
||||
test('the shader decodes depth the way the CPU decode does', () => {
|
||||
// The fake context stands in for the shader, so pin the parts of the source
|
||||
// the two must agree on: the unpack weights, both depth formulas, the
|
||||
// nearest-range-first order and the comparison.
|
||||
const source = PROBE_VERTEX_SHADER.replace(/\s+/g, ' ');
|
||||
assert.match(
|
||||
source,
|
||||
/vec4\(1\.0, 1\.0 \/ 255\.0, 1\.0 \/ 65025\.0, 1\.0 \/ 16581375\.0\)/,
|
||||
);
|
||||
assert.match(source, /if \(depth <= 0\.0 \|\| depth >= 1\.0\) return -1\.0;/);
|
||||
assert.match(
|
||||
source,
|
||||
/near \+ exp2\(depth \* log2\(far - near \+ 1\.0\)\) - 1\.0/,
|
||||
);
|
||||
assert.match(source, /near \* far \/ \(far - depth \* \(far - near\)\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/bool hidden = surface > 0\.0 && surface < a_contact\.z;/,
|
||||
);
|
||||
assert.ok(
|
||||
source.indexOf('u_depth0, u_near.x') < source.indexOf('u_depth1, u_near.y'),
|
||||
);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// Only MMSI-keyed cards can resolve back to one actionable vessel.
|
||||
// Only keyed cards can resolve back to one actionable vessel.
|
||||
interactive: card?.actionable === true,
|
||||
};
|
||||
}
|
||||
@@ -982,6 +982,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
// show_street_level (street-level photos) is a later, separate tool.
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) =>
|
||||
![
|
||||
@@ -989,6 +990,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'resolve_area',
|
||||
'find_imagery',
|
||||
'osm_query',
|
||||
'show_street_level',
|
||||
].includes(tool.name),
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
|
||||
@@ -251,7 +251,12 @@ test('mint locks canonical action schemas and server instructions with no perman
|
||||
|
||||
test('missing key and malformed model fail without an upstream request', async (t) => {
|
||||
const missing = await fixture(t, { resolveApiKey: () => '' });
|
||||
assert.equal((await missing.send()).status, 503);
|
||||
const missingResponse = await missing.send();
|
||||
assert.equal(missingResponse.status, 503);
|
||||
assert.deepEqual(await missingResponse.json(), {
|
||||
error: 'Add a Gemini API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(missing.calls.length, 0);
|
||||
for (const model of [
|
||||
'',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -150,7 +150,10 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
const firstToken = await invokeRoute(token);
|
||||
const secondToken = await invokeRoute(token);
|
||||
assert.equal(firstToken.statusCode, 503);
|
||||
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
|
||||
assert.deepEqual(firstToken.body, {
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(secondToken.statusCode, 429);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
|
||||
@@ -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,10 +22,12 @@ 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' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import sv from './locales/sv.json' with { type: 'json' };
|
||||
import uk from './locales/uk.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
@@ -35,10 +37,12 @@ const CATALOGS = Object.freeze({
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
he,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
sv,
|
||||
uk,
|
||||
'zh-CN': zhCN,
|
||||
'zh-Hant': zhHant,
|
||||
|
||||
+8
-2
@@ -14,10 +14,12 @@ export const LOCALES = Object.freeze([
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'he',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'sv',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
@@ -30,17 +32,19 @@ export const LOCALE_NAMES = Object.freeze({
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
he: 'עברית',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
ru: 'Русский',
|
||||
sv: 'Svenska',
|
||||
uk: 'Українська',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
});
|
||||
|
||||
/** 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 +66,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 +83,7 @@ export function normalizeLocale(candidate) {
|
||||
? 'zh-Hant'
|
||||
: 'zh-CN';
|
||||
}
|
||||
if (primary === 'iw') return 'he';
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
|
||||
+202
-6
@@ -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",
|
||||
@@ -1787,6 +1889,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "لم يُصفِّ KartaView هذه المنطقة في الوقت المحدد — حرّك الخريطة لإعادة الطلب",
|
||||
"None of KartaView’s photos here pass the imagery filter": "لا تجتاز أي من صور KartaView هنا مرشح الصور",
|
||||
"KartaView did not answer in time — try again": "لم يستجب KartaView في الوقت المحدد — أعد المحاولة",
|
||||
"Mapillary did not answer in time — try again": "لم يستجب Mapillary في الوقت المحدد — أعد المحاولة",
|
||||
"KartaView could not be reached — check the network": "تعذّر الوصول إلى KartaView — تحقّق من الشبكة",
|
||||
"KartaView sent an answer that could not be read": "أرسل KartaView استجابة تعذّرت قراءتها",
|
||||
"KartaView is rate-limiting requests": "يقيّد KartaView معدل الطلبات",
|
||||
@@ -2284,7 +2387,7 @@
|
||||
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · استعلام الموقع · حديث",
|
||||
"{source} · LAST {hours} H": "{source} · آخر {hours} س",
|
||||
"Rendered globe frames per second · toggle with `": "إطارات الكرة الأرضية المعروضة في الثانية · بدّل بالمفتاح `",
|
||||
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "تعذّر تحديث ارتفاعات الأرض. أعد استيراد الملف بعد تحميل الخريطة.",
|
||||
"{count} map objects": {
|
||||
@@ -2574,8 +2677,6 @@
|
||||
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
|
||||
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
|
||||
"Shared satellite is unavailable": "القمر الصناعي المشترك غير متاح",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}: لا بيانات واردة الآن — يُعرض الموضع عند {time}",
|
||||
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
|
||||
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
|
||||
"subject": "الهدف",
|
||||
@@ -3511,6 +3612,62 @@
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "آخر 24 ساعة من الأخبار. يرتبط كل تقرير بناشره؛ تُستخرج الأماكن والفئات من النص والبيانات تلقائيًا وقد تكون خاطئة. تصف تسميات الأطراف، حيث تظهر، المصدر لا صحة التقرير.",
|
||||
"Economy": "اقتصاد",
|
||||
"News from {area}": "أخبار: {area}",
|
||||
"Within {km} km of the view centre": "ضمن {km} كم من مركز العرض",
|
||||
"{region}, {country}": "{region}، {country}",
|
||||
"via {source}": "عبر {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "المحلي في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض",
|
||||
"{provider} refused its key": "رفض {provider} مفتاحه",
|
||||
"Check {envVars} in Provider Settings": "تحقق من {envVars} في «إعدادات المزوّدين»",
|
||||
"{provider} key required": "مفتاح {provider} مطلوب",
|
||||
"{provider} limit reached for today": "بلغ {provider} حدّه لهذا اليوم",
|
||||
"News resumes tomorrow, or with a paid plan": "تُستأنف الأخبار غدًا، أو مع خطة مدفوعة",
|
||||
"News key required": "مفتاح الأخبار مطلوب",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "نقاط نزاع دقيقة في طبقة الأخبار، إلى جانب مقالات GDELT",
|
||||
"Worldwide": "العالم",
|
||||
"Shipwrecks": "حطام السفن",
|
||||
"Before 1900": "قبل 1900",
|
||||
"After 1945": "بعد 1945",
|
||||
"Date unknown": "التاريخ غير معروف",
|
||||
"Position: centre of the protected area": "الموقع: مركز المنطقة المحمية",
|
||||
"Position approximate (within about {km} km)": "الموقع تقريبي (في حدود {km} كم تقريبًا)",
|
||||
"Position surveyed (within about {m} m)": "الموقع مُسَح (في حدود {m} م تقريبًا)",
|
||||
"Vessel: {value}": "السفينة: {value}",
|
||||
"What happened: {value}": "ما حدث: {value}",
|
||||
"Depth: {m} m ({quality})": "العمق: {m} م ({quality})",
|
||||
"Depth: {m} m": "العمق: {m} م",
|
||||
"Notes: {value}": "ملاحظات: {value}",
|
||||
"Cargo or finds: {value}": "الحمولة أو المكتشفات: {value}",
|
||||
"Protected wreck": "حطام محمي",
|
||||
"Source: {value}": "المصدر: {value}",
|
||||
"Click the card to open EMODnet ↗": "انقر على البطاقة لفتح EMODnet ↗",
|
||||
"Click the card to open the source ↗": "انقر على البطاقة لفتح المصدر ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط",
|
||||
"Shipwrecks unavailable": "بيانات حطام السفن غير متاحة",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)",
|
||||
"{count} wrecks": {
|
||||
"zero": "لا حطام ({count})",
|
||||
"one": "حطام واحد ({count})",
|
||||
"two": "حطامان ({count})",
|
||||
"few": "{count} حطامات",
|
||||
"many": "{count} حطامًا",
|
||||
"other": "{count} حطام"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "غرق سفينة",
|
||||
"Fatalities: {count}": "الوفيات: {count}",
|
||||
"Survivors: {count}": "الناجون: {count}",
|
||||
"Click the card to open the article ↗": "انقر على البطاقة لفتح المقالة ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.",
|
||||
"All years": "كل السنوات",
|
||||
"Period": "الفترة",
|
||||
"From": "من",
|
||||
"To": "إلى",
|
||||
"First year of the shipwreck period": "السنة الأولى من فترة الغرق",
|
||||
"Last year of the shipwreck period": "السنة الأخيرة من فترة الغرق",
|
||||
"SIGHTING": "مشاهدة",
|
||||
"Show {name} sighting": "إظهار مشاهدة {name}",
|
||||
"seen {age} ago": "شوهد قبل {age}",
|
||||
@@ -3578,6 +3735,17 @@
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
|
||||
"Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة",
|
||||
"Captured {month}": "التُقطت في {month}",
|
||||
"this spot": "هذا الموضع",
|
||||
"the view centre": "مركز العرض",
|
||||
"{source} at {place}": "{source} في {place}",
|
||||
"Street level at {place}": "مستوى الشارع في {place}",
|
||||
"Switching map": "جارٍ تبديل الخريطة",
|
||||
"Opening imagery": "جارٍ فتح الصور",
|
||||
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
|
||||
"{source} cannot open this link's street-level photo without a working key": "لا يمكن لـ {source} فتح صورة مستوى الشارع في هذا الرابط بدون مفتاح صالح",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
|
||||
"STATIONS": "المحطات",
|
||||
"Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها",
|
||||
"IMAGING": "التصوير",
|
||||
@@ -3619,5 +3787,33 @@
|
||||
"1 wk/s": "أسبوع/ث",
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث"
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Photo © {photographer}": "صورة © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°",
|
||||
"Street Level is unavailable": "مستوى الشارع غير متاح",
|
||||
"No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع",
|
||||
"Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع",
|
||||
"Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور",
|
||||
"Aim the view at a street": "وجّه العرض نحو شارع",
|
||||
"{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا",
|
||||
"{source} needs its key": "يحتاج {source} إلى مفتاحه",
|
||||
"{source} needs the {map} map, which is not available": "يحتاج {source} إلى خريطة {map}، وهي غير متاحة",
|
||||
"{source} could not be switched on": "تعذّر تفعيل {source}",
|
||||
"{source} is not available on this map": "{source} غير متاح على هذه الخريطة",
|
||||
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
|
||||
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
|
||||
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})"
|
||||
}
|
||||
+186
-6
@@ -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",
|
||||
@@ -1701,6 +1791,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier passt zum Bildfilter",
|
||||
"KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen",
|
||||
"Mapillary did not answer in time — try again": "Mapillary hat nicht rechtzeitig geantwortet – erneut versuchen",
|
||||
"KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen",
|
||||
"KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet",
|
||||
"KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen",
|
||||
@@ -2180,7 +2271,7 @@
|
||||
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL",
|
||||
"{source} · LAST {hours} H": "{source} · LETZTE {hours} H",
|
||||
"Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `",
|
||||
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.",
|
||||
"{count} map objects": {
|
||||
@@ -2438,8 +2529,6 @@
|
||||
"Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar",
|
||||
"Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar",
|
||||
"Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
|
||||
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
|
||||
"subject": "Objekt",
|
||||
@@ -3203,6 +3292,58 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Letzte 24 h Nachrichten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Seitenangaben, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.",
|
||||
"Economy": "Wirtschaft",
|
||||
"News from {area}": "Nachrichten: {area}",
|
||||
"Within {km} km of the view centre": "Im Umkreis von {km} km um die Bildmitte",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "über {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Lokal in der Nachrichtenebene: die Nachrichten des Tages aus den Medien des Landes im Blick",
|
||||
"{provider} refused its key": "{provider} hat den Schlüssel abgelehnt",
|
||||
"Check {envVars} in Provider Settings": "{envVars} in den Anbieter-Einstellungen prüfen",
|
||||
"{provider} key required": "{provider}-Schlüssel erforderlich",
|
||||
"{provider} limit reached for today": "{provider}: Tageslimit erreicht",
|
||||
"News resumes tomorrow, or with a paid plan": "Nachrichten gibt es morgen wieder, oder mit einem kostenpflichtigen Tarif",
|
||||
"News key required": "Nachrichtenschlüssel erforderlich",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT",
|
||||
"Worldwide": "Weltweit",
|
||||
"Shipwrecks": "Schiffswracks",
|
||||
"Before 1900": "Vor 1900",
|
||||
"After 1945": "Nach 1945",
|
||||
"Date unknown": "Datum unbekannt",
|
||||
"Position: centre of the protected area": "Position: Mitte des Schutzgebiets",
|
||||
"Position approximate (within about {km} km)": "Position ungefähr (auf etwa {km} km genau)",
|
||||
"Position surveyed (within about {m} m)": "Position vermessen (auf etwa {m} m genau)",
|
||||
"Vessel: {value}": "Schiff: {value}",
|
||||
"What happened: {value}": "Was geschah: {value}",
|
||||
"Depth: {m} m ({quality})": "Tiefe: {m} m ({quality})",
|
||||
"Depth: {m} m": "Tiefe: {m} m",
|
||||
"Notes: {value}": "Hinweise: {value}",
|
||||
"Cargo or finds: {value}": "Ladung oder Funde: {value}",
|
||||
"Protected wreck": "Geschütztes Wrack",
|
||||
"Source: {value}": "Quelle: {value}",
|
||||
"Click the card to open EMODnet ↗": "Karte anklicken, um EMODnet zu öffnen ↗",
|
||||
"Click the card to open the source ↗": "Karte anklicken, um die Quelle zu öffnen ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfügbar; nur EMODnet wird angezeigt",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfügbar; nur Wikidata wird angezeigt",
|
||||
"Shipwrecks unavailable": "Schiffswracks nicht verfügbar",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (europäische Gewässer) · Wikidata (weltweit)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} Wrack",
|
||||
"other": "{count} Wracks"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Schiffbruch",
|
||||
"Fatalities: {count}": "Todesopfer: {count}",
|
||||
"Survivors: {count}": "Überlebende: {count}",
|
||||
"Click the card to open the article ↗": "Karte anklicken, um den Artikel zu öffnen ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Klicke auf eine Markierung für ihre Karte.",
|
||||
"All years": "Alle Jahre",
|
||||
"Period": "Zeitraum",
|
||||
"From": "Von",
|
||||
"To": "Bis",
|
||||
"First year of the shipwreck period": "Erstes Jahr des Schiffbruch-Zeitraums",
|
||||
"Last year of the shipwreck period": "Letztes Jahr des Schiffbruch-Zeitraums",
|
||||
"SIGHTING": "BEOBACHTUNG",
|
||||
"Show {name} sighting": "Beobachtung von {name} anzeigen",
|
||||
"seen {age} ago": "vor {age} gesichtet",
|
||||
@@ -3270,6 +3411,17 @@
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
|
||||
"Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden",
|
||||
"Captured {month}": "Aufgenommen im {month}",
|
||||
"this spot": "diese Stelle",
|
||||
"the view centre": "die Bildmitte",
|
||||
"{source} at {place}": "{source} bei {place}",
|
||||
"Street level at {place}": "Straßenebene bei {place}",
|
||||
"Switching map": "Karte wird gewechselt",
|
||||
"Opening imagery": "Bilder werden geöffnet",
|
||||
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
|
||||
"STATIONS": "STATIONEN",
|
||||
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
|
||||
"IMAGING": "BILDGEBUNG",
|
||||
@@ -3311,5 +3463,33 @@
|
||||
"1 wk/s": "1 Wo./s",
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s"
|
||||
"1 min/s": "1 Min./s",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Der geteilte Militärflug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Das geteilte Schiff sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Das geteilte Schiff sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Der geteilte Satellit sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Der geteilte Satellit sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "Schiffs-UUID: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen",
|
||||
"Street Level is unavailable": "Straßenebene ist nicht verfügbar",
|
||||
"No street-level imagery source is configured": "Keine Quelle für Bilder auf Straßenebene konfiguriert",
|
||||
"Street Level could not be enabled": "Straßenebene konnte nicht eingeschaltet werden",
|
||||
"Street Level changed while it looked for imagery": "Straßenebene hat sich während der Bildersuche geändert",
|
||||
"Aim the view at a street": "Die Ansicht auf eine Straße richten",
|
||||
"{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene",
|
||||
"{source} needs its key": "{source} braucht seinen Schlüssel",
|
||||
"{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist",
|
||||
"{source} could not be switched on": "{source} konnte nicht eingeschaltet werden",
|
||||
"{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar",
|
||||
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
|
||||
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
|
||||
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})"
|
||||
}
|
||||
+190
-6
@@ -1,4 +1,94 @@
|
||||
{
|
||||
"News feed unavailable": "Noticias no disponibles",
|
||||
"Developing: {name}": "En desarrollo: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} suceso hoy",
|
||||
"many": "{count} sucesos hoy",
|
||||
"other": "{count} sucesos hoy"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} noticia hoy",
|
||||
"many": "{count} noticias hoy",
|
||||
"other": "{count} noticias hoy"
|
||||
},
|
||||
"Disputed: {name}": "En disputa: {name}",
|
||||
"Video": "Vídeo",
|
||||
"Photos": "Fotos",
|
||||
"Claimed by {who}": "Según {who}",
|
||||
"Claimed": "Afirmación",
|
||||
"Not yet confirmed by others": "Aún sin confirmar por otros",
|
||||
"Reported by {side} sources": "Informado por fuentes de {side}",
|
||||
"Reported by {sides}": "Informado por {sides}",
|
||||
"{side} sources": "fuentes de {side}",
|
||||
"Official": "Oficial",
|
||||
"Reported by both sides": "Informado por ambos bandos",
|
||||
"Independently reported": "Informado de forma independiente",
|
||||
"Single source": "Fuente única",
|
||||
"International": "Internacional",
|
||||
"Independent": "Independiente",
|
||||
"Unattributed": "Sin atribuir",
|
||||
"Outside party": "Parte externa",
|
||||
"Ukraine–Russia": "Ucrania–Rusia",
|
||||
"Israel–Gaza–Lebanon": "Israel–Gaza–Líbano",
|
||||
"Iran–Hormuz": "Irán–Ormuz",
|
||||
"Yemen–Red Sea": "Yemen–Mar Rojo",
|
||||
"Sudan": "Sudán",
|
||||
"Sahel": "Sahel",
|
||||
"Elsewhere": "En otros lugares",
|
||||
"Strike": "Ataque",
|
||||
"Alert": "Alerta",
|
||||
"Air defence": "Defensa aérea",
|
||||
"Ground fighting": "Combates terrestres",
|
||||
"Casualties": "Víctimas",
|
||||
"Humanitarian": "Humanitario",
|
||||
"Disaster": "Catástrofe",
|
||||
"Protest": "Protesta",
|
||||
"Political": "Político",
|
||||
"No physical event": "Sin hecho físico",
|
||||
"Minor incident": "Incidente menor",
|
||||
"Significant": "Significativo",
|
||||
"Major": "Grave",
|
||||
"age|now": "ahora",
|
||||
"{minutes} min": "{minutes} min",
|
||||
"{hours} h": "{hours} h",
|
||||
"{count} sources": {
|
||||
"one": "{count} fuente",
|
||||
"many": "{count} fuentes",
|
||||
"other": "{count} fuentes"
|
||||
},
|
||||
"News feed not configured on this server": "Noticias no configuradas en este servidor",
|
||||
"Malformed news hotspots response": "Respuesta de focos de noticias mal formada",
|
||||
"Malformed news feed response": "Respuesta de noticias mal formada",
|
||||
"{place} (placed by model)": "{place} (ubicado por el modelo)",
|
||||
"English headline by the model": "Titular en inglés del modelo",
|
||||
"Not placed on the map": "Sin ubicar en el mapa",
|
||||
"Close": "Cerrar",
|
||||
"Any severity": "Cualquier gravedad",
|
||||
"{severity} and above": "{severity} o más",
|
||||
"{severity} only": "Solo {severity}",
|
||||
"All categories": "Todas las categorías",
|
||||
"Reading the news…": "Leyendo las noticias…",
|
||||
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
||||
"Last {hours} h": "Últimas {hours} h",
|
||||
"Time window": "Periodo",
|
||||
"Conflicts": "Conflictos",
|
||||
"Local": "Local",
|
||||
"News sections": "Secciones de noticias",
|
||||
"Zoom in to see local news": "Acerca el mapa para ver noticias locales",
|
||||
"Hotspots": "Focos",
|
||||
"Severity": "Gravedad",
|
||||
"Category": "Categoría",
|
||||
"Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h",
|
||||
"Latest news": "Últimas noticias",
|
||||
"No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros",
|
||||
"Load older news": "Cargar noticias anteriores",
|
||||
"How to read the map": "Cómo leer el mapa",
|
||||
"Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Borde continuo: informado por ambos bandos o de forma independiente. Borde discontinuo: una afirmación. Borde fino: una fuente, o la prensa de un bando.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Más grande: más fuentes · más tenue: más antiguo. Tarjeta con imagen: un suceso grave o en rápido crecimiento con dos o más fuentes.",
|
||||
"Side labels describe the source, not whether a report is true.": "Las etiquetas de bando describen la fuente, no si la noticia es cierta.",
|
||||
"Exit cockpit to fly to a news report": "Sal de la cabina para volar a una noticia",
|
||||
"News items in the feed": "Noticias en la lista",
|
||||
"NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR",
|
||||
"Initializing photorealistic world...": "Inicializando el mundo fotorrealista...",
|
||||
"Configuring viewer...": "Configurando el visor...",
|
||||
@@ -320,11 +410,12 @@
|
||||
"PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIÓN",
|
||||
"POSITION UNAVAILABLE": "POSICIÓN NO DISPONIBLE",
|
||||
"REGIONAL NEWS UNAVAILABLE": "NOTICIAS DE ZONA NO DISPONIBLES",
|
||||
"CACHED · REFRESH FAILED": "EN CACHÉ · ERROR AL ACTUALIZAR",
|
||||
"REGION UNAVAILABLE": "REGIÓN NO DISPONIBLE",
|
||||
"NO RECENT LOCATION MATCHES": "SIN COINCIDENCIAS RECIENTES DE UBICACIÓN",
|
||||
"NO NEWS IN THE LAST {hours} H": "SIN NOTICIAS EN LAS ÚLTIMAS {hours} H",
|
||||
"SOURCE": "FUENTE",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{source} · LOCATION QUERY": "{source} · CONSULTA POR UBICACIÓN",
|
||||
"{source} · CACHED {age}": "{source} · EN CACHÉ {age}",
|
||||
"TIME UNKNOWN": "HORA DESCONOCIDA",
|
||||
"{count}M AGO": "HACE {count} MIN",
|
||||
"{count}H AGO": "HACE {count} H",
|
||||
@@ -336,8 +427,6 @@
|
||||
"Link copied!": "¡Enlace copiado!",
|
||||
"Link shared": "Enlace compartido",
|
||||
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||
"Copy failed": "No se pudo copiar",
|
||||
"Location not found": "Ubicación no encontrada",
|
||||
@@ -500,6 +589,7 @@
|
||||
"LOADING": "CARGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESACTUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
|
||||
"FALLBACK": "RESPALDO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"UNCERTAIN": "INCIERTO",
|
||||
@@ -737,6 +827,7 @@
|
||||
"KEY REQUIRED": "CLAVE REQUERIDA",
|
||||
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
|
||||
"LIVE · updated {age}": "EN VIVO · actualizado {age}",
|
||||
"24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC",
|
||||
"<1m ago": "hace <1 min",
|
||||
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
|
||||
"▲ {frp} MW": "▲ {frp} MW",
|
||||
@@ -2006,7 +2097,7 @@
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
|
||||
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
|
||||
"ft": "ft",
|
||||
"kts": "kn",
|
||||
"PAUSE": "PAUSA",
|
||||
@@ -2320,6 +2411,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
|
||||
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
|
||||
"Mapillary did not answer in time — try again": "Mapillary no respondió a tiempo — inténtalo de nuevo",
|
||||
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
|
||||
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
|
||||
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
|
||||
@@ -3280,6 +3372,59 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de noticias. Cada noticia enlaza a su medio; los lugares y categorías se leen del texto y los datos automáticamente y pueden ser erróneos. Las etiquetas de bando, donde aparecen, describen la fuente, no si la noticia es cierta.",
|
||||
"Economy": "Economía",
|
||||
"News from {area}": "Noticias: {area}",
|
||||
"Within {km} km of the view centre": "A menos de {km} km del centro de la vista",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "vía {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local en la capa de noticias: las noticias del día de los medios del país a la vista",
|
||||
"{provider} refused its key": "{provider} rechazó su clave",
|
||||
"Check {envVars} in Provider Settings": "Revisa {envVars} en Ajustes de proveedores",
|
||||
"{provider} key required": "Se necesita una clave de {provider}",
|
||||
"{provider} limit reached for today": "{provider} alcanzó su límite de hoy",
|
||||
"News resumes tomorrow, or with a paid plan": "Las noticias vuelven mañana, o con un plan de pago",
|
||||
"News key required": "Se necesita una clave de noticias",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Puntos de conflicto precisos en la capa de noticias, junto a los artículos de GDELT",
|
||||
"Worldwide": "Mundial",
|
||||
"Shipwrecks": "Naufragios",
|
||||
"Before 1900": "Antes de 1900",
|
||||
"After 1945": "Después de 1945",
|
||||
"Date unknown": "Fecha desconocida",
|
||||
"Position: centre of the protected area": "Posición: centro de la zona protegida",
|
||||
"Position approximate (within about {km} km)": "Posición aproximada (margen de unos {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posición levantada (margen de unos {m} m)",
|
||||
"Vessel: {value}": "Embarcación: {value}",
|
||||
"What happened: {value}": "Qué ocurrió: {value}",
|
||||
"Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profundidad: {m} m",
|
||||
"Notes: {value}": "Notas: {value}",
|
||||
"Cargo or finds: {value}": "Carga o hallazgos: {value}",
|
||||
"Protected wreck": "Pecio protegido",
|
||||
"Source: {value}": "Fuente: {value}",
|
||||
"Click the card to open EMODnet ↗": "Haz clic en la ficha para abrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Haz clic en la ficha para abrir la fuente ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata",
|
||||
"Shipwrecks unavailable": "Naufragios no disponibles",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (aguas europeas) · Wikidata (todo el mundo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} naufragio",
|
||||
"many": "{count} de naufragios",
|
||||
"other": "{count} naufragios"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufragio",
|
||||
"Fatalities: {count}": "Fallecidos: {count}",
|
||||
"Survivors: {count}": "Supervivientes: {count}",
|
||||
"Click the card to open the article ↗": "Haz clic en la ficha para abrir el artículo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su ficha.",
|
||||
"All years": "Todos los años",
|
||||
"Period": "Periodo",
|
||||
"From": "Desde",
|
||||
"To": "Hasta",
|
||||
"First year of the shipwreck period": "Primer año del periodo de naufragios",
|
||||
"Last year of the shipwreck period": "Último año del periodo de naufragios",
|
||||
"SIGHTING": "AVISTAMIENTO",
|
||||
"Show {name} sighting": "Mostrar avistamiento de {name}",
|
||||
"seen {age} ago": "visto hace {age}",
|
||||
@@ -3347,6 +3492,17 @@
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
|
||||
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
|
||||
"Captured {month}": "Capturada en {month}",
|
||||
"this spot": "este punto",
|
||||
"the view centre": "el centro de la vista",
|
||||
"{source} at {place}": "{source} en {place}",
|
||||
"Street level at {place}": "A pie de calle en {place}",
|
||||
"Switching map": "Cambiando de mapa",
|
||||
"Opening imagery": "Abriendo imágenes",
|
||||
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} no puede abrir la foto a pie de calle de este enlace sin una clave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.",
|
||||
"STATIONS": "ESTACIONES",
|
||||
"Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan",
|
||||
"IMAGING": "IMÁGENES",
|
||||
@@ -3388,5 +3544,33 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID del buque: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitud debe estar dentro de ±90° y la longitud dentro de ±180°",
|
||||
"Street Level is unavailable": "A pie de calle no está disponible",
|
||||
"No street-level imagery source is configured": "No hay ninguna fuente de imágenes a pie de calle configurada",
|
||||
"Street Level could not be enabled": "No se pudo activar A pie de calle",
|
||||
"Street Level changed while it looked for imagery": "A pie de calle cambió mientras buscaba imágenes",
|
||||
"Aim the view at a street": "Apunta la vista a una calle",
|
||||
"{source} is not a street-level imagery source here": "{source} no es una fuente de imágenes a pie de calle aquí",
|
||||
"{source} needs its key": "{source} necesita su clave",
|
||||
"{source} needs the {map} map, which is not available": "{source} requiere el mapa {map}, que no está disponible",
|
||||
"{source} could not be switched on": "No se pudo activar {source}",
|
||||
"{source} is not available on this map": "{source} no está disponible en este mapa",
|
||||
"{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
|
||||
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})"
|
||||
}
|
||||
+190
-6
@@ -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",
|
||||
@@ -1093,6 +1185,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images",
|
||||
"KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez",
|
||||
"Mapillary did not answer in time — try again": "Mapillary n'a pas répondu à temps — réessayez",
|
||||
"KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau",
|
||||
"KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible",
|
||||
"KartaView is rate-limiting requests": "KartaView limite les requêtes",
|
||||
@@ -1689,14 +1782,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.",
|
||||
"ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES",
|
||||
"REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES",
|
||||
"CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L’ACTUALISATION",
|
||||
"RESOLVING REGION": "IDENTIFICATION DE LA RÉGION",
|
||||
"REGION UNAVAILABLE": "RÉGION INDISPONIBLE",
|
||||
"NO RECENT LOCATION MATCHES": "AUCUNE ACTU RÉCENTE POUR CE LIEU",
|
||||
"NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"CLOUD {pct}%": "NÉBULOSITÉ {pct} %",
|
||||
"CLOUD UNKNOWN": "NÉBULOSITÉ INCONNUE",
|
||||
"{source} · LOCATION QUERY": "{source} · REQUÊTE DE LIEU",
|
||||
"{source} · CACHED {age}": "{source} · EN CACHE {age}",
|
||||
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
|
||||
@@ -1762,7 +1856,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "PINGS OBSERVÉS / CARTOGRAPHIÉS",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ÉVÉNEMENTS SOURCÉS · AUCUNE ACTU SYNTHÉTIQUE",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "DERNIÈRES ACTUS LIÉES AU LIEU",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · REQUÊTE DE LIEU · RÉCENT",
|
||||
"{source} · LAST {hours} H": "{source} · {hours} DERNIÈRES H",
|
||||
"LOCAL INFO": "INFOS LOCALES",
|
||||
"PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION",
|
||||
"TIME UNKNOWN": "HEURE INCONNUE",
|
||||
@@ -2194,8 +2288,6 @@
|
||||
"Shared {subject} follow expired": "Suivi partagé expiré : {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
|
||||
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"This share link can't be opened": "Ce lien de partage ne peut pas être ouvert",
|
||||
"Shared vessel is unavailable": "Le navire partagé est indisponible",
|
||||
"ACQUIRING": "ACQUISITION",
|
||||
@@ -3280,6 +3372,59 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d’actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l’article.",
|
||||
"Economy": "Économie",
|
||||
"News from {area}": "Actualités : {area}",
|
||||
"Within {km} km of the view centre": "À moins de {km} km du centre de la vue",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "via {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l’actualité du jour des médias du pays affiché",
|
||||
"{provider} refused its key": "{provider} a refusé sa clé",
|
||||
"Check {envVars} in Provider Settings": "Vérifiez {envVars} dans les paramètres des fournisseurs",
|
||||
"{provider} key required": "Clé {provider} requise",
|
||||
"{provider} limit reached for today": "{provider} a atteint sa limite du jour",
|
||||
"News resumes tomorrow, or with a paid plan": "Les actualités reprennent demain, ou avec une offre payante",
|
||||
"News key required": "Clé d’actualités requise",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT",
|
||||
"Worldwide": "Monde entier",
|
||||
"Shipwrecks": "Épaves",
|
||||
"Before 1900": "Avant 1900",
|
||||
"After 1945": "Après 1945",
|
||||
"Date unknown": "Date inconnue",
|
||||
"Position: centre of the protected area": "Position : centre de la zone protégée",
|
||||
"Position approximate (within about {km} km)": "Position approximative (à environ {km} km près)",
|
||||
"Position surveyed (within about {m} m)": "Position relevée (à environ {m} m près)",
|
||||
"Vessel: {value}": "Navire : {value}",
|
||||
"What happened: {value}": "Ce qui s'est passé : {value}",
|
||||
"Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondeur : {m} m",
|
||||
"Notes: {value}": "Notes : {value}",
|
||||
"Cargo or finds: {value}": "Cargaison ou découvertes : {value}",
|
||||
"Protected wreck": "Épave protégée",
|
||||
"Source: {value}": "Source : {value}",
|
||||
"Click the card to open EMODnet ↗": "Cliquez sur la fiche pour ouvrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Cliquez sur la fiche pour ouvrir la source ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché",
|
||||
"Shipwrecks unavailable": "Épaves indisponibles",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} épave",
|
||||
"many": "{count} d'épaves",
|
||||
"other": "{count} épaves"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufrage",
|
||||
"Fatalities: {count}": "Morts : {count}",
|
||||
"Survivors: {count}": "Survivants : {count}",
|
||||
"Click the card to open the article ↗": "Cliquez sur la fiche pour ouvrir l'article ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le repère grossit avec le nombre de morts. Cliquez sur un repère pour voir sa fiche.",
|
||||
"All years": "Toutes les années",
|
||||
"Period": "Période",
|
||||
"From": "Du",
|
||||
"To": "Au",
|
||||
"First year of the shipwreck period": "Première année de la période des naufrages",
|
||||
"Last year of the shipwreck period": "Dernière année de la période des naufrages",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l'observation : {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
@@ -3347,6 +3492,17 @@
|
||||
"Refresh map still": "Actualiser l'image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
|
||||
"Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo",
|
||||
"Captured {month}": "Prise en {month}",
|
||||
"this spot": "cet endroit",
|
||||
"the view centre": "le centre de la vue",
|
||||
"{source} at {place}": "{source} à {place}",
|
||||
"Street level at {place}": "Niveau rue à {place}",
|
||||
"Switching map": "Changement de carte",
|
||||
"Opening imagery": "Ouverture des images",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
@@ -3388,5 +3544,33 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Photo © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"ICAO: {id}": "ICAO : {id}",
|
||||
"NORAD: {id}": "NORAD : {id}",
|
||||
"MMSI: {id}": "MMSI : {id}",
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°",
|
||||
"Street Level is unavailable": "Niveau rue n'est pas disponible",
|
||||
"No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée",
|
||||
"Street Level could not be enabled": "Impossible d'activer Niveau rue",
|
||||
"Street Level changed while it looked for imagery": "Niveau rue a changé pendant la recherche d'images",
|
||||
"Aim the view at a street": "Orientez la vue vers une rue",
|
||||
"{source} is not a street-level imagery source here": "{source} n'est pas une source d'images au niveau de la rue ici",
|
||||
"{source} needs its key": "{source} a besoin de sa clé",
|
||||
"{source} needs the {map} map, which is not available": "{source} nécessite la carte {map}, qui n'est pas disponible",
|
||||
"{source} could not be switched on": "Impossible d'activer {source}",
|
||||
"{source} is not available on this map": "{source} n'est pas disponible sur cette carte",
|
||||
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
|
||||
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+190
-6
@@ -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",
|
||||
@@ -1093,6 +1185,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini",
|
||||
"KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova",
|
||||
"Mapillary did not answer in time — try again": "Mapillary non ha risposto in tempo: riprova",
|
||||
"KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete",
|
||||
"KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile",
|
||||
"KartaView is rate-limiting requests": "KartaView sta limitando le richieste",
|
||||
@@ -1689,14 +1782,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Scorri automaticamente le pagine del briefing ogni 9 secondi (Segnali → Notizie → Locale). Lo scorrimento si interrompe quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.",
|
||||
"ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI",
|
||||
"REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI",
|
||||
"CACHED · REFRESH FAILED": "IN CACHE · AGGIORNAMENTO NON RIUSCITO",
|
||||
"RESOLVING REGION": "IDENTIFICAZIONE REGIONE",
|
||||
"REGION UNAVAILABLE": "REGIONE NON DISPONIBILE",
|
||||
"NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE",
|
||||
"NO NEWS IN THE LAST {hours} H": "NESSUNA NOTIZIA NELLE ULTIME {hours} H",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"CLOUD {pct}%": "NUVOLE {pct}%",
|
||||
"CLOUD UNKNOWN": "NUVOLE N/D",
|
||||
"{source} · LOCATION QUERY": "{source} · QUERY PER POSIZIONE",
|
||||
"{source} · CACHED {age}": "{source} · IN CACHE {age}",
|
||||
"REGIONAL NEWS": "NOTIZIE REGIONALI",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
|
||||
@@ -1762,7 +1856,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTI DA FONTI · NESSUNA NOTIZIA SINTETICA",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · QUERY PER POSIZIONE · RECENTI",
|
||||
"{source} · LAST {hours} H": "{source} · ULTIME {hours} H",
|
||||
"LOCAL INFO": "INFO LOCALI",
|
||||
"PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE",
|
||||
"TIME UNKNOWN": "ORA SCONOSCIUTA",
|
||||
@@ -2194,8 +2288,6 @@
|
||||
"Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject}: feed non disponibile",
|
||||
"Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota ({time})",
|
||||
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
|
||||
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
|
||||
"ACQUIRING": "ACQUISIZIONE",
|
||||
@@ -3280,6 +3372,59 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette di parte, dove presenti, descrivono la fonte, non se la notizia è vera.",
|
||||
"Economy": "Economia",
|
||||
"News from {area}": "Notizie: {area}",
|
||||
"Within {km} km of the view centre": "Entro {km} km dal centro della vista",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "tramite {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Locale nel livello Notizie: le notizie del giorno dalle testate del paese inquadrato",
|
||||
"{provider} refused its key": "{provider} ha rifiutato la chiave",
|
||||
"Check {envVars} in Provider Settings": "Controlla {envVars} in Impostazioni provider",
|
||||
"{provider} key required": "Chiave {provider} richiesta",
|
||||
"{provider} limit reached for today": "{provider} ha raggiunto il limite di oggi",
|
||||
"News resumes tomorrow, or with a paid plan": "Le notizie riprendono domani, o con un piano a pagamento",
|
||||
"News key required": "Chiave per le notizie richiesta",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Punti di conflitto precisi nel livello Notizie, accanto agli articoli di GDELT",
|
||||
"Worldwide": "Mondo",
|
||||
"Shipwrecks": "Relitti",
|
||||
"Before 1900": "Prima del 1900",
|
||||
"After 1945": "Dopo il 1945",
|
||||
"Date unknown": "Data sconosciuta",
|
||||
"Position: centre of the protected area": "Posizione: centro dell'area protetta",
|
||||
"Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)",
|
||||
"Vessel: {value}": "Imbarcazione: {value}",
|
||||
"What happened: {value}": "Cosa è successo: {value}",
|
||||
"Depth: {m} m ({quality})": "Profondità: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondità: {m} m",
|
||||
"Notes: {value}": "Note: {value}",
|
||||
"Cargo or finds: {value}": "Carico o reperti: {value}",
|
||||
"Protected wreck": "Relitto protetto",
|
||||
"Source: {value}": "Fonte: {value}",
|
||||
"Click the card to open EMODnet ↗": "Fai clic sulla scheda per aprire EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Fai clic sulla scheda per aprire la fonte ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata non disponibile; solo EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet non disponibile; solo Wikidata",
|
||||
"Shipwrecks unavailable": "Relitti non disponibili",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (acque europee) · Wikidata (mondo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} relitto",
|
||||
"many": "{count} di relitti",
|
||||
"other": "{count} relitti"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufragio",
|
||||
"Fatalities: {count}": "Vittime: {count}",
|
||||
"Survivors: {count}": "Sopravvissuti: {count}",
|
||||
"Click the card to open the article ↗": "Fai clic sulla scheda per aprire l'articolo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda.",
|
||||
"All years": "Tutti gli anni",
|
||||
"Period": "Periodo",
|
||||
"From": "Dal",
|
||||
"To": "Al",
|
||||
"First year of the shipwreck period": "Primo anno del periodo dei naufragi",
|
||||
"Last year of the shipwreck period": "Ultimo anno del periodo dei naufragi",
|
||||
"SIGHTING": "AVVISTAMENTO",
|
||||
"Show {name} sighting": "Mostra avvistamento di {name}",
|
||||
"seen {age} ago": "avvistato {age} fa",
|
||||
@@ -3347,6 +3492,17 @@
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
|
||||
"Press OPEN to load the photo": "Premi APRI per caricare la foto",
|
||||
"Captured {month}": "Scattata a {month}",
|
||||
"this spot": "questo punto",
|
||||
"the view centre": "il centro della vista",
|
||||
"{source} at {place}": "{source} a {place}",
|
||||
"Street level at {place}": "Vista stradale a {place}",
|
||||
"Switching map": "Cambio mappa",
|
||||
"Opening imagery": "Apertura delle immagini",
|
||||
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} non può aprire la foto a livello stradale di questo link senza una chiave valida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.",
|
||||
"STATIONS": "STAZIONI",
|
||||
"Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano",
|
||||
"IMAGING": "IMMAGINI",
|
||||
@@ -3388,5 +3544,33 @@
|
||||
"1 wk/s": "1 sett/s",
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°",
|
||||
"Street Level is unavailable": "Vista stradale non è disponibile",
|
||||
"No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata",
|
||||
"Street Level could not be enabled": "Impossibile attivare Vista stradale",
|
||||
"Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini",
|
||||
"Aim the view at a street": "Punta la vista su una strada",
|
||||
"{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui",
|
||||
"{source} needs its key": "{source} richiede la sua chiave",
|
||||
"{source} needs the {map} map, which is not available": "{source} richiede la mappa {map}, che non è disponibile",
|
||||
"{source} could not be switched on": "Impossibile attivare {source}",
|
||||
"{source} is not available on this map": "{source} non è disponibile su questa mappa",
|
||||
"{source} imagery could not be opened": "Impossibile aprire le immagini di {source}",
|
||||
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
|
||||
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})"
|
||||
}
|
||||
+182
-6
@@ -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": "情勢図",
|
||||
@@ -1061,6 +1147,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaViewがこの範囲を時間内に絞り込めませんでした。地図を動かして再度リクエストしてください",
|
||||
"None of KartaView’s photos here pass the imagery filter": "この場所のKartaViewの写真はいずれも画像フィルターを通過しません",
|
||||
"KartaView did not answer in time — try again": "KartaViewから時間内に応答がありませんでした。もう一度お試しください",
|
||||
"Mapillary did not answer in time — try again": "Mapillaryから時間内に応答がありませんでした。もう一度お試しください",
|
||||
"KartaView could not be reached — check the network": "KartaViewに接続できません。ネットワークを確認してください",
|
||||
"KartaView sent an answer that could not be read": "KartaViewから読み取れない応答が返されました",
|
||||
"KartaView is rate-limiting requests": "KartaViewがリクエストをレート制限中です",
|
||||
@@ -1641,14 +1728,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "9秒ごとにブリーフィングページを自動で切り替えます(シグナル → ニュース → ローカル)。パネルにカーソルを合わせるかフォーカスしている間は一時停止します。ライブシグナルのデータは、どちらの場合も継続的に更新されます。",
|
||||
"ACQUIRING REGIONAL NEWS": "地域ニュースを取得中",
|
||||
"REGIONAL NEWS UNAVAILABLE": "地域ニュースを取得できません",
|
||||
"CACHED · REFRESH FAILED": "キャッシュ · 更新失敗",
|
||||
"RESOLVING REGION": "地域を特定中",
|
||||
"REGION UNAVAILABLE": "地域を取得できません",
|
||||
"NO RECENT LOCATION MATCHES": "この地域の最近のニュースなし",
|
||||
"NO NEWS IN THE LAST {hours} H": "過去{hours}時間のニュースなし",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} km/h",
|
||||
"CLOUD {pct}%": "雲量 {pct}%",
|
||||
"CLOUD UNKNOWN": "雲量不明",
|
||||
"{source} · LOCATION QUERY": "{source} · 位置クエリ",
|
||||
"{source} · CACHED {age}": "{source} · キャッシュ {age}",
|
||||
"REGIONAL NEWS": "地域ニュース",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
||||
@@ -1712,7 +1800,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "観測 / マップ済みピング",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "出典付きイベントのみ · 合成ニュースなし",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "位置に一致する最新の報道",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · 位置クエリ · 最近",
|
||||
"{source} · LAST {hours} H": "{source} · 過去{hours}時間",
|
||||
"LOCAL INFO": "ローカル情報",
|
||||
"PLACE / CONDITIONS / POSITION": "場所 / 天候 / 位置",
|
||||
"TIME UNKNOWN": "時刻不明",
|
||||
@@ -2122,8 +2210,6 @@
|
||||
"Shared {subject} follow expired": "共有された{subject}の追従が期限切れになりました",
|
||||
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
|
||||
"Shared {subject} is unavailable": "共有された{subject}は利用できません",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}は現在報告していません。最後に確認された位置を表示しています",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}は現在報告していません。{time}時点の位置を表示しています",
|
||||
"This share link can't be opened": "この共有リンクは開けません",
|
||||
"Shared vessel is unavailable": "共有された船舶は利用できません",
|
||||
"ACQUIRING": "取得中",
|
||||
@@ -3126,6 +3212,57 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。",
|
||||
"Economy": "経済",
|
||||
"News from {area}": "{area}のニュース",
|
||||
"Within {km} km of the view centre": "表示中心から{km} km以内",
|
||||
"{region}, {country}": "{country}・{region}",
|
||||
"via {source}": "{source}経由",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "ニュースレイヤーのローカル:表示中の国のメディアによる今日のニュース",
|
||||
"{provider} refused its key": "{provider}がキーを拒否しました",
|
||||
"Check {envVars} in Provider Settings": "プロバイダー設定で{envVars}を確認してください",
|
||||
"{provider} key required": "{provider}のキーが必要です",
|
||||
"{provider} limit reached for today": "{provider}の本日の上限に達しました",
|
||||
"News resumes tomorrow, or with a paid plan": "ニュースは明日、または有料プランで再開します",
|
||||
"News key required": "ニュースのキーが必要です",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)",
|
||||
"Worldwide": "世界",
|
||||
"Shipwrecks": "難破船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以降",
|
||||
"Date unknown": "日付不明",
|
||||
"Position: centre of the protected area": "位置: 保護区域の中心",
|
||||
"Position approximate (within about {km} km)": "おおよその位置(誤差約{km} km)",
|
||||
"Position surveyed (within about {m} m)": "測量された位置(誤差約{m} m)",
|
||||
"Vessel: {value}": "船種: {value}",
|
||||
"What happened: {value}": "経緯: {value}",
|
||||
"Depth: {m} m ({quality})": "水深: {m} m({quality})",
|
||||
"Depth: {m} m": "水深: {m} m",
|
||||
"Notes: {value}": "備考: {value}",
|
||||
"Cargo or finds: {value}": "積荷・遺物: {value}",
|
||||
"Protected wreck": "保護対象の難破船",
|
||||
"Source: {value}": "出典: {value}",
|
||||
"Click the card to open EMODnet ↗": "カードをクリックしてEMODnetを開く ↗",
|
||||
"Click the card to open the source ↗": "カードをクリックして出典を開く ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidataを利用できません。EMODnetのみ表示しています",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnetを利用できません。Wikidataのみ表示しています",
|
||||
"Shipwrecks unavailable": "難破船データを利用できません",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域)· Wikidata(世界)",
|
||||
"{count} wrecks": {
|
||||
"other": "難破船 {count}件"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "難破",
|
||||
"Fatalities: {count}": "死者: {count}",
|
||||
"Survivors: {count}": "生存者: {count}",
|
||||
"Click the card to open the article ↗": "カードをクリックして記事を開く ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧州の歴史的な難破船はEMODnet Human Activities、世界の著名な難破船はWikidataによります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。",
|
||||
"All years": "全期間",
|
||||
"Period": "期間",
|
||||
"From": "開始",
|
||||
"To": "終了",
|
||||
"First year of the shipwreck period": "難破の期間の開始年",
|
||||
"Last year of the shipwreck period": "難破の期間の終了年",
|
||||
"SIGHTING": "目撃情報",
|
||||
"Show {name} sighting": "{name}の目撃情報を表示",
|
||||
"seen {age} ago": "{age}前に目撃",
|
||||
@@ -3193,6 +3330,17 @@
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
|
||||
"Press OPEN to load the photo": "「開く」を押して写真を読み込む",
|
||||
"Captured {month}": "{month}撮影",
|
||||
"this spot": "この地点",
|
||||
"the view centre": "表示中心",
|
||||
"{source} at {place}": "{place}の{source}",
|
||||
"Street level at {place}": "{place}のストリートレベル",
|
||||
"Switching map": "地図を切り替え中",
|
||||
"Opening imagery": "画像を開いています",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
|
||||
"STATIONS": "ステーション",
|
||||
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
|
||||
"IMAGING": "地球観測",
|
||||
@@ -3234,5 +3382,33 @@
|
||||
"1 wk/s": "1週間/秒",
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒"
|
||||
"1 min/s": "1分/秒",
|
||||
"Photo © {photographer}": "写真 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "船舶UUID: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください",
|
||||
"Street Level is unavailable": "ストリートレベルは利用できません",
|
||||
"No street-level imagery source is configured": "ストリートレベル画像のソースが設定されていません",
|
||||
"Street Level could not be enabled": "ストリートレベルをオンにできませんでした",
|
||||
"Street Level changed while it looked for imagery": "画像の検索中にストリートレベルが変更されました",
|
||||
"Aim the view at a street": "表示を通りに向けてください",
|
||||
"{source} is not a street-level imagery source here": "{source}はここではストリートレベル画像のソースではありません",
|
||||
"{source} needs its key": "{source}にはキーが必要です",
|
||||
"{source} needs the {map} map, which is not available": "{source}には{map}マップが必要ですが、利用できません",
|
||||
"{source} could not be switched on": "{source}をオンにできませんでした",
|
||||
"{source} is not available on this map": "{source}はこのマップでは利用できません",
|
||||
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
|
||||
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
|
||||
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})"
|
||||
}
|
||||
+190
-6
@@ -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",
|
||||
@@ -1091,6 +1183,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens",
|
||||
"KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente",
|
||||
"Mapillary did not answer in time — try again": "O Mapillary não respondeu a tempo — tente novamente",
|
||||
"KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede",
|
||||
"KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida",
|
||||
"KartaView is rate-limiting requests": "O KartaView está limitando as requisições",
|
||||
@@ -1689,14 +1782,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Alternar as páginas do briefing automaticamente a cada 9 segundos (Sinais → Notícias → Local). A alternância pausa enquanto você passa o mouse ou foca o painel. Os dados de sinais ao vivo continuam sendo atualizados de qualquer forma.",
|
||||
"ACQUIRING REGIONAL NEWS": "OBTENDO NOTÍCIAS DA ÁREA",
|
||||
"REGIONAL NEWS UNAVAILABLE": "NOTÍCIAS DA ÁREA INDISPONÍVEIS",
|
||||
"CACHED · REFRESH FAILED": "EM CACHE · FALHA AO ATUALIZAR",
|
||||
"RESOLVING REGION": "IDENTIFICANDO REGIÃO",
|
||||
"REGION UNAVAILABLE": "REGIÃO INDISPONÍVEL",
|
||||
"NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDÊNCIA RECENTE COM O LOCAL",
|
||||
"NO NEWS IN THE LAST {hours} H": "SEM NOTÍCIAS NAS ÚLTIMAS {hours} H",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"CLOUD {pct}%": "NUVENS {pct}%",
|
||||
"CLOUD UNKNOWN": "NEBULOSIDADE DESCONHECIDA",
|
||||
"{source} · LOCATION QUERY": "{source} · CONSULTA POR LOCAL",
|
||||
"{source} · CACHED {age}": "{source} · EM CACHE {age}",
|
||||
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
|
||||
@@ -1762,7 +1856,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTOS COM FONTE · SEM NOTÍCIAS SINTÉTICAS",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "ÚLTIMAS NOTÍCIAS DO LOCAL",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR LOCAL · RECENTE",
|
||||
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
|
||||
"LOCAL INFO": "INFO LOCAL",
|
||||
"PLACE / CONDITIONS / POSITION": "LUGAR / CONDIÇÕES / POSIÇÃO",
|
||||
"TIME UNKNOWN": "HORA DESCONHECIDA",
|
||||
@@ -2194,8 +2288,6 @@
|
||||
"Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível",
|
||||
"Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"This share link can't be opened": "Não é possível abrir este link de compartilhamento",
|
||||
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
|
||||
"ACQUIRING": "ADQUIRINDO",
|
||||
@@ -3280,6 +3372,59 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia leva ao seu editor; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.",
|
||||
"Economy": "Economia",
|
||||
"News from {area}": "Notícias: {area}",
|
||||
"Within {km} km of the view centre": "Até {km} km do centro da vista",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "via {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Local na camada de notícias: as notícias do dia dos veículos do país em vista",
|
||||
"{provider} refused its key": "{provider} recusou a chave",
|
||||
"Check {envVars} in Provider Settings": "Verifique {envVars} em \"Configurações de provedores\"",
|
||||
"{provider} key required": "Chave do {provider} necessária",
|
||||
"{provider} limit reached for today": "{provider} atingiu o limite de hoje",
|
||||
"News resumes tomorrow, or with a paid plan": "As notícias voltam amanhã, ou com um plano pago",
|
||||
"News key required": "Chave de notícias necessária",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Pontos de conflito precisos na camada de notícias, ao lado dos artigos do GDELT",
|
||||
"Worldwide": "Mundo",
|
||||
"Shipwrecks": "Naufrágios",
|
||||
"Before 1900": "Antes de 1900",
|
||||
"After 1945": "Depois de 1945",
|
||||
"Date unknown": "Data desconhecida",
|
||||
"Position: centre of the protected area": "Posição: centro da área protegida",
|
||||
"Position approximate (within about {km} km)": "Posição aproximada (margem de cerca de {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posição levantada (margem de cerca de {m} m)",
|
||||
"Vessel: {value}": "Embarcação: {value}",
|
||||
"What happened: {value}": "O que aconteceu: {value}",
|
||||
"Depth: {m} m ({quality})": "Profundidade: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profundidade: {m} m",
|
||||
"Notes: {value}": "Notas: {value}",
|
||||
"Cargo or finds: {value}": "Carga ou achados: {value}",
|
||||
"Protected wreck": "Naufrágio protegido",
|
||||
"Source: {value}": "Fonte: {value}",
|
||||
"Click the card to open EMODnet ↗": "Clique na ficha para abrir o EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Clique na ficha para abrir a fonte ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponível; mostrando só o EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponível; mostrando só o Wikidata",
|
||||
"Shipwrecks unavailable": "Naufrágios indisponíveis",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (águas europeias) · Wikidata (mundo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} naufrágio",
|
||||
"many": "{count} de naufrágios",
|
||||
"other": "{count} naufrágios"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufrágio",
|
||||
"Fatalities: {count}": "Mortos: {count}",
|
||||
"Survivors: {count}": "Sobreviventes: {count}",
|
||||
"Click the card to open the article ↗": "Clique na ficha para abrir o artigo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique num marcador para ver a ficha.",
|
||||
"All years": "Todos os anos",
|
||||
"Period": "Período",
|
||||
"From": "Início",
|
||||
"To": "Fim",
|
||||
"First year of the shipwreck period": "Primeiro ano do período dos naufrágios",
|
||||
"Last year of the shipwreck period": "Último ano do período dos naufrágios",
|
||||
"SIGHTING": "AVISTAMENTO",
|
||||
"Show {name} sighting": "Mostrar avistamento de {name}",
|
||||
"seen {age} ago": "visto há {age}",
|
||||
@@ -3347,6 +3492,17 @@
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
|
||||
"Press OPEN to load the photo": "Clique em ABRIR para carregar a foto",
|
||||
"Captured {month}": "Capturada em {month}",
|
||||
"this spot": "este ponto",
|
||||
"the view centre": "o centro da vista",
|
||||
"{source} at {place}": "{source} em {place}",
|
||||
"Street level at {place}": "Nível da rua em {place}",
|
||||
"Switching map": "Mudando de mapa",
|
||||
"Opening imagery": "Abrindo imagens",
|
||||
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"STATIONS": "ESTAÇÕES",
|
||||
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
|
||||
"IMAGING": "IMAGENS",
|
||||
@@ -3388,5 +3544,33 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°",
|
||||
"Street Level is unavailable": "Nível da rua não está disponível",
|
||||
"No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada",
|
||||
"Street Level could not be enabled": "Não foi possível ativar Nível da rua",
|
||||
"Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens",
|
||||
"Aim the view at a street": "Aponte a vista para uma rua",
|
||||
"{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui",
|
||||
"{source} needs its key": "{source} precisa da sua chave",
|
||||
"{source} needs the {map} map, which is not available": "{source} requer o mapa {map}, que não está disponível",
|
||||
"{source} could not be switched on": "Não foi possível ativar {source}",
|
||||
"{source} is not available on this map": "{source} não está disponível neste mapa",
|
||||
"{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
|
||||
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})"
|
||||
}
|
||||
+194
-6
@@ -1,4 +1,97 @@
|
||||
{
|
||||
"News feed unavailable": "Лента новостей недоступна",
|
||||
"Developing: {name}": "Развивается: {name}",
|
||||
"{count} events today": {
|
||||
"one": "{count} событие сегодня",
|
||||
"few": "{count} события сегодня",
|
||||
"many": "{count} событий сегодня",
|
||||
"other": "{count} события сегодня"
|
||||
},
|
||||
"{count} reports today": {
|
||||
"one": "{count} сообщение сегодня",
|
||||
"few": "{count} сообщения сегодня",
|
||||
"many": "{count} сообщений сегодня",
|
||||
"other": "{count} сообщения сегодня"
|
||||
},
|
||||
"Disputed: {name}": "Спорная территория: {name}",
|
||||
"Video": "Видео",
|
||||
"Photos": "Фото",
|
||||
"Claimed by {who}": "Заявление: {who}",
|
||||
"Claimed": "Заявление",
|
||||
"Not yet confirmed by others": "Пока не подтверждено другими",
|
||||
"Reported by {side} sources": "Сообщают источники {side}",
|
||||
"Reported by {sides}": "Сообщают: {sides}",
|
||||
"{side} sources": "источники {side}",
|
||||
"Official": "Официально",
|
||||
"Reported by both sides": "Сообщают обе стороны",
|
||||
"Independently reported": "Подтверждено независимыми источниками",
|
||||
"Single source": "Один источник",
|
||||
"International": "Международные",
|
||||
"Independent": "Независимые",
|
||||
"Unattributed": "Без атрибуции",
|
||||
"Outside party": "Внешняя сторона",
|
||||
"Ukraine–Russia": "Украина–Россия",
|
||||
"Israel–Gaza–Lebanon": "Израиль–Газа–Ливан",
|
||||
"Iran–Hormuz": "Иран–Ормуз",
|
||||
"Yemen–Red Sea": "Йемен–Красное море",
|
||||
"Sudan": "Судан",
|
||||
"Sahel": "Сахель",
|
||||
"Elsewhere": "Другие места",
|
||||
"Strike": "Удар",
|
||||
"Alert": "Тревога",
|
||||
"Air defence": "ПВО",
|
||||
"Ground fighting": "Наземные бои",
|
||||
"Casualties": "Жертвы",
|
||||
"Humanitarian": "Гуманитарное",
|
||||
"Disaster": "Бедствие",
|
||||
"Protest": "Протест",
|
||||
"Political": "Политика",
|
||||
"No physical event": "Без физического события",
|
||||
"Minor incident": "Незначительный инцидент",
|
||||
"Significant": "Значительный",
|
||||
"Major": "Крупный",
|
||||
"age|now": "сейчас",
|
||||
"{minutes} min": "{minutes} мин",
|
||||
"{hours} h": "{hours} ч",
|
||||
"{count} sources": {
|
||||
"one": "{count} источник",
|
||||
"few": "{count} источника",
|
||||
"many": "{count} источников",
|
||||
"other": "{count} источника"
|
||||
},
|
||||
"News feed not configured on this server": "Лента новостей не настроена на этом сервере",
|
||||
"Malformed news hotspots response": "Некорректный ответ горячих точек новостей",
|
||||
"Malformed news feed response": "Некорректный ответ ленты новостей",
|
||||
"{place} (placed by model)": "{place} (место определила модель)",
|
||||
"English headline by the model": "Английский заголовок от модели",
|
||||
"Not placed on the map": "Не отмечено на карте",
|
||||
"Close": "Закрыть",
|
||||
"Any severity": "Любая тяжесть",
|
||||
"{severity} and above": "{severity} и выше",
|
||||
"{severity} only": "Только {severity}",
|
||||
"All categories": "Все категории",
|
||||
"Reading the news…": "Читаем новости…",
|
||||
"News, last {hours} hours": "Новости за последние {hours} ч",
|
||||
"Last {hours} h": "За {hours} ч",
|
||||
"Time window": "Период",
|
||||
"Conflicts": "Конфликты",
|
||||
"Local": "Местные",
|
||||
"News sections": "Разделы новостей",
|
||||
"Zoom in to see local news": "Приблизьте карту, чтобы увидеть местные новости",
|
||||
"Hotspots": "Горячие точки",
|
||||
"Severity": "Тяжесть",
|
||||
"Category": "Категория",
|
||||
"Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось",
|
||||
"Latest news": "Последние новости",
|
||||
"No news matches these filters in the last {hours} h": "За последние {hours} ч нет новостей по этим фильтрам",
|
||||
"Load older news": "Загрузить более ранние новости",
|
||||
"How to read the map": "Как читать карту",
|
||||
"Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события",
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной обод: сообщают обе стороны или независимые источники. Пунктирный обод: заявление. Тонкий обод: один источник или пресса одной стороны.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Крупнее: больше источников · бледнее: старше. Карточка с фото: крупное или быстро растущее событие от двух и более источников.",
|
||||
"Side labels describe the source, not whether a report is true.": "Метки сторон описывают источник, а не достоверность сообщения.",
|
||||
"Exit cockpit to fly to a news report": "Выйдите из кабины, чтобы перелететь к сообщению",
|
||||
"News items in the feed": "Новостей в ленте",
|
||||
"Flying to Austin, TX...": "Перелёт в Остин, Техас...",
|
||||
"Restoring shared view...": "Восстановление общего вида...",
|
||||
"Initializing systems...": "Инициализация систем...",
|
||||
@@ -143,10 +236,12 @@
|
||||
"{count}h ago": "{count} ч назад",
|
||||
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
|
||||
"STALE": "УСТАРЕЛ",
|
||||
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
|
||||
"refreshing...": "обновление...",
|
||||
"loading...": "загрузка...",
|
||||
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
|
||||
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
|
||||
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
|
||||
"Retrying mapped sites…": "Повторный запрос объектов…",
|
||||
"Fetching mapped sites…": "Загрузка объектов…",
|
||||
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
|
||||
@@ -330,8 +425,6 @@
|
||||
"Link copied!": "Ссылка скопирована!",
|
||||
"Link shared": "Ссылка отправлена",
|
||||
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
|
||||
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||
"Copy failed": "Не удалось скопировать",
|
||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||
@@ -398,10 +491,11 @@
|
||||
"OVERCAST": "ПАСМУРНО",
|
||||
"ACQUIRING REGIONAL NEWS": "ЗАГРУЗКА РЕГИОНАЛЬНЫХ НОВОСТЕЙ",
|
||||
"REGIONAL NEWS UNAVAILABLE": "РЕГИОНАЛЬНЫЕ НОВОСТИ НЕДОСТУПНЫ",
|
||||
"CACHED · REFRESH FAILED": "КЭШ · ОБНОВЛЕНИЕ НЕ УДАЛОСЬ",
|
||||
"RESOLVING REGION": "ОПРЕДЕЛЕНИЕ РЕГИОНА",
|
||||
"REGION UNAVAILABLE": "РЕГИОН НЕДОСТУПЕН",
|
||||
"FOG": "ТУМАН",
|
||||
"NO RECENT LOCATION MATCHES": "НЕТ СВЕЖИХ СОВПАДЕНИЙ ПО МЕСТУ",
|
||||
"NO NEWS IN THE LAST {hours} H": "НЕТ НОВОСТЕЙ ЗА {hours} Ч",
|
||||
"DRIZZLE": "МОРОСЬ",
|
||||
"RAIN": "ДОЖДЬ",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
@@ -413,7 +507,7 @@
|
||||
"CLOUD {pct}%": "ОБЛАЧНОСТЬ {pct}%",
|
||||
"CLOUD UNKNOWN": "ОБЛАЧНОСТЬ НЕИЗВЕСТНА",
|
||||
"MIXED CONDITIONS": "СМЕШАННЫЕ УСЛОВИЯ",
|
||||
"{source} · LOCATION QUERY": "{source} · ЗАПРОС ПО МЕСТУ",
|
||||
"{source} · CACHED {age}": "{source} · КЭШ {age}",
|
||||
"REGIONAL NEWS": "НОВОСТИ РЕГИОНА",
|
||||
"CYCLE ON": "АВТО: ВКЛ",
|
||||
"CYCLE OFF": "АВТО: ВЫКЛ",
|
||||
@@ -2040,7 +2134,7 @@
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
|
||||
"{source} · LAST {hours} H": "{source} · ЗА {hours} Ч",
|
||||
"ft": "фт",
|
||||
"kts": "уз",
|
||||
"PAUSE": "ПАУЗА",
|
||||
@@ -2358,6 +2452,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
|
||||
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
|
||||
"Mapillary did not answer in time — try again": "Mapillary не ответил вовремя — попробуйте снова",
|
||||
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
|
||||
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
|
||||
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
|
||||
@@ -3357,6 +3452,60 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новости за последние 24 ч. Каждое сообщение ведёт к своему изданию; места и категории определяются по тексту и данным автоматически и могут быть ошибочны. Метки сторон, где они есть, описывают источник, а не достоверность сообщения.",
|
||||
"Economy": "Экономика",
|
||||
"News from {area}": "Новости: {area}",
|
||||
"Within {km} km of the view centre": "В радиусе {km} км от центра карты",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "через {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Местное в слое новостей: новости дня от СМИ страны в кадре",
|
||||
"{provider} refused its key": "{provider} отклонил ключ",
|
||||
"Check {envVars} in Provider Settings": "Проверьте {envVars} в «Настройках провайдеров»",
|
||||
"{provider} key required": "Нужен ключ {provider}",
|
||||
"{provider} limit reached for today": "{provider}: дневной лимит исчерпан",
|
||||
"News resumes tomorrow, or with a paid plan": "Новости вернутся завтра или с платным тарифом",
|
||||
"News key required": "Нужен ключ новостей",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT",
|
||||
"Worldwide": "Весь мир",
|
||||
"Shipwrecks": "Кораблекрушения",
|
||||
"Before 1900": "До 1900",
|
||||
"After 1945": "После 1945",
|
||||
"Date unknown": "Дата неизвестна",
|
||||
"Position: centre of the protected area": "Положение: центр охраняемой зоны",
|
||||
"Position approximate (within about {km} km)": "Положение приблизительное (точность около {km} км)",
|
||||
"Position surveyed (within about {m} m)": "Положение измерено (точность около {m} м)",
|
||||
"Vessel: {value}": "Судно: {value}",
|
||||
"What happened: {value}": "Что произошло: {value}",
|
||||
"Depth: {m} m ({quality})": "Глубина: {m} м ({quality})",
|
||||
"Depth: {m} m": "Глубина: {m} м",
|
||||
"Notes: {value}": "Примечания: {value}",
|
||||
"Cargo or finds: {value}": "Груз или находки: {value}",
|
||||
"Protected wreck": "Охраняемый объект",
|
||||
"Source: {value}": "Источник: {value}",
|
||||
"Click the card to open EMODnet ↗": "Нажмите на карточку, чтобы открыть EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Нажмите на карточку, чтобы открыть источник ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показан только EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet недоступен; показана только Wikidata",
|
||||
"Shipwrecks unavailable": "Данные о кораблекрушениях недоступны",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (воды Европы) · Wikidata (весь мир)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} затонувшее судно",
|
||||
"few": "{count} затонувших судна",
|
||||
"many": "{count} затонувших судов",
|
||||
"other": "{count} затонувшего судна"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Кораблекрушение",
|
||||
"Fatalities: {count}": "Погибших: {count}",
|
||||
"Survivors: {count}": "Выживших: {count}",
|
||||
"Click the card to open the article ↗": "Нажмите на карточку, чтобы открыть статью ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Исторические затонувшие суда Европы — из EMODnet Human Activities, известные кораблекрушения по всему миру — из Wikidata. Цвет показывает, когда судно затонуло; маркер растёт с числом погибших. Нажмите на маркер, чтобы открыть карточку.",
|
||||
"All years": "Все годы",
|
||||
"Period": "Период",
|
||||
"From": "С",
|
||||
"To": "По",
|
||||
"First year of the shipwreck period": "Первый год периода кораблекрушений",
|
||||
"Last year of the shipwreck period": "Последний год периода кораблекрушений",
|
||||
"SIGHTING": "НАБЛЮДЕНИЕ",
|
||||
"Show {name} sighting": "Показать наблюдение: {name}",
|
||||
"seen {age} ago": "замечено {age} назад",
|
||||
@@ -3424,6 +3573,17 @@
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
|
||||
"Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок",
|
||||
"Captured {month}": "Снято: {month}",
|
||||
"this spot": "эта точка",
|
||||
"the view centre": "центр вида",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Уличные снимки: {place}",
|
||||
"Switching map": "Смена карты",
|
||||
"Opening imagery": "Открытие снимков",
|
||||
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} не может открыть уличное фото по этой ссылке без рабочего ключа",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
|
||||
"STATIONS": "СТАНЦИИ",
|
||||
"Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли",
|
||||
"IMAGING": "СЪЁМКА",
|
||||
@@ -3465,5 +3625,33 @@
|
||||
"1 wk/s": "1 нед/с",
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с"
|
||||
"1 min/s": "1 мин/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°",
|
||||
"Street Level is unavailable": "Уличные снимки недоступны",
|
||||
"No street-level imagery source is configured": "Источник уличных снимков не настроен",
|
||||
"Street Level could not be enabled": "Не удалось включить уличные снимки",
|
||||
"Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска",
|
||||
"Aim the view at a street": "Наведите вид на улицу",
|
||||
"{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков",
|
||||
"{source} needs its key": "{source}: нужен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна",
|
||||
"{source} could not be switched on": "Не удалось включить {source}",
|
||||
"{source} is not available on this map": "{source}: недоступно на этой карте",
|
||||
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
|
||||
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
|
||||
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+194
-6
@@ -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": "Розвідувальна обстановка",
|
||||
@@ -1108,6 +1203,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Жодне фото KartaView тут не проходить фільтр знімків",
|
||||
"KartaView did not answer in time — try again": "KartaView не відповів вчасно — спробуйте знову",
|
||||
"Mapillary did not answer in time — try again": "Mapillary не відповів вчасно — спробуйте знову",
|
||||
"KartaView could not be reached — check the network": "Не вдалося звʼязатися з KartaView — перевірте мережу",
|
||||
"KartaView sent an answer that could not be read": "KartaView надіслав відповідь, яку не вдалося прочитати",
|
||||
"KartaView is rate-limiting requests": "KartaView обмежує частоту запитів",
|
||||
@@ -1712,14 +1808,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Автоматично гортає сторінки брифінгу кожні 9 секунд (Сигнали → Новини → Місцеві дані). Пауза, доки ви наводите вказівник на панель або вона у фокусі. Дані сигналів у реальному часі однаково оновлюються безперервно.",
|
||||
"ACQUIRING REGIONAL NEWS": "ОТРИМАННЯ РЕГІОНАЛЬНИХ НОВИН",
|
||||
"REGIONAL NEWS UNAVAILABLE": "РЕГІОНАЛЬНІ НОВИНИ НЕДОСТУПНІ",
|
||||
"CACHED · REFRESH FAILED": "КЕШ · ОНОВЛЕННЯ НЕ ВДАЛОСЯ",
|
||||
"RESOLVING REGION": "ВИЗНАЧЕННЯ РЕГІОНУ",
|
||||
"REGION UNAVAILABLE": "РЕГІОН НЕДОСТУПНИЙ",
|
||||
"NO RECENT LOCATION MATCHES": "НЕМАЄ СВІЖИХ ЗБІГІВ ЗА РОЗТАШУВАННЯМ",
|
||||
"NO NEWS IN THE LAST {hours} H": "НЕМАЄ НОВИН ЗА {hours} ГОД",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} КМ/ГОД",
|
||||
"CLOUD {pct}%": "ХМАРНІСТЬ {pct}%",
|
||||
"CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА",
|
||||
"{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ",
|
||||
"{source} · CACHED {age}": "{source} · КЕШ {age}",
|
||||
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
||||
@@ -1786,7 +1883,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ КОНТАКТИ",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ІМІТОВАНИХ НОВИН",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ · НЕЩОДАВНІ",
|
||||
"{source} · LAST {hours} H": "{source} · ЗА {hours} ГОД",
|
||||
"LOCAL INFO": "МІСЦЕВІ ДАНІ",
|
||||
"PLACE / CONDITIONS / POSITION": "МІСЦЕ / УМОВИ / ПОЗИЦІЯ",
|
||||
"TIME UNKNOWN": "ЧАС НЕВІДОМИЙ",
|
||||
@@ -2229,8 +2326,6 @@
|
||||
"Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
|
||||
"Shared {subject} is unavailable": "Недоступно за посиланням: {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})",
|
||||
"This share link can't be opened": "Не вдається відкрити це посилання",
|
||||
"Shared vessel is unavailable": "Судно за посиланням недоступне",
|
||||
"ACQUIRING": "ОТРИМАННЯ",
|
||||
@@ -3357,6 +3452,60 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новини за останні 24 год. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту й даних автоматично і можуть бути хибними. Позначки сторін, де вони є, описують джерело, а не правдивість повідомлення.",
|
||||
"Economy": "Економіка",
|
||||
"News from {area}": "Новини: {area}",
|
||||
"Within {km} km of the view centre": "У радіусі {km} км від центру мапи",
|
||||
"{region}, {country}": "{region}, {country}",
|
||||
"via {source}": "через {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "Місцеве в шарі новин: новини дня від медіа країни в кадрі",
|
||||
"{provider} refused its key": "{provider} відхилив ключ",
|
||||
"Check {envVars} in Provider Settings": "Перевірте {envVars} у «Налаштуваннях постачальників»",
|
||||
"{provider} key required": "Потрібен ключ {provider}",
|
||||
"{provider} limit reached for today": "{provider}: денний ліміт вичерпано",
|
||||
"News resumes tomorrow, or with a paid plan": "Новини повернуться завтра або з платним тарифом",
|
||||
"News key required": "Потрібен ключ новин",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT",
|
||||
"Worldwide": "Увесь світ",
|
||||
"Shipwrecks": "Корабельні аварії",
|
||||
"Before 1900": "До 1900",
|
||||
"After 1945": "Після 1945",
|
||||
"Date unknown": "Дата невідома",
|
||||
"Position: centre of the protected area": "Положення: центр охоронної зони",
|
||||
"Position approximate (within about {km} km)": "Положення приблизне (точність близько {km} км)",
|
||||
"Position surveyed (within about {m} m)": "Положення виміряно (точність близько {m} м)",
|
||||
"Vessel: {value}": "Судно: {value}",
|
||||
"What happened: {value}": "Що сталося: {value}",
|
||||
"Depth: {m} m ({quality})": "Глибина: {m} м ({quality})",
|
||||
"Depth: {m} m": "Глибина: {m} м",
|
||||
"Notes: {value}": "Примітки: {value}",
|
||||
"Cargo or finds: {value}": "Вантаж або знахідки: {value}",
|
||||
"Protected wreck": "Охоронюваний об'єкт",
|
||||
"Source: {value}": "Джерело: {value}",
|
||||
"Click the card to open EMODnet ↗": "Натисніть картку, щоб відкрити EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Натисніть картку, щоб відкрити джерело ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показано лише EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet недоступний; показано лише Wikidata",
|
||||
"Shipwrecks unavailable": "Дані про корабельні аварії недоступні",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} затонуле судно",
|
||||
"few": "{count} затонулі судна",
|
||||
"many": "{count} затонулих суден",
|
||||
"other": "{count} затонулого судна"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Корабельна аварія",
|
||||
"Fatalities: {count}": "Загиблих: {count}",
|
||||
"Survivors: {count}": "Тих, хто вижив: {count}",
|
||||
"Click the card to open the article ↗": "Натисніть картку, щоб відкрити статтю ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі корабельні аварії світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.",
|
||||
"All years": "Усі роки",
|
||||
"Period": "Період",
|
||||
"From": "З",
|
||||
"To": "По",
|
||||
"First year of the shipwreck period": "Перший рік періоду корабельних аварій",
|
||||
"Last year of the shipwreck period": "Останній рік періоду корабельних аварій",
|
||||
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
|
||||
"Show {name} sighting": "Показати спостереження: {name}",
|
||||
"seen {age} ago": "помічено {age} тому",
|
||||
@@ -3424,6 +3573,17 @@
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
|
||||
"Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото",
|
||||
"Captured {month}": "Знято: {month}",
|
||||
"this spot": "це місце",
|
||||
"the view centre": "центр карти",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Вуличні знімки: {place}",
|
||||
"Switching map": "Перемикання карти",
|
||||
"Opening imagery": "Відкриття знімків",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
|
||||
"STATIONS": "СТАНЦІЇ",
|
||||
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
|
||||
"IMAGING": "ЗЙОМКА",
|
||||
@@ -3465,5 +3625,33 @@
|
||||
"1 wk/s": "1 тиж/с",
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с"
|
||||
"1 min/s": "1 хв/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°",
|
||||
"Street Level is unavailable": "Вуличні знімки недоступні",
|
||||
"No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано",
|
||||
"Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки",
|
||||
"Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку",
|
||||
"Aim the view at a street": "Наведіть вигляд на вулицю",
|
||||
"{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків",
|
||||
"{source} needs its key": "{source}: потрібен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна",
|
||||
"{source} could not be switched on": "Не вдалося увімкнути {source}",
|
||||
"{source} is not available on this map": "{source}: недоступно на цій карті",
|
||||
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
|
||||
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
|
||||
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})"
|
||||
}
|
||||
+182
-6
@@ -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%",
|
||||
@@ -1038,6 +1124,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求",
|
||||
"None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选",
|
||||
"KartaView did not answer in time — try again": "KartaView 未及时响应——请重试",
|
||||
"Mapillary did not answer in time — try again": "Mapillary 未及时响应——请重试",
|
||||
"KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络",
|
||||
"KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取",
|
||||
"KartaView is rate-limiting requests": "KartaView 已被限流",
|
||||
@@ -1519,8 +1606,6 @@
|
||||
"Link copied!": "链接已复制!",
|
||||
"Link shared": "链接已分享",
|
||||
"This share link can't be opened": "无法打开此分享链接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
|
||||
"Shared vessel is unavailable": "分享的船只不可用",
|
||||
"Copy failed": "复制失败",
|
||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||
@@ -1617,14 +1702,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自动轮换简报页面(信号 → 新闻 → 本地)。悬停或聚焦面板时暂停。无论是否轮换,实时信号数据都会持续刷新。",
|
||||
"ACQUIRING REGIONAL NEWS": "正在获取地区新闻",
|
||||
"REGIONAL NEWS UNAVAILABLE": "地区新闻不可用",
|
||||
"CACHED · REFRESH FAILED": "缓存 · 刷新失败",
|
||||
"RESOLVING REGION": "正在解析区域",
|
||||
"REGION UNAVAILABLE": "区域不可用",
|
||||
"NO RECENT LOCATION MATCHES": "暂无近期位置匹配结果",
|
||||
"NO NEWS IN THE LAST {hours} H": "过去 {hours} 小时无新闻",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} km/h",
|
||||
"CLOUD {pct}%": "云量 {pct}%",
|
||||
"CLOUD UNKNOWN": "云量未知",
|
||||
"{source} · LOCATION QUERY": "{source} · 位置查询",
|
||||
"{source} · CACHED {age}": "{source} · 缓存 {age}",
|
||||
"REGIONAL NEWS": "地区新闻",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
||||
@@ -1688,7 +1774,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "观测 / 地图标注目标",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "当前地区最新报道",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期",
|
||||
"{source} · LAST {hours} H": "{source} · 过去 {hours} 小时",
|
||||
"LOCAL INFO": "本地信息",
|
||||
"PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置",
|
||||
"TIME UNKNOWN": "时间未知",
|
||||
@@ -3126,6 +3212,57 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。",
|
||||
"Economy": "经济",
|
||||
"News from {area}": "{area}新闻",
|
||||
"Within {km} km of the view centre": "视图中心 {km} 公里范围内",
|
||||
"{region}, {country}": "{country}{region}",
|
||||
"via {source}": "来自 {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的本地:视野中国家的媒体当天的新闻",
|
||||
"{provider} refused its key": "{provider} 拒绝了密钥",
|
||||
"Check {envVars} in Provider Settings": "请在服务商设置中检查 {envVars}",
|
||||
"{provider} key required": "需要 {provider} 密钥",
|
||||
"{provider} limit reached for today": "{provider} 已达今日上限",
|
||||
"News resumes tomorrow, or with a paid plan": "新闻将于明天恢复,或升级付费套餐",
|
||||
"News key required": "需要新闻密钥",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列",
|
||||
"Worldwide": "全球",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以后",
|
||||
"Date unknown": "日期不详",
|
||||
"Position: centre of the protected area": "位置:保护区中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(误差约 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "实测位置(误差约 {m} 米)",
|
||||
"Vessel: {value}": "船只:{value}",
|
||||
"What happened: {value}": "经过:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 米({quality})",
|
||||
"Depth: {m} m": "深度:{m} 米",
|
||||
"Notes: {value}": "备注:{value}",
|
||||
"Cargo or finds: {value}": "货物或出水文物:{value}",
|
||||
"Protected wreck": "受保护沉船",
|
||||
"Source: {value}": "来源:{value}",
|
||||
"Click the card to open EMODnet ↗": "点击卡片打开 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "点击卡片打开来源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 不可用;仅显示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 不可用;仅显示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船数据不可用",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧洲海域)· Wikidata(全球)",
|
||||
"{count} wrecks": {
|
||||
"other": "{count} 艘沉船"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "遇难:{count}",
|
||||
"Survivors: {count}": "幸存者:{count}",
|
||||
"Click the card to open the article ↗": "点击卡片打开文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧洲历史沉船来自 EMODnet Human Activities;全球知名沉船来自 Wikidata。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。",
|
||||
"All years": "所有年份",
|
||||
"Period": "时段",
|
||||
"From": "起",
|
||||
"To": "止",
|
||||
"First year of the shipwreck period": "沉船时段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船时段的结束年份",
|
||||
"SIGHTING": "目击",
|
||||
"Show {name} sighting": "显示{name}目击",
|
||||
"seen {age} ago": "{age}前目击",
|
||||
@@ -3193,6 +3330,17 @@
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
|
||||
"Press OPEN to load the photo": "点击“打开”加载照片",
|
||||
"Captured {month}": "拍摄于 {month}",
|
||||
"this spot": "此处",
|
||||
"the view centre": "视图中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切换地图",
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
|
||||
"{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
|
||||
"STATIONS": "空间站",
|
||||
"Crewed stations and the craft that visit them": "载人空间站及来访飞行器",
|
||||
"IMAGING": "成像",
|
||||
@@ -3234,5 +3382,33 @@
|
||||
"1 wk/s": "1 周/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒"
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内",
|
||||
"Street Level is unavailable": "街景不可用",
|
||||
"No street-level imagery source is configured": "未配置街景影像来源",
|
||||
"Street Level could not be enabled": "无法开启街景",
|
||||
"Street Level changed while it looked for imagery": "查找影像时街景已更改",
|
||||
"Aim the view at a street": "请将视野对准街道",
|
||||
"{source} is not a street-level imagery source here": "{source} 在此处不是街景影像来源",
|
||||
"{source} needs its key": "{source} 需要其密钥",
|
||||
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地图,但该地图不可用",
|
||||
"{source} could not be switched on": "无法开启 {source}",
|
||||
"{source} is not available on this map": "{source} 在此地图上不可用",
|
||||
"{source} imagery could not be opened": "无法打开 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})"
|
||||
}
|
||||
@@ -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%",
|
||||
@@ -1033,6 +1119,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求",
|
||||
"None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選",
|
||||
"KartaView did not answer in time — try again": "KartaView 未及時響應——請重試",
|
||||
"Mapillary did not answer in time — try again": "Mapillary 未及時響應——請重試",
|
||||
"KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡",
|
||||
"KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取",
|
||||
"KartaView is rate-limiting requests": "KartaView 已被限流",
|
||||
@@ -1607,14 +1694,15 @@
|
||||
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。",
|
||||
"ACQUIRING REGIONAL NEWS": "正在獲取地區新聞",
|
||||
"REGIONAL NEWS UNAVAILABLE": "地區新聞不可用",
|
||||
"CACHED · REFRESH FAILED": "快取 · 重新整理失敗",
|
||||
"RESOLVING REGION": "正在解析區域",
|
||||
"REGION UNAVAILABLE": "區域不可用",
|
||||
"NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果",
|
||||
"NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞",
|
||||
"{domain} · {age}": "{domain} · {age}",
|
||||
"{value} KM/H": "{value} km/h",
|
||||
"CLOUD {pct}%": "雲量 {pct}%",
|
||||
"CLOUD UNKNOWN": "雲量未知",
|
||||
"{source} · LOCATION QUERY": "{source} · 位置查詢",
|
||||
"{source} · CACHED {age}": "{source} · 快取 {age}",
|
||||
"REGIONAL NEWS": "地區新聞",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
||||
@@ -1678,7 +1766,7 @@
|
||||
"OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標",
|
||||
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞",
|
||||
"LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期",
|
||||
"{source} · LAST {hours} H": "{source} · 過去 {hours} 小時",
|
||||
"LOCAL INFO": "本地信息",
|
||||
"PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置",
|
||||
"TIME UNKNOWN": "時間未知",
|
||||
@@ -2896,8 +2984,6 @@
|
||||
"Take the exit {direction}": "從出口{direction}駛出",
|
||||
"Link shared": "鏈接已分享",
|
||||
"This share link can't be opened": "無法打開此分享鏈接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置",
|
||||
"Shared vessel is unavailable": "分享的船隻不可用",
|
||||
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
||||
"Press Enter to try again.": "按 Enter 重試。",
|
||||
@@ -3126,6 +3212,57 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
|
||||
"Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。",
|
||||
"Economy": "經濟",
|
||||
"News from {area}": "{area}新聞",
|
||||
"Within {km} km of the view centre": "視圖中心 {km} 公里範圍內",
|
||||
"{region}, {country}": "{country}{region}",
|
||||
"via {source}": "來自 {source}",
|
||||
"Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的本地:視野中國家的媒體當天的新聞",
|
||||
"{provider} refused its key": "{provider} 拒絕了金鑰",
|
||||
"Check {envVars} in Provider Settings": "請在服務商設置中檢查 {envVars}",
|
||||
"{provider} key required": "需要 {provider} 金鑰",
|
||||
"{provider} limit reached for today": "{provider} 已達今日上限",
|
||||
"News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費方案",
|
||||
"News key required": "需要新聞金鑰",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列",
|
||||
"Worldwide": "全球",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以後",
|
||||
"Date unknown": "日期不詳",
|
||||
"Position: centre of the protected area": "位置:保護區中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(誤差約 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "實測位置(誤差約 {m} 公尺)",
|
||||
"Vessel: {value}": "船隻:{value}",
|
||||
"What happened: {value}": "經過:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 公尺({quality})",
|
||||
"Depth: {m} m": "深度:{m} 公尺",
|
||||
"Notes: {value}": "備註:{value}",
|
||||
"Cargo or finds: {value}": "貨物或出水文物:{value}",
|
||||
"Protected wreck": "受保護沉船",
|
||||
"Source: {value}": "來源:{value}",
|
||||
"Click the card to open EMODnet ↗": "點擊卡片開啟 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "點擊卡片開啟來源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 無法使用;僅顯示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 無法使用;僅顯示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船資料無法使用",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(歐洲海域)· Wikidata(全球)",
|
||||
"{count} wrecks": {
|
||||
"other": "{count} 艘沉船"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "罹難:{count}",
|
||||
"Survivors: {count}": "倖存者:{count}",
|
||||
"Click the card to open the article ↗": "點擊卡片開啟文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。",
|
||||
"All years": "所有年份",
|
||||
"Period": "時段",
|
||||
"From": "起",
|
||||
"To": "迄",
|
||||
"First year of the shipwreck period": "沉船時段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船時段的結束年份",
|
||||
"SIGHTING": "目擊",
|
||||
"Show {name} sighting": "顯示{name}目擊",
|
||||
"seen {age} ago": "{age}前目擊",
|
||||
@@ -3193,6 +3330,17 @@
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
|
||||
"Press OPEN to load the photo": "點擊“打開”加載照片",
|
||||
"Captured {month}": "拍攝於 {month}",
|
||||
"this spot": "此處",
|
||||
"the view centre": "視圖中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切換地圖",
|
||||
"Opening imagery": "正在打開影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
|
||||
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
|
||||
"STATIONS": "太空站",
|
||||
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
|
||||
"IMAGING": "成像",
|
||||
@@ -3234,5 +3382,33 @@
|
||||
"1 wk/s": "1 週/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒"
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內",
|
||||
"Street Level is unavailable": "街景不可用",
|
||||
"No street-level imagery source is configured": "未配置街景影像來源",
|
||||
"Street Level could not be enabled": "無法開啟街景",
|
||||
"Street Level changed while it looked for imagery": "查找影像時街景已更改",
|
||||
"Aim the view at a street": "請將視野對準街道",
|
||||
"{source} is not a street-level imagery source here": "{source} 在此處不是街景影像來源",
|
||||
"{source} needs its key": "{source} 需要其密鑰",
|
||||
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地圖,但該地圖不可用",
|
||||
"{source} could not be switched on": "無法開啟 {source}",
|
||||
"{source} is not available on this map": "{source} 在此地圖上不可用",
|
||||
"{source} imagery could not be opened": "無法打開 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ar": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -43,10 +44,14 @@
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -99,6 +104,7 @@
|
||||
"House",
|
||||
"Hybrid",
|
||||
"Indie",
|
||||
"International",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LIVE",
|
||||
@@ -145,6 +151,7 @@
|
||||
"Pin",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Protest",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
@@ -172,6 +179,7 @@
|
||||
"STD",
|
||||
"SW",
|
||||
"SYNC",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Scanlines",
|
||||
"Scope",
|
||||
@@ -180,6 +188,7 @@
|
||||
"Soul",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"Sudan",
|
||||
"T minus {seconds}",
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
@@ -192,6 +201,7 @@
|
||||
"Turboprop",
|
||||
"UPSTREAM",
|
||||
"Upstream HTTP {status}",
|
||||
"Video",
|
||||
"Vignette",
|
||||
"WHOT/BHOT",
|
||||
"WIND",
|
||||
@@ -217,6 +227,7 @@
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{region}, {country}",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
@@ -230,11 +241,15 @@
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -286,6 +301,7 @@
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Local",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
@@ -329,6 +345,7 @@
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
@@ -362,10 +379,13 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{hours} h",
|
||||
"{minutes} min",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{region}, {country}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
@@ -381,11 +401,15 @@
|
||||
"Reptiles",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
@@ -462,6 +486,7 @@
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Local",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
@@ -497,6 +522,7 @@
|
||||
"PROPAGATION",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Photos",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
@@ -523,6 +549,7 @@
|
||||
"STATION",
|
||||
"STATIONS",
|
||||
"STD",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
"Saturation",
|
||||
@@ -567,17 +594,22 @@
|
||||
"satellite",
|
||||
"standard",
|
||||
"station",
|
||||
"via {source}",
|
||||
"voice|OFF",
|
||||
"{count} CONTACTS",
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{count} sources",
|
||||
"{hours} h",
|
||||
"{minutes} min",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
"{region}, {country}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
@@ -586,11 +618,13 @@
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
@@ -650,6 +684,7 @@
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Iran–Hormuz",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"KTS",
|
||||
@@ -708,11 +743,13 @@
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
"Sahel",
|
||||
"San Francisco",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Sudan",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
@@ -731,6 +768,7 @@
|
||||
"VIEWSHED ON",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"Video",
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
@@ -756,10 +794,13 @@
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
"{count} scenes",
|
||||
"{hours} h",
|
||||
"{minutes} min",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{region}, {country}",
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
@@ -778,10 +819,14 @@
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -840,11 +885,15 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -900,6 +949,7 @@
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Layout",
|
||||
"Local",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
@@ -942,6 +992,7 @@
|
||||
"STARLINK ✕",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sahel",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Sonar",
|
||||
@@ -974,11 +1025,15 @@
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"via {source}",
|
||||
"view band|GLOBAL",
|
||||
"{hours} h",
|
||||
"{minutes} min",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{region}, {country}",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
@@ -991,9 +1046,13 @@
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1017,14 +1076,179 @@
|
||||
"TLE",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"{region}, {country}",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"sv": [
|
||||
"STATION",
|
||||
"NAV",
|
||||
"GEO",
|
||||
"SAR",
|
||||
"CRT",
|
||||
"NVG",
|
||||
"FLIR",
|
||||
"REC",
|
||||
"AIS: --",
|
||||
"HDG {value}°",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CRS {value}°",
|
||||
"Station {id}",
|
||||
"Station",
|
||||
"FOV {value}°",
|
||||
"▲ {frp} MW",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"STATUS · ",
|
||||
"DESTINATION",
|
||||
"STATUS",
|
||||
"T minus {seconds}",
|
||||
"Ambient",
|
||||
"Blues",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"House",
|
||||
"Indie",
|
||||
"Jazz",
|
||||
"Latin",
|
||||
"Metal",
|
||||
"Oldies",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Soul",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"Bloom",
|
||||
"DEST {bearing}",
|
||||
"Normal",
|
||||
"Anime",
|
||||
"Noir",
|
||||
"CCTV",
|
||||
"Radio",
|
||||
"DEST ---°",
|
||||
"TEMP",
|
||||
"RADIO",
|
||||
"COCKPIT",
|
||||
"Layout",
|
||||
"Minimal",
|
||||
"Scope",
|
||||
"{speed} kn",
|
||||
"NORMAL",
|
||||
"ANIME",
|
||||
"NOIR",
|
||||
"view band|GLOBAL",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"POSITION {age}",
|
||||
"fire behavior|Minimal",
|
||||
"{county} County",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Google Street View",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Hybrid",
|
||||
"RADAR · {units}",
|
||||
"REGION",
|
||||
"FILTER",
|
||||
"{source} HTTP {status}",
|
||||
"WHOT/BHOT",
|
||||
"Ironbow",
|
||||
"HDG",
|
||||
"FOV",
|
||||
"ΔN",
|
||||
"LIVE",
|
||||
"OK",
|
||||
"{value} KM/H",
|
||||
"SQUAWK {code} · {status}",
|
||||
"S2 GhostMaps",
|
||||
"NASA FIRMS · LIVE",
|
||||
"feed status|live",
|
||||
"DSP {count} blocks",
|
||||
"Austin",
|
||||
"San Francisco",
|
||||
"New York",
|
||||
"Tokyo",
|
||||
"London",
|
||||
"Paris",
|
||||
"Dubai",
|
||||
"Washington DC",
|
||||
"Tallinn",
|
||||
"Los Angeles",
|
||||
"km/h",
|
||||
"mph",
|
||||
"hPa",
|
||||
"ft",
|
||||
"km",
|
||||
"min",
|
||||
"dB",
|
||||
"STD",
|
||||
"Plan",
|
||||
"MINI",
|
||||
"standard",
|
||||
"mini",
|
||||
"STANDARD",
|
||||
"FT",
|
||||
"MIL",
|
||||
"MM",
|
||||
"SIG",
|
||||
"AUTO",
|
||||
"FM MHZ",
|
||||
"Cyber",
|
||||
"Sonar",
|
||||
"Cyan",
|
||||
"≥ {value} MW",
|
||||
"Be Whale Wise",
|
||||
"Salish Sea",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"iNaturalist",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"{range} NM",
|
||||
"OFF-NADIR {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}",
|
||||
"HOME NADIR",
|
||||
"ZOOM {zoom}×",
|
||||
"NORAD {id}",
|
||||
"TLE",
|
||||
"NADIR",
|
||||
"SENSOR",
|
||||
"1 min/s",
|
||||
"Live",
|
||||
"EXPORT",
|
||||
"via {source}",
|
||||
"Video",
|
||||
"Iran–Hormuz",
|
||||
"Sudan",
|
||||
"Sahel",
|
||||
"Protest",
|
||||
"{minutes} min",
|
||||
"Period",
|
||||
"ICAO: {id}",
|
||||
"NORAD: {id}",
|
||||
"MMSI: {id}"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1043,15 +1267,20 @@
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"{region}, {country}",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1122,6 +1351,7 @@
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1189,5 +1419,105 @@
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"he": [
|
||||
"{speed} kn",
|
||||
"GEO",
|
||||
"SAR",
|
||||
"EARTH WATCH",
|
||||
"CRT",
|
||||
"NVG",
|
||||
"FLIR",
|
||||
"AIS: --",
|
||||
"REC",
|
||||
"HDG {value}°",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CRS {value}°",
|
||||
"FOV {value}°",
|
||||
"CAL {value}",
|
||||
"SRC {value}",
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW",
|
||||
"Intel Picture",
|
||||
"Kinetic 30d",
|
||||
"Border Crisis",
|
||||
"Iran Picture",
|
||||
"Iran Kinetic",
|
||||
"Yemen FLOT",
|
||||
"Latest Drop",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"V/S {rate} FPM",
|
||||
"Sentinel-2",
|
||||
"Landsat 8/9",
|
||||
"Google Street View",
|
||||
"GOOGLE STREET VIEW",
|
||||
"AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"POS: {time}Z",
|
||||
"{source} HTTP {status}",
|
||||
"NNE",
|
||||
"NE",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"SE",
|
||||
"SSE",
|
||||
"SSW",
|
||||
"SW",
|
||||
"WSW",
|
||||
"WNW",
|
||||
"NW",
|
||||
"NNW",
|
||||
"WHOT/BHOT",
|
||||
"HDG",
|
||||
"FOV",
|
||||
"HGT",
|
||||
"ΔN",
|
||||
"ΔE",
|
||||
"CAL · {badge}",
|
||||
"DEST ---°",
|
||||
"SQUAWK {code} · {status}",
|
||||
"DEST {bearing}",
|
||||
"CCTV",
|
||||
"S2 GhostMaps",
|
||||
"Street Level",
|
||||
"STREET LEVEL",
|
||||
"km/h",
|
||||
"mph",
|
||||
"hPa",
|
||||
"kt",
|
||||
"kts",
|
||||
"ft",
|
||||
"km",
|
||||
"min",
|
||||
"dB",
|
||||
"KTS",
|
||||
"FT",
|
||||
"FLT",
|
||||
"MIL",
|
||||
"WX",
|
||||
"MSG/S",
|
||||
"FM MHZ",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"Salish Sea",
|
||||
"iNaturalist",
|
||||
"Be Whale Wise",
|
||||
"MIC",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"{range} NM",
|
||||
"NORAD {id}",
|
||||
"EPOCH {time}",
|
||||
"TLE",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"Ironbow",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
|
||||
return (
|
||||
items.find(
|
||||
(item) =>
|
||||
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
||||
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
|
||||
) || subject
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item, index) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
const targetId = item.icao24 || item.id || item.mmsi;
|
||||
const distance = presentAwarenessDistance(item.distanceM);
|
||||
if (!targetId)
|
||||
return {
|
||||
@@ -182,6 +182,29 @@ export function createPanel({ state: layerState, parts }) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The tracked airliner's photo with its credit, or null. The subject
|
||||
* refresh resolves it; only the subject it was resolved for shows it. */
|
||||
function presentPhoto(subject) {
|
||||
const held = layerState.subjectPhoto;
|
||||
if (
|
||||
!held ||
|
||||
held.layerId !== subject.layerId ||
|
||||
String(held.id) !== String(subject.id)
|
||||
)
|
||||
return null;
|
||||
const { photo } = held;
|
||||
return {
|
||||
src: photo.src,
|
||||
width: photo.width ?? null,
|
||||
height: photo.height ?? null,
|
||||
link: photo.link,
|
||||
// A photo whose answer names no photographer shows no credit line.
|
||||
credit: photo.photographer
|
||||
? t('Photo © {photographer}', { photographer: photo.photographer })
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
ensurePanel();
|
||||
@@ -191,6 +214,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
kind: 'results',
|
||||
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||
subjectNames: presentNames(subject.memberNames),
|
||||
photo: presentPhoto(subject),
|
||||
navigation: presentNavigation(),
|
||||
rows: cohorts.map(presentRow),
|
||||
note: t(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createSubject } from './subject.js';
|
||||
|
||||
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
|
||||
const cohort = (id, source, sourceStatus) => ({
|
||||
@@ -36,3 +37,130 @@ test('a Contacts row names its provider as the Layers panel does, with its feed
|
||||
);
|
||||
state.offPanelLocale?.();
|
||||
});
|
||||
|
||||
const PHOTO = Object.freeze({
|
||||
src: 'https://t.plnspttrs.net/1/abc123_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/n123ab',
|
||||
});
|
||||
|
||||
/** The published Contacts view for one subject and the refresh's photo. */
|
||||
function photoView(subject, subjectPhoto) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
results: { subject, cohorts: [] },
|
||||
subjectPhoto,
|
||||
cohortPages: new Map(),
|
||||
panelListeners: new Set(),
|
||||
panelSignature: '',
|
||||
navigationIndex: 0,
|
||||
};
|
||||
const parts = {
|
||||
navigation: { canNavigateNext: () => false },
|
||||
rendering: { hideDirectionOverlay() {} },
|
||||
};
|
||||
createPanel({ state, parts }).renderResults();
|
||||
state.offPanelLocale?.();
|
||||
return state.panelView;
|
||||
}
|
||||
|
||||
const FLIGHT = Object.freeze({
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
label: 'N123AB',
|
||||
});
|
||||
|
||||
test('the Contacts view carries the tracked flight’s photo with its credit', () => {
|
||||
const view = photoView(FLIGHT, {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
photo: PHOTO,
|
||||
});
|
||||
assert.deepEqual(view.photo, {
|
||||
src: PHOTO.src,
|
||||
width: 280,
|
||||
height: 186,
|
||||
link: PHOTO.link,
|
||||
credit: 'Photo © Jane Doe',
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
photoView(FLIGHT, { layerId: 'flights', id: 'def456', photo: PHOTO }).photo,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
photoView(
|
||||
{ layerId: 'ais-live-vessels', id: 'abc123', label: 'VESSEL' },
|
||||
{ layerId: 'flights', id: 'abc123', photo: PHOTO },
|
||||
).photo,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => {
|
||||
const tracked = {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
label: 'N123AB',
|
||||
photo: PHOTO,
|
||||
};
|
||||
let current = tracked;
|
||||
let lookups = 0;
|
||||
const flights = {
|
||||
getTrackedSubject: () => {
|
||||
lookups += 1;
|
||||
return current;
|
||||
},
|
||||
};
|
||||
const military = {
|
||||
getTrackedSubject: () => ({ ...tracked, layerId: 'military' }),
|
||||
};
|
||||
const { resolveSubjectPhoto } = createSubject({
|
||||
state: {},
|
||||
services: { flights, military, vessels: {}, installations: {} },
|
||||
parts: {
|
||||
navigation: {
|
||||
isFlightLayer: (id) => id === 'flights' || id === 'military',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }),
|
||||
PHOTO,
|
||||
);
|
||||
assert.equal(lookups, 1);
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'def456' }), null);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'military-installations', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSubjectPhoto({ layerId: 'military', id: 'abc123' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveSubjectPhoto(null), null);
|
||||
current = null;
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
|
||||
current = { ...tracked, photo: null };
|
||||
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
|
||||
});
|
||||
@@ -29,6 +29,8 @@ export function createState({ services }) {
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
/** The tracked flight's photo with the subject it belongs to, or null. */
|
||||
subjectPhoto: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
/** Whether layer views replace the panel's initial markup yet. */
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||
.filter(
|
||||
(item) =>
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'id'),
|
||||
);
|
||||
const coverage = installationsState.stats?.coverage;
|
||||
const installationRadius =
|
||||
@@ -378,19 +378,48 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
*
|
||||
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
|
||||
* @param {{layerId: string, id: string, label: string}} subject Current subject.
|
||||
* @param {?object} [tracked] The subject's tracked descriptor, when the
|
||||
* caller already has it (see trackedSubjectOf).
|
||||
* @returns {string} The current label, or the cached one when the owning layer
|
||||
* has no tracked subject (e.g. a cross-layer handoff is in flight).
|
||||
*/
|
||||
|
||||
function resolveSubjectLabel(subject) {
|
||||
if (!parts.navigation.isFlightLayer(subject?.layerId))
|
||||
return subject?.label;
|
||||
function resolveSubjectLabel(subject, tracked = trackedSubjectOf(subject)) {
|
||||
return tracked?.label || subject?.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning flight layer's tracked-subject descriptor when it describes
|
||||
* this subject, else null. Built once per refresh and shared by the label
|
||||
* and the photo.
|
||||
* @param {{layerId: string, id: string}|null} subject Current subject.
|
||||
* @returns {?{id: string, label: string, photo?: ?object}}
|
||||
*/
|
||||
function trackedSubjectOf(subject) {
|
||||
if (!parts.navigation.isFlightLayer(subject?.layerId)) return null;
|
||||
const layer =
|
||||
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
||||
const tracked = layer.getTrackedSubject?.();
|
||||
if (tracked?.label && String(tracked.id) === String(subject.id))
|
||||
return tracked.label;
|
||||
return subject.label;
|
||||
return tracked && String(tracked.id) === String(subject.id)
|
||||
? tracked
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracked airliner's Planespotters photo, for the Contacts panel.
|
||||
*
|
||||
* Only the flights layer looks photos up, and only for the plane it tracks;
|
||||
* a vessel, an installation, a military aircraft or an untracked flight has
|
||||
* none.
|
||||
* @param {{layerId: string, id: string}|null} subject Current subject.
|
||||
* @param {?object} [tracked] The subject's tracked descriptor, when the
|
||||
* caller already has it.
|
||||
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}}
|
||||
*/
|
||||
function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) {
|
||||
return subject?.layerId === 'flights' && tracked?.photo
|
||||
? tracked.photo
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Refresh proximity counts/distances against the subject's current live position. */
|
||||
@@ -417,8 +446,21 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
|
||||
else if (presence === SUBJECT_PRESENCE.MISSING)
|
||||
layerState.subjectMissing = true;
|
||||
const nextLabel = resolveSubjectLabel(layerState.subject);
|
||||
const tracked = trackedSubjectOf(layerState.subject);
|
||||
const nextLabel = resolveSubjectLabel(layerState.subject, tracked);
|
||||
const labelChanged = nextLabel !== layerState.subject.label;
|
||||
// The panel reads the photo from here, with the subject it belongs to.
|
||||
const photo = resolveSubjectPhoto(layerState.subject, tracked);
|
||||
layerState.subjectPhoto = photo
|
||||
? {
|
||||
layerId: layerState.subject.layerId,
|
||||
id: layerState.subject.id,
|
||||
photo,
|
||||
}
|
||||
: null;
|
||||
// A photo arriving after selection is render-worthy on its own, as a label.
|
||||
const photoChanged =
|
||||
(photo?.src ?? null) !== (layerState.panelView?.photo?.src ?? null);
|
||||
layerState.subject = { ...layerState.subject, position, label: nextLabel };
|
||||
const movementM = layerState.lastEvaluatedPosition
|
||||
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
|
||||
@@ -439,7 +481,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
// kept the ICAO hex until unrelated movement, a source-revision bump, or
|
||||
// page rotation happened to repaint it. Gated on an ACTUAL change so the
|
||||
// common no-op refresh (every 750 ms) still costs no repaint.
|
||||
if (labelChanged) parts.panel.renderResults();
|
||||
if (labelChanged || photoChanged) parts.panel.renderResults();
|
||||
parts.rendering.scheduleDirectionOverlayUpdate(force);
|
||||
return;
|
||||
}
|
||||
@@ -477,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return {
|
||||
layerId: record.layerId,
|
||||
id: record.properties?.mmsi || record.id,
|
||||
id: record.properties?.vesselId || record.id,
|
||||
label: record.label || record.id,
|
||||
memberNames: record.properties?.memberNames || [],
|
||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||
@@ -502,6 +544,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
militaryInstallationsLayer.setContextAnchor?.(null);
|
||||
layerState.autoFocusRetryPending = false;
|
||||
layerState.subject = null;
|
||||
layerState.subjectPhoto = null;
|
||||
layerState.subjectMissing = false;
|
||||
layerState.results = null;
|
||||
layerState.navigationHistory = [];
|
||||
@@ -528,6 +571,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
resolveSubjectPosition,
|
||||
collectionSubjectPosition,
|
||||
resolveSubjectLabel,
|
||||
resolveSubjectPhoto,
|
||||
refreshSelectedSubject,
|
||||
subjectFromContext,
|
||||
markSubjectEvicted,
|
||||
|
||||
@@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// sampler. It remains event-driven, a few probes per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
if (sampleGround && parts.model.projectionTilesReady()) {
|
||||
record.groundMeshSampleRequestCount =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses, floors and the tracked
|
||||
* trail belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Flights follow the world clock only while their source declares a timeline
|
||||
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
const following = { timeline: { active: () => true } };
|
||||
const live = { timeline: { active: () => false } };
|
||||
|
||||
function records(now = () => T) {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
const observation = (fields = {}) => ({
|
||||
id: 'abc123',
|
||||
reference: 'abc123',
|
||||
longitude: -77,
|
||||
latitude: 39,
|
||||
callsign: 'TEST1',
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
speedMps: 120,
|
||||
courseDeg: 90,
|
||||
positionTimeMs: T - 5_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(following), 0);
|
||||
assert.equal(followsClock({}), false);
|
||||
assert.equal(followsClock(following), true);
|
||||
});
|
||||
|
||||
test('civil records judge absence and receipt at the layer clock', () => {
|
||||
let now = T;
|
||||
const store = records(() => now);
|
||||
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', {
|
||||
complete: true,
|
||||
tracked: true,
|
||||
nowMs: T + 11 * 60_000,
|
||||
}),
|
||||
'remove',
|
||||
'a given time judges retention',
|
||||
);
|
||||
store.receive(observation(), view());
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
'the clock judges retention when no time is given',
|
||||
);
|
||||
});
|
||||
|
||||
test('a records reset forgets where airframes were and keeps what they are', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation(), view());
|
||||
first.meta.typeCode = 'B738';
|
||||
first.meta.registration = 'N123';
|
||||
first.meta.photo = { src: 'photo.jpg' };
|
||||
store.absence('abc123', { complete: true });
|
||||
store.beginSnapshot(T);
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
const again = store.receive(
|
||||
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
|
||||
view(),
|
||||
);
|
||||
assert.equal(again.prevMeta, undefined, 'no motion carries across');
|
||||
assert.equal(again.groundFlipped, false);
|
||||
assert.equal(again.meta.typeCode, 'B738');
|
||||
assert.equal(again.meta.registration, 'N123');
|
||||
assert.equal(again.meta.photo.src, 'photo.jpg');
|
||||
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
|
||||
store.forget('abc123');
|
||||
assert.equal(store.airframes.size, 0);
|
||||
});
|
||||
|
||||
function ingestion(source, { follows = false } = {}) {
|
||||
const feed = {
|
||||
_source: source,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_trackingRefreshEpoch: 0,
|
||||
_lastSource: 'Fixture',
|
||||
_retryAt: 0,
|
||||
};
|
||||
const applied = [];
|
||||
const state = { follows };
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
getQuery: () => ({ latitude: 39 }),
|
||||
applySnapshot: (snapshot, _viewer, options) => {
|
||||
applied.push({ snapshot, options });
|
||||
return { count: 1, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => state.follows,
|
||||
});
|
||||
return { feed, applied, state, methods };
|
||||
}
|
||||
|
||||
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
await probe.methods.update(null);
|
||||
assert.deepEqual(queries[0], { latitude: 39 });
|
||||
assert.equal(probe.applied[0].options, undefined);
|
||||
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
|
||||
assert.deepEqual(probe.applied[1].options, { replace: true });
|
||||
assert.equal(probe.feed._lastUpdate, T - 60_000);
|
||||
});
|
||||
|
||||
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
|
||||
let release;
|
||||
const probe = ingestion({
|
||||
getSnapshot: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
const out = probe.methods.update(null);
|
||||
probe.state.follows = true;
|
||||
release({ records: [], observedAtMs: T, freshness: 'current' });
|
||||
await out;
|
||||
assert.equal(probe.applied.length, 0);
|
||||
await probe.methods.update(null);
|
||||
assert.equal(probe.applied.length, 0, 'no poll while following');
|
||||
});
|
||||
|
||||
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
|
||||
},
|
||||
});
|
||||
assert.equal(await probe.methods.loadAt(T), false);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
assert.equal(probe.feed._lastError, 'Live data unavailable');
|
||||
});
|
||||
|
||||
test('a jump forgets how contacts moved; clearing drops every contact', () => {
|
||||
const calls = [];
|
||||
const flightState = {
|
||||
records: records(),
|
||||
_positionHistory: new Map([['abc123', []]]),
|
||||
_displayCourse: new Map([['abc123', 90]]),
|
||||
_groundSnap: { clear: () => calls.push('ground') },
|
||||
_displayFloorState: new Map([['abc123', {}]]),
|
||||
_drReconcileValid: true,
|
||||
_cachedDRFrame: 7,
|
||||
_billboards: new Map([['abc123', { id: 'abc123' }]]),
|
||||
_billboardCollection: { remove: () => calls.push('remove') },
|
||||
_cullPositions: new Map([['abc123', null]]),
|
||||
_detectionObjects: new Map([['abc123', {}]]),
|
||||
_trackedIcao: 'abc123',
|
||||
feed: { _count: 1, _lastUpdate: T },
|
||||
};
|
||||
const parts = {
|
||||
tracking: {
|
||||
_clearTrail: () => calls.push('trail'),
|
||||
_clearTracking: (_skip, { evicted }) => {
|
||||
calls.push(`untrack:${evicted}`);
|
||||
flightState._trackedIcao = null;
|
||||
},
|
||||
},
|
||||
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
|
||||
};
|
||||
const follow = createClockFollow({ flightState, parts });
|
||||
flightState.records.receive(observation(), view());
|
||||
follow.resetMotion();
|
||||
assert.equal(flightState.records.data.size, 0);
|
||||
assert.equal(flightState._positionHistory.size, 0);
|
||||
assert.equal(flightState._displayCourse.size, 0);
|
||||
assert.equal(flightState._displayFloorState.size, 0);
|
||||
assert.equal(flightState._drReconcileValid, false);
|
||||
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
|
||||
follow.clear();
|
||||
assert.equal(flightState._billboards.size, 0);
|
||||
assert.equal(flightState._detectionObjects.size, 0);
|
||||
assert.equal(flightState.feed._count, 0);
|
||||
assert.equal(flightState.feed._lastUpdate, null);
|
||||
assert.ok(calls.includes('untrack:true'));
|
||||
assert.ok(calls.includes('model:abc123'));
|
||||
});
|
||||
|
||||
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
|
||||
assert.equal(probe.feed._lastError, undefined);
|
||||
assert.equal(probe.feed._backoff, undefined);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ENRICH_AMBIENT_REFILL_TOKENS,
|
||||
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||
ENRICH_AMBIENT_PER_SWEEP,
|
||||
ENRICH_RETRY_COOLDOWN_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createEnrichment({
|
||||
@@ -17,11 +19,62 @@ export function createEnrichment({
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
function _enqueueEnrich(key, query, onData, priority = false) {
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { isTr3b } = services.aircraftPresentation;
|
||||
/**
|
||||
* Whether `key` is answered, queued, or cooling down after a failure. A
|
||||
* key past its cool-down is released here so it can be asked again.
|
||||
* @param {string} key Lookup key.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _enrichHeld(key) {
|
||||
if (flightState._enrichSeen.has(key)) return true;
|
||||
const retryAfter = flightState._enrichRetryAfter.get(key);
|
||||
if (retryAfter === undefined) return false;
|
||||
if (Date.now() < retryAfter) return true;
|
||||
flightState._enrichRetryAfter.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source failure is permanent: an unsupported lookup, or an HTTP
|
||||
* 4xx other than 401/403 (an authorization refusal that may clear), 408
|
||||
* (timeout) and 429 (rate limited). Anything else, including an error without a
|
||||
* status, is temporary.
|
||||
* @param {*} error The rejection, as `LiveSourceError` / `httpError` carry it.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _enrichFailureIsFinal(error) {
|
||||
if (error?.code === 'unsupported') return true;
|
||||
const status = Number(error?.status);
|
||||
return (
|
||||
Number.isInteger(status) &&
|
||||
status >= 400 &&
|
||||
status < 500 &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 408 &&
|
||||
status !== 429
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask each key at most once per session, except that a lookup whose source
|
||||
* failed temporarily (rejected or threw) may be asked again after
|
||||
* ENRICH_RETRY_COOLDOWN_MS; an answer, found or not, and a permanent
|
||||
* refusal (see _enrichFailureIsFinal) are final.
|
||||
* @param {string} key Session-unique lookup key.
|
||||
* @param {object} query The source's enrichment query.
|
||||
* @param {function(object):void} onData Receives a `found` answer.
|
||||
* @param {boolean} [priority] Front of the queue.
|
||||
* @param {function(*):?object} [read] Reads the source's answer first, for
|
||||
* a lookup whose answer may arrive in more than one shape.
|
||||
*/
|
||||
function _enqueueEnrich(key, query, onData, priority = false, read = null) {
|
||||
if (flightState.lifetime.signal.aborted) return;
|
||||
if (flightState._enrichSeen.has(key)) return;
|
||||
if (_enrichHeld(key)) return;
|
||||
flightState._enrichSeen.add(key);
|
||||
const job = { query, onData };
|
||||
const job = { key, query, onData, read };
|
||||
// Priority (tracked / model-eligible) goes to the FRONT so a deep ambient
|
||||
// backlog can never delay the plane the user just clicked or zoomed into.
|
||||
if (priority) flightState._enrichQueue.unshift(job);
|
||||
@@ -61,9 +114,24 @@ export function createEnrichment({
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
})
|
||||
.then((data) => {
|
||||
if (!lifetime.signal.aborted && data && data.found) job.onData(data);
|
||||
})
|
||||
.then(
|
||||
(answer) => {
|
||||
const data = job.read ? job.read(answer) : answer;
|
||||
if (!lifetime.signal.aborted && data && data.found)
|
||||
job.onData(data);
|
||||
},
|
||||
(error) => {
|
||||
// The source failed: release the key after a cool-down. A layer
|
||||
// teardown is not a failure and schedules nothing, and a
|
||||
// permanent refusal is as final as an answer.
|
||||
if (lifetime.signal.aborted || _enrichFailureIsFinal(error)) return;
|
||||
flightState._enrichSeen.delete(job.key);
|
||||
flightState._enrichRetryAfter.set(
|
||||
job.key,
|
||||
Date.now() + ENRICH_RETRY_COOLDOWN_MS,
|
||||
);
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
/* enrichment never surfaces errors */
|
||||
})
|
||||
@@ -119,6 +187,14 @@ export function createEnrichment({
|
||||
(data) => {
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
if (!meta) return;
|
||||
// A late answer for a callsign the plane no longer flies is dropped.
|
||||
if (
|
||||
String(meta.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase() !== cs
|
||||
)
|
||||
return;
|
||||
meta.routeCallsign = cs;
|
||||
meta.airline = data.airline || meta.airline;
|
||||
if (data.origin && data.destination)
|
||||
meta.route = { origin: data.origin, destination: data.destination };
|
||||
@@ -129,6 +205,31 @@ export function createEnrichment({
|
||||
); // route lookups only fire for the TRACKED plane — front of the queue
|
||||
}
|
||||
|
||||
/** The tracked plane's photo from Planespotters, asked once per hex at the
|
||||
* front of the queue like its route. A miss is final; a temporary failure
|
||||
* is asked again after the cool-down. A military aircraft or a converted
|
||||
* TR-3B is never looked up. The answer is read with
|
||||
* `parsePlanespottersPhoto` whatever shape the source returns (the upstream
|
||||
* answer, a server's cached shape, or an already-read photo), so only a
|
||||
* checked photo is ever stored. */
|
||||
function _requestPhotoEnrichment(icao24) {
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) return;
|
||||
const id = icao24.toLowerCase();
|
||||
if (isMilitaryIcao(id) || isTr3b(id)) return;
|
||||
_enqueueEnrich(
|
||||
`p:${id}`,
|
||||
{ kind: 'photo', id },
|
||||
(data) => {
|
||||
const meta = flightState.records.data.get(id);
|
||||
if (!meta) return; // evicted while the lookup was in flight
|
||||
const { src, width, height, photographer, link } = data;
|
||||
meta.photo = { src, width, height, photographer, link };
|
||||
},
|
||||
true,
|
||||
parsePlanespottersPhoto,
|
||||
);
|
||||
}
|
||||
|
||||
/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the
|
||||
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
|
||||
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
|
||||
@@ -196,7 +297,9 @@ export function createEnrichment({
|
||||
);
|
||||
const cand = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
||||
// Answered, queued, negative this session, or cooling down after a
|
||||
// failure: never spend a token on it.
|
||||
if (_enrichHeld(`t:${icao24}`)) continue;
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
||||
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||
@@ -233,6 +336,7 @@ export function createEnrichment({
|
||||
_drainEnrich,
|
||||
_requestTypeEnrichment,
|
||||
_requestRouteEnrichment,
|
||||
_requestPhotoEnrichment,
|
||||
_ambientBudgetKnobs,
|
||||
_refillAmbientBudget,
|
||||
_sweepAmbientEnrichment,
|
||||
|
||||
@@ -10,6 +10,9 @@ import { createLifecycle } from './lifecycle.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one civil-flight layer with application-owned scene services. */
|
||||
export function createCivilFlightLayer({
|
||||
source,
|
||||
@@ -29,6 +32,7 @@ export function createCivilFlightLayer({
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -45,7 +49,11 @@ export function createCivilFlightLayer({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -53,6 +61,19 @@ export function createCivilFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user