Let a host application place live contacts

createApplicationCatalog takes an optional placement provider. Its
layer(kind) hands Flights, Military Flights, AIS Vessels, Transit and Rail
a place(id, wallMs), and optionally observe(id, fix) and forget(id). While
live, each layer tells it every fix it accepts and draws a contact where it
places it: position, height (the layer's own when none is given), course
and speed. A null answer, a provider that throws, a kind it leaves out, or
no provider at all keeps the layer's own motion unchanged, and following
the world clock the layers never ask it. A transit vehicle or train it
holds still lets the render loop idle and is asked again each second;
vessels ask for frames only while it places one, and stop when the layer
turns off.

Tests show the layers draw and reconcile exactly as before without a
provider and with one that places nothing, draw where a provider places
them, and idle while it holds them still.
This commit is contained in:
Sameh Khamis committed 2026-11-14 15:07:25 -08:00
1 parent e53cfef1d1
commit 6f830a2e70
46 files changed
+1417 -34

No files matched your search

+10
View File
@@ -6,6 +6,7 @@ import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { layerPlacement } from '../data/placementProvider.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -63,6 +64,9 @@ export const APPLICATION_LAYER_METADATA = Object.freeze([
/** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails.
* `placement`, a host-supplied placement provider (../data/placementProvider.js),
* places live aircraft, vessels, transit and trains; without one each layer
* uses its own motion.
*/
export function createApplicationCatalog({
surface,
@@ -74,6 +78,7 @@ export function createApplicationCatalog({
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
placement = null,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -97,17 +102,20 @@ export function createApplicationCatalog({
const flights = createApplicationFlights({
surface,
source: sources.flights,
placement: layerPlacement(placement, 'aircraft'),
militaryRegistry,
resolveAsset,
});
const military = createApplicationMilitary({
surface,
source: sources.military,
placement: layerPlacement(placement, 'military'),
militaryRegistry,
resolveAsset,
});
const vessels = createApplicationVessels({
source: sources.vessels,
placement: layerPlacement(placement, 'vessels'),
options: vesselOptions,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
@@ -172,6 +180,7 @@ export function createApplicationCatalog({
createApplicationTransit({
surface,
source: sources.transit,
placement: layerPlacement(placement, 'transit'),
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
@@ -210,6 +219,7 @@ export function createApplicationCatalog({
createApplicationRail({
surface,
source: sources.rail,
placement: layerPlacement(placement, 'rail'),
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
+3
View File
@@ -16,9 +16,11 @@ import { fleetModelServices } from './fleetModels.js';
* Construct one layer using the application scene owners and a supplied
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
* preference, under which nearby vessels are drawn as fleet models.
* `placement`: a host-supplied placement for its live vessels, or null.
*/
export function createApplicationVessels({
source,
placement = null,
options = {},
displayParams = null,
resolveAsset,
@@ -38,6 +40,7 @@ export function createApplicationVessels({
worldFocus,
render,
readouts,
placement,
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
+4 -1
View File
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
/** Construct one layer using the application scene owners and a supplied source.
* `placement`: a host-supplied placement for its live aircraft, or null. */
export function createApplicationFlights({
surface,
source,
militaryRegistry,
placement = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
@@ -41,6 +43,7 @@ export function createApplicationFlights({
render,
groundSnap,
recession,
placement,
},
});
}
+4 -1
View File
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
/** Construct one layer using the application scene owners and a supplied source.
* `placement`: a host-supplied placement for its live aircraft, or null. */
export function createApplicationMilitary({
surface,
source,
militaryRegistry,
placement = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
@@ -41,6 +43,7 @@ export function createApplicationMilitary({
render,
groundSnap,
recession,
placement,
},
});
}
+4 -1
View File
@@ -16,13 +16,15 @@ import {
* ground floor, the same floor Transit and the aircraft stand on.
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby trains are drawn as fleet models.
* @param {{surface: object, source?: object,
* `placement` is a host-supplied placement for its live trains, or null.
* @param {{surface: object, source?: object, placement?: object|null,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationRail({
surface,
source,
placement = null,
displayParams = null,
resolveAsset,
}) {
@@ -36,6 +38,7 @@ export function createApplicationRail({
sprites,
picking,
overlays,
placement,
credits: { registerDynamicCredit, railFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+4 -1
View File
@@ -23,13 +23,15 @@ import {
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby vehicles are drawn as fleet models.
*
* @param {{surface: object, source?: object,
* `placement` is a host-supplied placement for its live vehicles, or null.
* @param {{surface: object, source?: object, placement?: object|null,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationTransit({
surface,
source,
placement = null,
displayParams = null,
resolveAsset,
}) {
@@ -43,6 +45,7 @@ export function createApplicationTransit({
sprites,
picking,
overlays,
placement,
credits: { registerDynamicCredit, transitFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+131
View File
@@ -0,0 +1,131 @@
/**
* A host-supplied placement provider: an application embedding the catalog
* may decide where its moving contacts are drawn while live. Each layer that
* takes one tells it every fix it accepts (`observe`) and asks it, each time
* it draws a contact, where to draw it now (`place`). A layer given none, or
* asking about a contact the provider does not place (`null`), draws the
* contact with its own motion, exactly as without a provider. A provider
* that throws is treated as having no answer for that call.
*
* Layers consult a provider only while live: following the world clock, the
* layer's own motion places every contact.
*
* @typedef {'aircraft'|'military'|'vessels'|'transit'|'rail'} PlacementKind
*
* @typedef {object} PlacementFix
* @property {number} timeMs The fix's own time, Unix ms.
* @property {number} lat
* @property {number} lon
* @property {number} [altitudeM] The height the layer draws the fix at,
* metres above the ellipsoid; NaN when the layer keeps its own height.
* @property {number} [speedMps] Reported ground speed, NaN for none.
* @property {number} [courseDeg] Reported course over ground, NaN for none.
* @property {number} [headingDeg] Reported heading, NaN for none.
* @property {boolean} [onGround] Reported on the ground (aircraft).
* @property {string} [feedId] The feed the contact belongs to (transit, rail).
*
* @typedef {object} Placement
* @property {number} lat
* @property {number} lon
* @property {number} altitudeM Metres above the ellipsoid, as the observed
* fixes' `altitudeM`; NaN keeps the layer's own height.
* @property {number} courseDeg Direction of travel, NaN for none.
* @property {number} speedMps NaN for none.
* @property {boolean} extrapolating Past the contact's newest fix.
*
* @typedef {object} LayerPlacement What a layer is handed: never throws.
* @property {(id: string, fix: PlacementFix) => void} observe
* @property {(id: string, wallMs: number) => Placement|null} place The
* answer is one object reused by every call: read it before the next.
* @property {(id: string) => void} forget The contact left the layer.
*
* @typedef {object} PlacementProvider
* @property {(kind: PlacementKind) => object|null|undefined} layer The
* placement for one kind of contact: an object with `place(id, wallMs)`
* and optionally `observe(id, fix)` and `forget(id)`, or null to leave that
* kind to its layer's own motion.
*/
/** The kinds of contact a provider may place. */
export const PLACEMENT_KINDS = Object.freeze([
'aircraft',
'military',
'vessels',
'transit',
'rail',
]);
const finiteOr = (value, fallback) =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
/**
* One kind's placement from a provider, guarded so a layer can call it every
* frame: null when there is no provider or it does not place `kind`.
* @param {PlacementProvider|null|undefined} provider
* @param {PlacementKind} kind
* @returns {LayerPlacement|null}
*/
export function layerPlacement(provider, kind) {
if (!PLACEMENT_KINDS.includes(kind))
throw new TypeError(`Unknown placement kind: ${kind}`);
if (provider == null) return null;
if (typeof provider.layer !== 'function')
throw new TypeError('A placement provider needs a layer(kind) function');
const placement = provider.layer(kind);
if (placement == null) return null;
if (typeof placement.place !== 'function')
throw new TypeError(`The ${kind} placement needs a place(id, wallMs)`);
const observe =
typeof placement.observe === 'function' ? placement.observe : null;
const forget =
typeof placement.forget === 'function' ? placement.forget : null;
// Asked for every drawn contact every frame: one answer, reused.
const answer = {
lat: NaN,
lon: NaN,
altitudeM: NaN,
courseDeg: NaN,
speedMps: NaN,
extrapolating: false,
};
return Object.freeze({
observe(id, fix) {
if (!observe) return;
try {
observe.call(placement, id, fix);
} catch {
// A provider that fails to take a fix only answers less.
}
},
place(id, wallMs) {
let placed;
try {
placed = placement.place(id, wallMs);
} catch {
return null;
}
if (
!placed ||
!Number.isFinite(placed.lat) ||
!Number.isFinite(placed.lon) ||
Math.abs(placed.lat) > 90
)
return null;
answer.lat = placed.lat;
answer.lon = placed.lon;
answer.altitudeM = finiteOr(placed.altitudeM, NaN);
answer.courseDeg = finiteOr(placed.courseDeg, NaN);
answer.speedMps = finiteOr(placed.speedMps, NaN);
answer.extrapolating = placed.extrapolating === true;
return answer;
},
forget(id) {
if (!forget) return;
try {
forget.call(placement, id);
} catch {
// Nothing to undo.
}
},
});
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PLACEMENT_KINDS, layerPlacement } from './placementProvider.js';
test('no provider, or a provider that leaves a kind alone, gives no placement', () => {
for (const kind of PLACEMENT_KINDS) {
assert.equal(layerPlacement(null, kind), null);
assert.equal(layerPlacement(undefined, kind), null);
assert.equal(layerPlacement({ layer: () => null }, kind), null);
}
assert.throws(() => layerPlacement(null, 'satellites'), TypeError);
assert.throws(() => layerPlacement({}, 'aircraft'), TypeError);
assert.throws(
() => layerPlacement({ layer: () => ({ observe() {} }) }, 'aircraft'),
TypeError,
);
});
test('a placement is asked by id and wall time, and its answer is checked', () => {
const asked = [];
const seen = [];
const forgotten = [];
const answers = new Map([
[
'a',
{ lat: 51.5, lon: -0.1, altitudeM: 900, courseDeg: 270, speedMps: 80 },
],
['partial', { lat: 10, lon: 20, altitudeM: 'high', extrapolating: true }],
['off-globe', { lat: 91, lon: 0 }],
['nan', { lat: NaN, lon: 0 }],
]);
const provider = {
layer: (kind) =>
kind === 'aircraft'
? {
observe: (id, fix) => seen.push([id, fix.timeMs]),
place(id, wallMs) {
asked.push([id, wallMs]);
return answers.get(id) ?? null;
},
forget: (id) => forgotten.push(id),
}
: null,
};
assert.equal(layerPlacement(provider, 'vessels'), null);
const placement = layerPlacement(provider, 'aircraft');
placement.observe('a', { timeMs: 5, lat: 0, lon: 0 });
assert.deepEqual(seen, [['a', 5]]);
const placed = placement.place('a', 1000);
assert.deepEqual(
{ ...placed },
{
lat: 51.5,
lon: -0.1,
altitudeM: 900,
courseDeg: 270,
speedMps: 80,
extrapolating: false,
},
);
const partial = placement.place('partial', 2000);
assert.equal(partial, placed, 'one answer object, reused');
assert.ok(Number.isNaN(partial.altitudeM));
assert.ok(Number.isNaN(partial.courseDeg));
assert.ok(Number.isNaN(partial.speedMps));
assert.equal(partial.extrapolating, true);
assert.equal(placement.place('off-globe', 3000), null);
assert.equal(placement.place('nan', 3000), null);
assert.equal(placement.place('unknown', 3000), null);
assert.deepEqual(
asked.map(([id]) => id),
['a', 'partial', 'off-globe', 'nan', 'unknown'],
);
placement.forget('a');
assert.deepEqual(forgotten, ['a']);
});
test('a provider that throws has no answer, and observe and forget are optional', () => {
const placement = layerPlacement(
{
layer: () => ({
place() {
throw new Error('not ready');
},
}),
},
'transit',
);
assert.equal(placement.place('x', 0), null);
assert.doesNotThrow(() => placement.observe('x', { timeMs: 0 }));
assert.doesNotThrow(() => placement.forget('x'));
const failing = layerPlacement(
{
layer: () => ({
place: () => null,
observe() {
throw new Error('full');
},
forget() {
throw new Error('gone');
},
}),
},
'rail',
);
assert.doesNotThrow(() => failing.observe('x', { timeMs: 0 }));
assert.doesNotThrow(() => failing.forget('x'));
});
+7 -1
View File
@@ -1,14 +1,20 @@
import { forgetPlacedAircraft } from './placed.js';
/**
* 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 }) {
export function createClockFollow({ flightState, services, 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() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset();
flightState._positionHistory.clear();
flightState._displayCourse.clear();
+19
View File
@@ -217,6 +217,25 @@ test('a jump forgets how contacts moved; clearing drops every contact', () => {
assert.ok(calls.includes('model:abc123'));
});
test('leaving the live edge forgets every contact a host placement was told of', () => {
const forgotten = [];
const flightState = {
records: records(),
_positionHistory: new Map([
['abc123', []],
['def456', []],
]),
_displayCourse: new Map(),
_groundSnap: { clear() {} },
_displayFloorState: new Map(),
};
const parts = { tracking: { _clearTrail() {} } };
const services = { placement: { forget: (id) => forgotten.push(id) } };
createClockFollow({ flightState, services, parts }).resetMotion();
assert.deepEqual(forgotten, ['abc123', 'def456']);
assert.equal(flightState._positionHistory.size, 0);
});
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
const probe = ingestion({
async getSnapshot() {
+1
View File
@@ -35,6 +35,7 @@ export function createCivilFlightLayer({
parts.clockFollow = createClockFollow(context);
const applySnapshot = createFlightSnapshotRenderer({
flightState,
placement: services.placement ?? null,
records: flightState.records,
militaryRegistry: services.militaryRegistry,
groundFloor: services.groundFloor,
+9
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from './placed.js';
export function createLifecycle({
flightState,
@@ -72,6 +73,10 @@ export function createLifecycle({
flightState._cullPositions.clear();
flightState._detectionObjects = new Map();
flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
@@ -291,6 +296,10 @@ export function createLifecycle({
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
+14
View File
@@ -16,6 +16,7 @@ import {
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { placeLiveAircraft } from './placed.js';
import {
modelVisualAnchor,
modelAnchorWorld,
@@ -173,6 +174,19 @@ export function createMotion({
}
const out = result || new Cesium.Cartesian3();
// A host-supplied placement decides where a live contact is drawn; with
// none, or no answer for this contact, the layer's own motion below does.
if (
placeLiveAircraft(
flightState,
services.placement,
icao24,
info,
history,
out,
)
)
return out;
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime =
+191 -2
View File
@@ -3,8 +3,11 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js';
import { createMotion as createMilitaryMotion } from '../military/motion.js';
import { layerPlacement } from '../../data/placementProvider.js';
import { observeLiveAircraft } from './placed.js';
function fixture() {
function fixture(placement = null) {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
@@ -16,7 +19,7 @@ function fixture() {
};
const motion = createMotion({
flightState,
services: { groundFloor: {}, camera: {}, focus: {} },
services: { groundFloor: {}, camera: {}, focus: {}, placement },
parts: {},
});
return { flightState, motion };
@@ -184,3 +187,189 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
);
assert.equal(reads, 2);
});
/** Two fixes a minute apart, read across the minute from a clock at `start`. */
function placedScenario(create, placement, record) {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
now: () => 0,
};
const motion = create({
flightState,
services: { groundFloor: {}, camera: {}, focus: {}, placement },
parts: {},
});
const start = Date.parse('2026-10-24T12:00:00Z');
const at = (seconds) =>
Cesium.JulianDate.fromDate(new Date(start + seconds * 1000));
flightState.records.data.set('abc123', record);
flightState._positionHistory.set('abc123', [
{
time: at(0),
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
velocity: 100,
},
{
time: at(60),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
velocity: 100,
},
]);
const drawn = [];
for (let seconds = 0; seconds <= 150; seconds += 7.5) {
flightState.now = () => start + seconds * 1000;
const position = motion._deadReckon('abc123', new Cesium.Cartesian3());
drawn.push({
position,
course: flightState._drCourseDeg,
speed: flightState._drSpeedMps,
hold: flightState._drCourseHold,
});
}
return { flightState, motion, drawn, start };
}
const declining = (asked) =>
layerPlacement(
{
layer: () => ({
place: (id, wallMs) => {
asked.push([id, wallMs]);
return null;
},
}),
},
'aircraft',
);
// Each kind's record carries only its own kinematics fields.
for (const [name, create, record] of [
[
'civil',
createMotion,
{ velocity: 100, true_track: 90, renderAltitudeM: 1234 },
],
[
'military',
createMilitaryMotion,
{ speedMps: 100, track: 90, renderAltitudeM: 1234 },
],
]) {
test(`${name} aircraft move as before without a placement or with one that places nothing`, () => {
const own = placedScenario(create, null, record).drawn;
const asked = [];
const declined = placedScenario(create, declining(asked), record).drawn;
assert.equal(asked.length, own.length);
assert.equal(declined.length, own.length);
for (let i = 0; i < own.length; i++) {
assert.ok(
Cesium.Cartesian3.equals(declined[i].position, own[i].position),
);
assert.equal(declined[i].course, own[i].course);
assert.equal(declined[i].speed, own[i].speed);
assert.equal(declined[i].hold, own[i].hold);
}
});
test(`${name} aircraft are drawn where a placement places them, live only`, () => {
const asked = [];
const placement = layerPlacement(
{
layer: () => ({
place(id, wallMs) {
asked.push(wallMs);
return wallMs % 2 === 0
? {
lat: 30.5,
lon: -96.5,
altitudeM: 2000,
courseDeg: 45,
speedMps: 0.5,
extrapolating: true,
}
: { lat: 30.6, lon: -96.4 };
},
}),
},
'aircraft',
);
const { flightState, motion, start } = placedScenario(
create,
placement,
record,
);
flightState.now = () => start + 2000;
const out = motion._deadReckon('abc123', new Cesium.Cartesian3());
close(out, Cesium.Cartesian3.fromDegrees(-96.5, 30.5, 2000));
assert.equal(flightState._drCourseDeg, 45);
assert.equal(flightState._drSpeedMps, 0.5);
assert.equal(flightState._drCourseHold, true);
// No height, course or speed placed: the record's drawn height and kinematics.
flightState.now = () => start + 2001;
close(
motion._deadReckon('abc123', new Cesium.Cartesian3()),
Cesium.Cartesian3.fromDegrees(-96.4, 30.6, 1234),
);
assert.equal(flightState._drCourseDeg, 90);
assert.equal(flightState._drSpeedMps, 100);
// Following the world clock the layer's own motion places it, unasked.
const before = asked.length;
flightState.timeline = { active: () => true };
const own = motion._deadReckon('abc123', new Cesium.Cartesian3());
assert.equal(asked.length, before);
assert.ok(Cesium.Cartesian3.distance(own, out) > 1000);
});
}
test('an aircraft fix is told to the placement live, with its drawn height', () => {
const told = [];
const placement = layerPlacement(
{
layer: () => ({
place: () => null,
observe: (id, fix) => told.push([id, fix]),
}),
},
'aircraft',
);
const state = { timeline: { active: () => false } };
const meta = { rawLat: 1, rawLon: 2, renderAltitudeM: 300, onGround: false };
observeLiveAircraft(state, placement, 'abc123', 5000, meta, 80, 270);
observeLiveAircraft(
state,
placement,
'abc123',
6000,
{ ...meta, renderAltitudeInvented: true, onGround: true },
null,
undefined,
);
observeLiveAircraft(state, null, 'abc123', 7000, meta, 80, 270);
state.timeline.active = () => true;
observeLiveAircraft(state, placement, 'abc123', 8000, meta, 80, 270);
assert.equal(told.length, 2);
assert.deepEqual(told[0], [
'abc123',
{
timeMs: 5000,
lat: 1,
lon: 2,
altitudeM: 300,
speedMps: 80,
courseDeg: 270,
headingDeg: NaN,
onGround: false,
},
]);
assert.ok(Number.isNaN(told[1][1].altitudeM));
assert.ok(Number.isNaN(told[1][1].speedMps));
assert.ok(Number.isNaN(told[1][1].courseDeg));
assert.equal(told[1][1].onGround, true);
});
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { COURSE_HOLD_SPEED_MPS } from '../../data/motionModel.js';
import { followsClock } from './policy.js';
/** Scratch for a placed contact's held height. */
const _placedCarto = new Cesium.Cartographic();
/**
* Where the host-supplied placement (`services.placement`,
* ../../data/placementProvider.js) draws a live aircraft now, written into
* `out` with the course scratches `_deadReckon` sets on `flightState`; false
* when there is no placement, the layer follows the world clock, or it has
* no answer for this aircraft, and the layer's own motion places it. A
* placement without a height keeps the aircraft's newest drawn height.
* @param {object} flightState The civil or military flight state.
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {object|undefined} info The aircraft's record.
* @param {{position: Cesium.Cartesian3}[]} history Its fixes, newest last.
* @param {Cesium.Cartesian3} out
* @returns {boolean}
*/
export function placeLiveAircraft(
flightState,
placement,
icao24,
info,
history,
out,
) {
if (!placement || followsClock(flightState)) return false;
const placed = placement.place(icao24, flightState.now());
if (!placed) return false;
let heightM = placed.altitudeM;
if (!Number.isFinite(heightM)) heightM = info?.renderAltitudeM;
if (!Number.isFinite(heightM))
heightM = Cesium.Cartographic.fromCartesian(
history[history.length - 1].position,
Cesium.Ellipsoid.WGS84,
_placedCarto,
).height;
const speed = Number.isFinite(placed.speedMps)
? placed.speedMps
: // Civil records carry velocity and true_track, military speedMps and track.
Number.isFinite(info?.velocity)
? info.velocity
: info?.speedMps || 0;
flightState._drCourseDeg = Number.isFinite(placed.courseDeg)
? placed.courseDeg
: Number.isFinite(info?.true_track)
? info.true_track
: info?.track || 0;
flightState._drSpeedMps = speed;
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
flightState._drExtrapolating = placed.extrapolating;
Cesium.Cartesian3.fromDegrees(
placed.lon,
placed.lat,
heightM,
Cesium.Ellipsoid.WGS84,
out,
);
return true;
}
/**
* Tell the host-supplied placement, if any, that the aircraft `ids` left the
* layer (removed, the layer off, or the world clock moved it to another time).
* @param {object|null|undefined} placement
* @param {Iterable<string>} ids
*/
export function forgetPlacedAircraft(placement, ids) {
if (!placement) return;
for (const id of ids) placement.forget(id);
}
/**
* Tell the host-supplied placement an aircraft's fix as the layer took it
* (nothing while the layer follows the world clock): its own time, its
* position, the height the layer draws it at (NaN when that height is
* invented), and its reported speed, course and ground state.
* @param {object} flightState
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {number} fixEpochMs
* @param {{rawLat: number, rawLon: number, renderAltitudeM: number,
* renderAltitudeInvented?: boolean, onGround?: boolean}} meta
* @param {number|null|undefined} speedMps
* @param {number|null|undefined} courseDeg
*/
export function observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
speedMps,
courseDeg,
) {
if (!placement || followsClock(flightState)) return;
placement.observe(icao24, {
timeMs: fixEpochMs,
lat: meta.rawLat,
lon: meta.rawLon,
altitudeM:
meta.renderAltitudeInvented === true ? NaN : meta.renderAltitudeM,
speedMps: Number.isFinite(speedMps) ? speedMps : NaN,
courseDeg: Number.isFinite(courseDeg) ? courseDeg : NaN,
headingDeg: NaN,
onGround: meta.onGround === true,
});
}
+15
View File
@@ -9,10 +9,12 @@ import {
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import { followsClock } from './policy.js';
import { observeLiveAircraft } from './placed.js';
/** Apply reconciled aircraft records to Cesium resources and follow state. */
export function createFlightSnapshotRenderer({
flightState,
placement = null,
records,
militaryRegistry,
groundFloor,
@@ -65,6 +67,7 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24);
@@ -98,6 +101,17 @@ export function createFlightSnapshotRenderer({
);
const isTracked = icao24 === flightState._trackedIcao;
// A host-supplied placement, if any, is told every fix as taken.
observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
meta.velocity,
meta.true_track,
);
// Append to position history stamped with the FEED's fix epoch
// (time_position), not client receipt time — OpenSky positions arrive
// 5-15s stale and receipt-time stamping is what caused the
@@ -311,6 +325,7 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24);
+1
View File
@@ -1289,6 +1289,7 @@ export function createTracking({
flightState._billboards.delete(icao24);
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
flightState.records.data.delete(icao24);
services.placement?.forget(icao24);
flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24);
+7 -1
View File
@@ -1,14 +1,20 @@
import { forgetPlacedAircraft } from '../flights/placed.js';
/**
* 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 }) {
export function createClockFollow({ flightState, services, parts }) {
/**
* Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses and the tracked trail
* belong to the time the clock left.
*/
function resetMotion() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset();
flightState._positionHistory.clear();
flightState._displayCourse.clear();
+1
View File
@@ -31,6 +31,7 @@ export function createMilitaryFlightLayer({
parts.clockFollow = createClockFollow(context);
const applySnapshot = createMilitarySnapshotRenderer({
flightState,
placement: services.placement ?? null,
records: flightState.records,
groundFloor: services.groundFloor,
meshFloor: services.meshFloor,
+9
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from '../flights/placed.js';
export function createLifecycle({
flightState,
@@ -66,6 +67,10 @@ export function createLifecycle({
flightState._cullPositions.clear();
flightState._detectionObjects = new Map();
flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
@@ -264,6 +269,10 @@ export function createLifecycle({
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear();
flightState._displayCourse.clear();
flightState._groundSnap.clear();
+14
View File
@@ -15,6 +15,7 @@ import {
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { placeLiveAircraft } from '../flights/placed.js';
import {
modelVisualAnchor,
modelAnchorWorld,
@@ -70,6 +71,19 @@ export function createMotion({
}
const out = result || new Cesium.Cartesian3();
// A host-supplied placement decides where a live contact is drawn; with
// none, or no answer for this contact, the layer's own motion below does.
if (
placeLiveAircraft(
flightState,
services.placement,
icao24,
info,
history,
out,
)
)
return out;
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
+14
View File
@@ -15,10 +15,12 @@ import {
TRACKED_ICON_COLOR,
followsClock,
} from './policy.js';
import { observeLiveAircraft } from '../flights/placed.js';
/** Apply military record changes to Cesium resources and existing follow operations. */
export function createMilitarySnapshotRenderer({
flightState,
placement = null,
records,
groundFloor,
meshFloor,
@@ -73,6 +75,17 @@ export function createMilitarySnapshotRenderer({
);
const isTracked = icao24 === flightState._trackedIcao;
// A host-supplied placement, if any, is told every fix as taken.
observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
meta.speedMps,
meta.track,
);
// Append to position history stamped with the FEED's fix epoch.
// adsb.lol's seen_pos is the AGE in seconds of the last position
// report, so the fix epoch is receipt time minus that age. Only
@@ -324,6 +337,7 @@ export function createMilitarySnapshotRenderer({
flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24);
+29
View File
@@ -19,6 +19,7 @@ import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { publicRailCatalog } from '../../data/railFeeds.js';
import { pointAtS } from '../../data/railPaths.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
import { PLACED_WAKE_MS } from '../transit/movement.js';
const M_PER_DEG = 111_320;
const LAT = 42.36;
@@ -502,3 +503,31 @@ test('turning the layer off ends the hold', async (t) => {
assert.equal(app.state()._cockpitLostFeedId, null);
assert.equal(app.layer.getCockpitSubject(), null);
});
test('a train a placement holds still stops animating, and moves on when the placement does', async (t) => {
let spot = { lat: LAT, lon: LON0 + 3000 / KX };
const app = await onTheLine(t, {
placement: {
layer: () => ({
place: (id) => (id === T1 ? { ...spot, speedMps: 0 } : null),
}),
},
});
app.run(1_000);
const entry = app.entry(T1);
assert.equal(entry.placed, true);
assert.equal(app.state()._moving.has(entry), false, 'parked: not animating');
assert.notEqual(entry.wakeTimer, null, 'a wake asks the placement again');
assert.ok(app.state()._moving.has(app.entry(T2)), 'its own motion runs on');
spot = { lat: LAT, lon: spot.lon + 10 / KX };
t.mock.timers.tick(PLACED_WAKE_MS);
assert.ok(app.state()._moving.has(entry), 'moving again');
for (let i = 0; i < 3; i += 1) {
spot = { lat: LAT, lon: spot.lon + 1 / KX };
app.run(250);
assert.ok(app.state()._moving.has(entry), 'while it moves');
}
app.run(250);
assert.equal(app.state()._moving.has(entry), false, 'stopped again');
});
+3
View File
@@ -82,6 +82,7 @@ export function createIngestion({ state, services, parts, source }) {
state._moving.delete(entry);
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
entry.placement?.forget(key);
state._trains.delete(key);
state._revision += 1;
// A copy it hid may now be the one to draw.
@@ -274,6 +275,8 @@ export function createIngestion({ state, services, parts, source }) {
const entry = {
key,
feedId: feed.id,
// A host-supplied placement, if any, draws it while live.
placement: services.placement ?? null,
marker: null,
category: record.category,
railMode: record.mode,
+1
View File
@@ -35,6 +35,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
for (const entry of state._trains.values()) {
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
entry.placement?.forget(entry.key);
}
state._trains.clear();
state._moving.clear();
+30 -10
View File
@@ -1,5 +1,10 @@
import * as Cesium from 'cesium';
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
import {
PLACED_WAKE_MS,
applyDisplayCourse,
playbackMoving,
updatePlayback,
} from '../transit/movement.js';
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
import {
COLD_CELL_FLUSH_MS,
@@ -551,8 +556,11 @@ export function createRendering({ state, services, parts }) {
*/
function place(entry) {
const sample = entry.sample;
// Placed by a host-supplied placement, it is drawn where that puts it.
const onPath =
entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint);
!entry.placed &&
entry.path &&
placeOnPath(entry.path, sample, entry.segment, pathPoint);
const lat = onPath
? pathPoint.lat
: Number.isFinite(sample?.lat)
@@ -629,16 +637,26 @@ export function createRendering({ state, services, parts }) {
if (!entry.marker) return;
place(entry);
const sample = entry.sample;
const moving =
playing(entry) &&
sample?.phase === 'playing' &&
sample.segmentSpeedMps > 0;
const moving = playing(entry) && playbackMoving(entry);
if (moving) {
state._moving.add(entry);
return;
}
state._moving.delete(entry);
if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) {
if (!playing(entry)) return;
// A train a host-supplied placement holds still is asked again every
// PLACED_WAKE_MS: it moves on once the placement does.
const monoNow = performance.now();
const placedWake = entry.placed === true;
const boundary = sample?.nextWakeMonoMs;
const wakeMonoMs = placedWake
? Math.min(
Number.isFinite(boundary) ? boundary : Infinity,
monoNow + PLACED_WAKE_MS,
)
: boundary;
if (Number.isFinite(wakeMonoMs)) {
const atBoundary = !placedWake || boundary <= wakeMonoMs;
entry.wakeTimer = setTimeout(
() => {
entry.wakeTimer = null;
@@ -646,9 +664,10 @@ export function createRendering({ state, services, parts }) {
updatePlayback(entry, Date.now(), performance.now());
schedulePlayback(entry);
syncRenderHold();
governorRequestRender('rail-motion-boundary');
if (atBoundary || state._moving.has(entry))
governorRequestRender('rail-motion-boundary');
},
Math.max(1, sample.nextWakeMonoMs - performance.now()),
Math.max(1, wakeMonoMs - monoNow),
);
}
}
@@ -851,7 +870,8 @@ export function createRendering({ state, services, parts }) {
place(entry);
if (
entry.sample.phase !== 'playing' ||
entry.sample.segmentSpeedMps === 0
entry.sample.segmentSpeedMps === 0 ||
(entry.placed === true && entry.placedMoving !== true)
)
schedulePlayback(entry);
}
+3
View File
@@ -86,6 +86,7 @@ export function createIngestion({ state, services, parts, source }) {
state._heightDirty.delete(entry);
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
entry.placement?.forget(key);
state._vehicles.delete(key);
state._detectRevision += 1;
}
@@ -327,6 +328,8 @@ export function createIngestion({ state, services, parts, source }) {
entry = {
key,
feedId: feed.id,
// A host-supplied placement, if any, draws it while live.
placement: services.placement ?? null,
marker: null,
mode,
// The GTFS route type its model is chosen by, when it has one.
+2
View File
@@ -147,6 +147,7 @@ export function createLifecycle({ state, services, parts }) {
entry.detectContact = null;
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
entry.placement?.forget(entry.key);
}
state._vehicles.clear();
parts.height.clear();
@@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) {
entry.detectContact = null;
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
entry.placement?.forget(entry.key);
}
state._vehicles.clear();
parts.rendering.stopModels();
+109
View File
@@ -12,6 +12,8 @@ import {
} from './policy.js';
import { FLOOR_WARM_PER_POLL } from './policy.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
import { layerPlacement } from '../../data/placementProvider.js';
import { PLACED_WAKE_MS } from './movement.js';
import {
FUTURE_STAMP_TOLERANCE_MS,
ORDER_REJECTS_BEFORE_RESET,
@@ -183,6 +185,8 @@ function harness(
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
// looking straight down for them; without them vehicles stay icons.
fleet = null,
// A host-supplied placement (../../data/placementProvider.js), or none.
placement = null,
} = {},
) {
const viewRect = { ...view };
@@ -291,6 +295,7 @@ function harness(
source,
services: {
...(fleet ? { fleet } : {}),
...(placement ? { placement: layerPlacement(placement, 'transit') } : {}),
ground,
mesh: {
sampleMeshFloorCells(scene, points, options) {
@@ -1156,6 +1161,110 @@ test('a parked fleet that keeps reporting the same position stops asking for fra
'only the vehicle that actually moved is animating',
);
});
test('a vehicle a placement holds still stops asking for frames, and moves on when the placement does', async (t) => {
// The placement parks bus-1 at a terminal until `spot` moves.
let spot = { lat: 42.3605, lon: -71.0605 };
let asked = 0;
const app = harness(t, {
placement: {
layer: () => ({
place: () => {
asked += 1;
return { ...spot, courseDeg: 0, speedMps: 0 };
},
}),
},
});
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('bus-1', 42.36, -71.06, reported())],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.settle();
const entry = app.vehicles()[0];
assert.equal(entry.placed, true);
for (let i = 0; i < 3; i += 1) {
app.advance(16);
app.frame();
}
assert.equal(app.state()._moving.has(entry), false, 'parked: not animating');
assert.equal(app.holds().includes('transit'), false, 'and the loop idles');
assert.notEqual(entry.wakeTimer, null, 'a wake asks the placement again');
assert.ok(Math.abs(lat(entry) - 42.3605) < 1e-6, 'drawn where placed');
// Asked again while it stays put: still idle, the wake re-armed.
const before = asked;
app.advance(PLACED_WAKE_MS);
assert.ok(asked > before, 'the wake asked the placement');
assert.equal(app.state()._moving.has(entry), false);
assert.equal(app.holds().includes('transit'), false);
// The placement moves it: the next wake picks that up and the loop runs.
spot = { lat: 42.361, lon: -71.0605 };
app.advance(PLACED_WAKE_MS);
assert.equal(app.state()._moving.has(entry), true, 'moving again');
assert.ok(app.holds().includes('transit'), 'and holds the loop');
assert.ok(Math.abs(lat(entry) - 42.361) < 1e-6);
for (let i = 0; i < 3; i += 1) {
spot = { lat: spot.lat + 1e-5, lon: spot.lon };
app.advance(16);
app.frame();
assert.equal(app.state()._moving.has(entry), true, 'while it moves');
}
// It stops again: the next frame that sees it unchanged lets go.
app.advance(16);
app.frame();
assert.equal(app.state()._moving.has(entry), false, 'stopped again');
assert.equal(app.holds().includes('transit'), false);
});
test('a vehicle a placement draws is shown from a high camera, as one it does not', async (t) => {
// Far above the ground no floor is asked for, so the fixes carry no height
// and the vehicle stands on the ellipsoid, placed or not.
const app = harness(t, {
at: { lat: 52.09, lon: 5.12 },
altitude: 1_500_000,
view: { west: -78.084, south: 20, east: 88.084, north: 84 },
placement: {
layer: () => ({
place: (id) =>
id.endsWith('nl-1')
? { lat: 52.091, lon: 5.12, courseDeg: 0, speedMps: 0 }
: null,
}),
},
});
const now = Date.now();
app.serve('ovapi-nl', () => ({
status: 200,
body: snapshot(
'ovapi-nl',
'OVapi',
[
vehicle('nl-1', 52.09, 5.12, now / 1000),
vehicle('nl-2', 51.92, 4.48, now / 1000),
],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.advance(250);
app.frame();
const placed = app.vehicles().find((entry) => entry.placed === true);
assert.ok(placed, 'one vehicle is placed');
const shown = app.vehicles().filter((entry) => entry.marker?.show !== false);
assert.equal(shown.length, 2, 'both vehicles are drawn');
assert.ok(Math.abs(lat(placed) - 52.091) < 1e-6, 'where placed');
});
test('the last vehicle leaving mid-segment takes the render hold with it', async (t) => {
const app = harness(t);
const fetchedAt = Date.now();
+54
View File
@@ -186,13 +186,57 @@ export function recordFix(entry, fix, observation = {}) {
);
entry.currentEpoch = latest.epoch;
if (context) entry.track.epochs.set(latest.epoch, context);
entry.placement?.observe(entry.key, {
timeMs: fix.t,
lat: fix.lat,
lon: fix.lon,
altitudeM: NaN,
speedMps: NaN,
courseDeg: Number.isFinite(fix.bearingDeg) ? fix.bearingDeg : NaN,
headingDeg: NaN,
feedId: entry.feedId,
});
}
return result;
}
/**
* How often a vehicle a host-supplied placement holds still is asked again
* whether it moves, so it resumes without the frame loop running for it.
*/
export const PLACED_WAKE_MS = 1000;
/**
* Advance a vehicle's playback to now. With a host-supplied placement
* (`entry.placement`, ../../data/placementProvider.js) that places it, it is
* drawn there, its course and speed the placement's (`entry.placed`); the
* playback's own phase, motion and segment stand. `entry.placedMoving` says
* whether the placed position changed since the placement was last asked at
* an earlier time (true for its first answer, so one more frame can tell).
*/
export function updatePlayback(entry, wallNowMs, monoNowMs) {
entry.clocks.wallNowMs = wallNowMs;
entry.clocks.monoNowMs = monoNowMs;
advance(entry.track, entry.clocks, entry.sample);
const placed = entry.placement?.place(entry.key, wallNowMs) ?? null;
const wasPlaced = entry.placed === true;
entry.placed = placed !== null;
if (!placed) entry.placedMoving = false;
else if (!wasPlaced) entry.placedMoving = true;
else if (wallNowMs !== entry.placedAtWallMs)
entry.placedMoving =
placed.lat !== entry.placedLat || placed.lon !== entry.placedLon;
if (placed) {
entry.placedLat = placed.lat;
entry.placedLon = placed.lon;
entry.placedAtWallMs = wallNowMs;
const sample = entry.sample;
sample.lat = placed.lat;
sample.lon = placed.lon;
if (Number.isFinite(placed.courseDeg))
sample.segmentCourseDeg = placed.courseDeg;
if (Number.isFinite(placed.speedMps))
sample.segmentSpeedMps = placed.speedMps;
}
entry.resets = entry.track.resets;
const segment = entry.segment;
readFix(
@@ -217,6 +261,16 @@ export function syncPlayback(entry, wallNowMs, monoNowMs = performance.now()) {
}
updatePlayback(entry, wallNowMs, monoNowMs);
}
/**
* Whether a vehicle's drawn position changes on its own: a vehicle a
* host-supplied placement draws moves while that placement's position
* changes; any other while its playback glides along a segment.
*/
export function playbackMoving(entry) {
if (entry.placed === true) return entry.placedMoving === true;
const sample = entry.sample;
return sample?.phase === 'playing' && sample.segmentSpeedMps > 0;
}
export function playbackPosition(entry) {
if (!entry?.track) return null;
return {
+99
View File
@@ -10,6 +10,7 @@ import {
} from './movement.js';
import { buildTransitSelectionCopy } from './policy.js';
import { sampleAt } from '../../data/contactPlayback.js';
import { layerPlacement } from '../../data/placementProvider.js';
const entry = () => {
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
initializePlayback(e);
@@ -163,3 +164,101 @@ test('course fallback prefers held course, then finite reported bearing, then un
assert.equal(e.courseDeg, expected);
}
});
const placedEntry = (placement) => {
const e = {
key: 'feed:7',
feedId: 'feed',
mode: 'bus',
modeInferred: false,
courseDeg: null,
placement,
};
initializePlayback(e);
return e;
};
const drive = (e) => {
recordFix(e, { ...fix(0), bearingDeg: 10 });
recordFix(e, { ...fix(15000, 42.001), bearingDeg: 20 });
recordFix(e, fix(30000, 42.002));
const samples = [];
for (let wall = 0; wall <= 120000; wall += 2500) {
updatePlayback(e, wall, wall);
samples.push({ ...e.sample });
}
return samples;
};
test('without a placement, or one that places nothing, playback is the layer own', () => {
const own = drive(placedEntry(null));
const declined = [];
const placement = layerPlacement(
{
layer: () => ({
observe: (id, f) => declined.push([id, f.timeMs]),
place: () => null,
}),
},
'transit',
);
const e = placedEntry(placement);
assert.deepEqual(drive(e), own);
assert.equal(e.placed, false);
assert.deepEqual(declined, [
['feed:7', 0],
['feed:7', 15000],
['feed:7', 30000],
]);
});
test('a placement draws the vehicle where it places it, keeping the playback state', () => {
const own = drive(placedEntry(null));
const e = placedEntry(
layerPlacement(
{
layer: () => ({
place: (id, wallMs) =>
wallMs >= 60000
? { lat: 1, lon: 2, courseDeg: 45, speedMps: 7 }
: { lat: 3, lon: 4 },
}),
},
'transit',
),
);
const placed = drive(e);
assert.equal(e.placed, true);
for (let i = 0; i < own.length; i++) {
const late = i * 2500 >= 60000;
assert.equal(placed[i].lat, late ? 1 : 3);
assert.equal(placed[i].lon, late ? 2 : 4);
assert.equal(placed[i].phase, own[i].phase);
assert.equal(placed[i].motion, own[i].motion);
if (late) {
assert.equal(placed[i].segmentCourseDeg, 45);
assert.equal(placed[i].segmentSpeedMps, 7);
} else {
// No course or speed placed: the playback's own stand.
assert.deepEqual(
[placed[i].segmentCourseDeg, placed[i].segmentSpeedMps],
[own[i].segmentCourseDeg, own[i].segmentSpeedMps],
);
}
}
});
test('a refused fix is never told to the placement', () => {
const told = [];
const e = placedEntry(
layerPlacement(
{
layer: () => ({
observe: (id, f) => told.push(f.lat),
place: () => null,
}),
},
'transit',
),
);
recordFix(e, fix(0));
recordFix(e, fix(15000, 42.001));
assert.equal(recordFix(e, fix(30000, 43)).accepted, false);
assert.deepEqual(told, [42, 42.001]);
});
+21 -5
View File
@@ -4,8 +4,10 @@ import {
perspectiveProjectedRotation,
} from '../../data/iconOrientation.js';
import {
PLACED_WAKE_MS,
updatePlayback,
applyDisplayCourse,
playbackMoving,
syncPlayback,
} from './movement.js';
import {
@@ -165,7 +167,7 @@ export function createRendering({ state, services, parts }) {
migrateMarker(entry, false);
return;
}
const positional = sample.phase === 'playing' && sample.segmentSpeedMps > 0;
const positional = playbackMoving(entry);
const turning =
Number.isFinite(sample.segmentCourseDeg) &&
Number.isFinite(entry.courseDeg) &&
@@ -178,7 +180,19 @@ export function createRendering({ state, services, parts }) {
} else {
state._moving.delete(entry);
migrateMarker(entry, false);
if (Number.isFinite(sample.nextWakeMonoMs)) {
// A vehicle a host-supplied placement holds still is asked again every
// PLACED_WAKE_MS: it moves on once the placement does.
const monoNow = performance.now();
const placedWake = entry.placed === true;
const boundary = sample.nextWakeMonoMs;
const wakeMonoMs = placedWake
? Math.min(
Number.isFinite(boundary) ? boundary : Infinity,
monoNow + PLACED_WAKE_MS,
)
: boundary;
if (Number.isFinite(wakeMonoMs)) {
const atBoundary = !placedWake || boundary <= wakeMonoMs;
entry.wakeTimer = setTimeout(
() => {
entry.wakeTimer = null;
@@ -187,9 +201,10 @@ export function createRendering({ state, services, parts }) {
placeSample(entry);
schedulePlayback(entry);
syncRenderHold();
governorRequestRender('transit-motion-boundary');
if (atBoundary || state._moving.has(entry))
governorRequestRender('transit-motion-boundary');
},
Math.max(1, sample.nextWakeMonoMs - performance.now()),
Math.max(1, wakeMonoMs - monoNow),
);
}
}
@@ -441,7 +456,8 @@ export function createRendering({ state, services, parts }) {
if (
!entry.surfaceReady ||
entry.sample.phase !== 'playing' ||
entry.sample.segmentSpeedMps === 0
entry.sample.segmentSpeedMps === 0 ||
(entry.placed === true && entry.placedMoving !== true)
)
schedulePlayback(entry);
if (!state._moving.has(entry)) settleSelectedCard(entry);
+18
View File
@@ -198,6 +198,24 @@ export function createTrails({ state, services, parts, source }) {
nearGround = parts.height.nearGround(),
) {
pruneMarkerPaths(entry, sample);
// Placed by a host-supplied placement, it is drawn at the placed point,
// at the height its own playback has reached; far above the ground, where
// no floor is asked for, on the ellipsoid as an unplaced vehicle is.
if (entry.placed === true) {
const heightM = Number.isFinite(sample.heightM)
? sample.heightM
: nearGround
? NaN
: 0;
if (!Number.isFinite(heightM)) return null;
return Cesium.Cartesian3.fromDegrees(
sample.lon,
sample.lat,
heightM,
undefined,
out,
);
}
if (!nearGround) {
const a = entry.fromCart,
b = entry.toCart,
+1
View File
@@ -31,6 +31,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.queries = createQueries(context);
parts.snapshots = createVesselSnapshotRenderer({
state: vesselState.state,
placement: services?.placement ?? null,
records: vesselState.state.records,
rendering: parts.rendering,
tracking: parts.tracking,
+4
View File
@@ -115,6 +115,8 @@ export function createLifecycle({
state.feed.firstConnectTimer = null;
state.feed.abort = null;
state.billboardCollection = null;
if (services.placement)
for (const id of state.records.byId.keys()) services.placement.forget(id);
state.records.all = [];
state.records.byId = new Map();
state.records.unkeyed = [];
@@ -211,6 +213,7 @@ export function createLifecycle({
invalidateAisSession();
releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass();
components.rendering.cancelPlacementFrame();
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -232,6 +235,7 @@ export function createLifecycle({
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass();
components.rendering.cancelPlacementFrame();
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { VesselRecords } from './records.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { layerPlacement } from '../../data/placementProvider.js';
const row = (id, fields = {}) => ({
id,
mmsi: id,
lat: 51.93,
lon: 4.05,
speed: 10,
course: 90,
heading: 92,
last_position_epoch: 1_000,
...fields,
});
function harness({ placement = null, following = false } = {}) {
const visuals = [];
const state = { selectedRecord: null, trailId: null };
const records = new VesselRecords({ now: () => 5_000_000 });
const rendering = {
ensureCollections() {},
makeOccluder: () => null,
renderRowLimit: () => Infinity,
prepareRecordVisual(record) {
visuals.push(record.id);
return { billboard: null };
},
addRecordPrimitives() {},
removeRecordPrimitives() {},
shipIcon: () => 'icon',
shipScale: () => 1,
updateVisibility() {},
};
const renderer = createVesselSnapshotRenderer({
state,
placement,
records,
rendering,
tracking: {},
selection: {},
cards: {},
followsClock: () => following,
});
return {
visuals,
reconcile: (rows, options) => renderer.reconcileVessels({}, rows, options),
};
}
function recording() {
const told = [];
const forgotten = [];
const placement = layerPlacement(
{
layer: () => ({
observe: (id, fix) => told.push([id, fix]),
place: () => null,
forget: (id) => forgotten.push(id),
}),
},
'vessels',
);
return { told, forgotten, placement };
}
test('vessels reconcile as before with no placement', () => {
const own = harness();
own.reconcile([row('a'), row('b')]);
own.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
const { placement } = recording();
const placed = harness({ placement });
placed.reconcile([row('a'), row('b')]);
placed.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
assert.deepEqual(placed.visuals, own.visuals);
});
test('each live vessel fix is told to the placement, in m/s, and a vessel that leaves is forgotten', () => {
const { told, forgotten, placement } = recording();
const probe = harness({ placement });
probe.reconcile([row('a'), row('b', { speed: null, course: null })]);
probe.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
assert.deepEqual(
told.map(([id, fix]) => [id, fix.timeMs, fix.lat]),
[
['a', 1_000_000, 51.93],
['b', 1_000_000, 51.93],
['a', 1_060_000, 52],
],
);
assert.ok(Math.abs(told[0][1].speedMps - 10 * (1852 / 3600)) < 1e-12);
assert.equal(told[0][1].courseDeg, 90);
assert.equal(told[0][1].headingDeg, 92);
assert.ok(Number.isNaN(told[1][1].speedMps));
assert.ok(Number.isNaN(told[1][1].courseDeg));
assert.deepEqual(forgotten, ['b']);
});
test('following the world clock, or rows at another time, tell the placement nothing', () => {
const { told, placement } = recording();
harness({ placement, following: true }).reconcile([row('a')]);
harness({ placement }).reconcile([row('a')], { replace: true });
assert.deepEqual(told, []);
});
+61 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js';
import { layerPlacement } from '../../data/placementProvider.js';
// Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera
@@ -9,7 +10,7 @@ import { VISIBILITY_UPDATE_MS } from './policy.js';
const noop = () => {};
function setup() {
function setup({ placement = null } = {}) {
let clock = 10_000;
const requests = [];
const holds = new Set();
@@ -46,6 +47,7 @@ function setup() {
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
...(placement ? { placement: layerPlacement(placement, 'vessels') } : {}),
},
});
const camera = {
@@ -136,3 +138,61 @@ test('destroy cancels a pending trailing frame', (t) => {
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []);
});
test('a placement that places nothing asks for no frames; one that places a drawn vessel moves it in place', (t) => {
let mono = 0;
t.mock.method(performance, 'now', () => mono);
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
let spot = null;
const { layer, camera, requests, timers, advance } = setup({
placement: { layer: () => ({ place: () => spot }) },
});
const record = {
mmsi: '111',
lat: 51.93,
lon: 4.05,
billboard: { show: true, position: null },
};
layer.testing._setVesselStateForTest({
viewer: { camera, scene: {} },
records: [record],
});
const visual = layer.testing._prepareVesselVisualForTest(record);
const position = visual.position;
const before = { ...position };
// Nothing placed: no frame is asked for, and the vessel stays put.
for (let i = 0; i < 8; i += 1) {
mono += 250;
layer.testing._runPlacementPassForTest();
advance(250);
}
assert.equal(timers.size, 0);
assert.deepEqual(requests, []);
assert.deepEqual({ ...visual.position }, before);
// Placed: moved in place, no new positions, and a frame asked for.
spot = { lat: 51.94, lon: 4.05 };
mono += 250;
layer.testing._runPlacementPassForTest();
assert.equal(visual.position, position, 'written in place');
assert.notDeepEqual({ ...visual.position }, before);
assert.equal(record.billboard.position, position);
assert.equal(timers.size, 1);
advance(1_000);
assert.deepEqual(requests, ['ais-vessels-placement']);
// Turning the layer off drops the pending frame.
mono += 250;
layer.testing._runPlacementPassForTest();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(1_000);
assert.deepEqual(requests, ['ais-vessels-placement']);
});
+94 -2
View File
@@ -33,6 +33,8 @@ import { isAidToNavigation } from '../../data/vesselTypes.js';
/** How often vessels move on as the world clock plays (wall ms). */
const FOLLOW_POSITION_MS = 250;
/** A frame is asked for this often while a placement moves live vessels. */
const PLACEMENT_FRAME_MS = 1000;
/** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
@@ -58,10 +60,12 @@ export function createRendering({
visual = { billboard: record.billboard || null };
visualRecords.set(record, visual);
}
// Following the world clock, a vessel is drawn where it was at the time.
// Following the world clock, a vessel is drawn where it was at the time;
// live, where a host-supplied placement draws it, if one does.
const { lat, lon } = followsClock(vesselState)
? vesselPositionAt(record, vesselState.worldNow())
: record;
: (services.placement?.place(record.id, vesselState._aisRuntime.now()) ??
record);
const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M,
@@ -78,9 +82,50 @@ export function createRendering({
visual.position,
new Cesium.Cartesian3(),
);
if (services.placement) {
visual.drawnLat = lat;
visual.drawnLon = lon;
}
return visual;
}
/**
* Move a prepared vessel visual to `lat`/`lon`, writing into its own
* positions rather than allocating new ones: the placement pass runs for
* every drawn vessel several times a second.
*/
function moveRecordVisual(visual, lat, lon) {
const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M,
);
const ellipsoid = Cesium.Ellipsoid.WGS84;
Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM,
ellipsoid,
visual.position,
);
Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM - VESSEL_LIFT_M,
ellipsoid,
visual.waterline,
);
Cesium.Cartesian3.fromDegrees(
lon,
lat,
0,
ellipsoid,
visual.surfacePosition,
);
ellipsoid.geodeticSurfaceNormal(visual.position, visual.normal);
visual.drawnLat = lat;
visual.drawnLon = lon;
}
function resetRecordVisuals() {
visualRecords = new WeakMap();
}
@@ -391,6 +436,7 @@ export function createRendering({
if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (followsClock(vesselState)) followClockPositions();
else if (services.placement && state.feed.enabled) followPlacements();
updateVisibility();
if (state.feed.enabled) updateModels();
});
@@ -413,6 +459,50 @@ export function createRendering({
}
}
// Live with a host-supplied placement, vessels move on between refreshes:
// drawn where it places them at most every FOLLOW_POSITION_MS, with a frame
// asked for every PLACEMENT_FRAME_MS while it places any drawn vessel, so
// they move while the scene idles.
let placedAtWallMs = -Infinity;
let placing = false;
let placementFrameTimer = null;
function followPlacements() {
const wallMs = performance.now();
if (wallMs - placedAtWallMs >= FOLLOW_POSITION_MS) {
placedAtWallMs = wallMs;
placing = false;
const nowMs = vesselState._aisRuntime.now();
// Drawn vessels only, as a sprite or a model: a hidden one is placed
// again when it is drawn.
for (const record of state.records.all) {
const visual = visualRecords.get(record);
if (!visual?.position) continue;
if (!visual.billboard?.show && !visual.modelOwnsVisual) continue;
const placed = services.placement.place(record.id, nowMs);
if (placed) placing = true;
const { lat, lon } = placed ?? record;
if (lat === visual.drawnLat && lon === visual.drawnLon) continue;
moveRecordVisual(visual, lat, lon);
if (visual.billboard) visual.billboard.position = visual.position;
}
}
if (placing && placementFrameTimer === null)
placementFrameTimer = vesselState._aisRuntime.setTimeout(() => {
placementFrameTimer = null;
if (state.feed.enabled && !followsClock(vesselState))
governorRequestRender('ais-vessels-placement');
}, PLACEMENT_FRAME_MS);
}
/** Stop asking for placement frames (the layer turned off or went away). */
function cancelPlacementFrame() {
placedAtWallMs = -Infinity;
placing = false;
if (placementFrameTimer === null) return;
vesselState._aisRuntime.clearTimeout(placementFrameTimer);
placementFrameTimer = null;
}
function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return;
const waitMs = Math.max(
@@ -787,6 +877,8 @@ export function createRendering({
installRuntime,
updateVisibility,
cancelTrailingVisibilityPass,
cancelPlacementFrame,
followPlacements,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
+35 -1
View File
@@ -1,6 +1,30 @@
/** Connect portable vessel reconciliation to scene and selection operations. */
const KNOT_MPS = 1852 / 3600;
const finiteOrNaN = (value) =>
typeof value === 'number' && Number.isFinite(value) ? value : NaN;
/**
* A vessel record's fix as a host-supplied placement takes it: its own time
* (when it was received, if the row gives none), position, speed in m/s
* (rows give knots), course and heading.
*/
function placementFix(record) {
const epoch = record.lastPositionEpoch;
return {
timeMs: Number.isFinite(epoch) ? epoch * 1000 : record.receivedAtMs,
lat: record.lat,
lon: record.lon,
altitudeM: NaN,
speedMps: Number.isFinite(record.speed) ? record.speed * KNOT_MPS : NaN,
courseDeg: finiteOrNaN(record.course),
headingDeg: finiteOrNaN(record.heading),
};
}
/** Connect portable vessel reconciliation to scene and selection operations.
* `placement`, a host-supplied placement, is told each live fix. */
export function createVesselSnapshotRenderer({
state,
placement = null,
records,
rendering,
tracking,
@@ -17,6 +41,13 @@ export function createVesselSnapshotRenderer({
rendering.ensureCollections(viewer);
const occluder = rendering.makeOccluder();
const follow = followsClock();
// Live fixes only: following the clock, the layer places every vessel.
const observe =
placement && !follow && !replace
? (record) => {
if (record.id) placement.observe(record.id, placementFix(record));
}
: () => {};
records.reconcile(
rows,
{
@@ -28,6 +59,7 @@ export function createVesselSnapshotRenderer({
},
{
add(record) {
observe(record);
rendering.prepareRecordVisual(record);
rendering.addRecordPrimitives(record, occluder);
},
@@ -35,6 +67,7 @@ export function createVesselSnapshotRenderer({
return rendering.shipIcon(record, record === state.selectedRecord);
},
updated(record, prevIcon) {
observe(record);
const selected = record === state.selectedRecord;
const visual = rendering.prepareRecordVisual(record);
if (visual.billboard) {
@@ -56,6 +89,7 @@ export function createVesselSnapshotRenderer({
rendering.removeRecordPrimitives(record);
},
removed(id) {
placement?.forget(id);
if (state.trailId === id) tracking.clearSelectedVesselTrail();
},
staleSelected(record) {
+14
View File
@@ -162,6 +162,18 @@ export function createTesting({
components.rendering.updateVisibility(force);
}
/** Draw a record's visual where it stands now. Test-only seam. */
function _prepareVesselVisualForTest(record) {
return components.rendering.prepareRecordVisual(record);
}
/** Run one live placement pass (a host-supplied placement). Test-only seam. */
function _runPlacementPassForTest() {
components.rendering.followPlacements();
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
@@ -194,6 +206,8 @@ export function createTesting({
_getVesselFeedStateForTest,
_getVesselStateForTest,
_runVisibilityPassForTest,
_prepareVesselVisualForTest,
_runPlacementPassForTest,
_hasTrailingVisibilityPassForTest,
};
}
+5
View File
@@ -73,6 +73,11 @@ export function createTracking({
heightM,
);
if (visual.billboard) visual.billboard.position = visual.position;
// A placed vessel is drawn again at its placement on the next pass.
if (services.placement) {
visual.drawnLat = NaN;
visual.drawnLon = NaN;
}
}
}
+7 -2
View File
@@ -4,6 +4,7 @@
// height, layer stats, selected route lines).
import * as Cesium from 'cesium';
import { createRailLayer } from '../layers/rail/index.js';
import { layerPlacement } from '../data/placementProvider.js';
import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js';
import { WORLD_FOCUS_FRAMING } from '../worldFocus.js';
import { publicRailCatalog } from '../data/railFeeds.js';
@@ -82,7 +83,8 @@ const headersOf = (values = {}) => ({
* catalog?: object[]|Promise<object[]>|Function|false,
* ground?: object,
* stops?: object|null, scheduleStops?: boolean,
* paths?: Record<string, object>|null, fleet?: object|null}} [options]
* paths?: Record<string, object>|null, fleet?: object|null,
* placement?: object|null}} [options]
* `ground`: a floor service in place of the always-cold stub; `catalog`:
* the server's feed catalog (it may list fewer feeds than are bundled; a
* promise answers when it settles, a function is called per request and
@@ -94,7 +96,8 @@ const headersOf = (values = {}) => ({
* failure status it answers with (404 for any other feed; without it the
* source has no path route at all); `fleet`: the fleet-model services
* (testSupport/fleetModelFakes.mjs), with a camera that looks straight
* down for them (none by default: trains stay icons).
* down for them (none by default: trains stay icons); `placement`: a
* host-supplied placement provider (../data/placementProvider.js).
*/
export function railLayerHarness(
t,
@@ -107,6 +110,7 @@ export function railLayerHarness(
scheduleStops = false,
paths = null,
fleet = null,
placement = null,
} = {},
) {
const T0 = RAIL_HARNESS_T0;
@@ -332,6 +336,7 @@ export function railLayerHarness(
warmGroundFloor() {},
},
...(fleet ? { fleet } : {}),
...(placement ? { placement: layerPlacement(placement, 'rail') } : {}),
camera: {
framing: WORLD_FOCUS_FRAMING.train,
requestWorldFocus(detail) {