mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
e53cfef1d1
commit
6f830a2e70
46 files changed
+1417
-34
No files matched your search
@@ -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 } : {}),
|
||||
}),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user