diff --git a/changelog.d/1737-placement-provider.md b/changelog.d/1737-placement-provider.md new file mode 100644 index 000000000..5d461a6dd --- /dev/null +++ b/changelog.d/1737-placement-provider.md @@ -0,0 +1,7 @@ +- Let a host application decide where live contacts are drawn. + `createApplicationCatalog({ placement })` takes a placement provider whose + `layer(kind)` hands Flights, Military Flights, AIS Vessels, Transit and Rail + a `place(id, wallMs)` (and optionally `observe(id, fix)`, `forget(id)`): + each layer tells it every live fix it accepts and draws a contact where it + places it. Without one, or for a contact it does not place, every layer + moves as before (samehkhamis, #1737). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ef7ac8719..9d1b5abda 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -365,6 +365,25 @@ The source factories are exported at `layers/directions/source` and `layers/recent-imagery/source`. Configuration is applied at construction time; this does not introduce live source replacement. +Application catalog composition also accepts an optional `placement`, a +host-supplied placement provider (`src/data/placementProvider.js`): an object +whose `layer(kind)` returns, for `aircraft`, `military`, `vessels`, `transit` +or `rail`, either null or `{ place(id, wallMs), observe?(id, fix), +forget?(id) }`. While live, each of those layers tells it every fix it accepts +(time, position, the height it draws the fix at, reported speed, course and +ground state) and draws a contact where `place` puts 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; following the world clock, the layers never +ask it. Transit and rail vehicles a provider places are advanced every frame +in view while their placed position changes; one it holds still lets the +render loop idle and is asked again each second, moving on once its placement +does. A placed transit vehicle is drawn at its placed point, and a placed +train where it is placed rather than on its bound railway line. Live vessels +a provider places move on between refreshes: redrawn in place at most every +250 ms, with a frame asked for each second while the provider places any +drawn vessel, and none once the layer is off. + Application catalog composition accepts an optional `streetLevelProviders` list. An explicit list replaces the default Mapillary provider; an empty list removes all imagery providers. Without that option, Mapillary is registered only when diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0fad9bc71..4136ddb8a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1457,5 +1457,7 @@ "src/ui/styles/rail-edge.css", "src/ui/styles/rail-split.css", "src/ui/styles/left-tools.css", - "src/ui/styles/panel-edge.css" + "src/ui/styles/panel-edge.css", + "src/data/placementProvider.test.mjs", + "src/layers/vessels/placement.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c0237942e..4aa4d4a7c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1588,7 +1588,8 @@ "src/ui/unitLabels.js", "src/time/worldClock.js", "src/time/julian.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/layers/flights/placed.js" ], "external": ["cesium"] }, @@ -1661,7 +1662,8 @@ "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/time/worldClock.js", - "src/time/julian.js" + "src/time/julian.js", + "src/layers/flights/placed.js" ], "external": ["cesium"] }, @@ -3824,7 +3826,9 @@ "src/data/planespottersPhoto.js", "src/ui/stores/dataLibrary.js", "src/data/openVesselPhoto.js", - "src/layers/vessels/photos.js" + "src/layers/vessels/photos.js", + "src/layers/flights/placed.js", + "src/data/placementProvider.js" ], "external": [ "hls.js", @@ -4542,7 +4546,9 @@ "src/data/imagingSensors.js", "src/data/planespottersPhoto.js", "src/layers/vessels/photos.js", - "src/data/openVesselPhoto.js" + "src/data/openVesselPhoto.js", + "src/layers/flights/placed.js", + "src/data/placementProvider.js" ], "external": [ "hls.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index d89d60e67..ed6821edd 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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 } : {}), }), diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index 65edc5d63..1f2a0ed85 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -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 } : {}), diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index 38f880a05..94fae26ee 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -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, }, }); } diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index 839220c54..fbedd20aa 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -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, }, }); } diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js index fb3a05c51..87fccde18 100644 --- a/src/app/layers/rail.js +++ b/src/app/layers/rail.js @@ -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, diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 1b430a957..5e89a1f47 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -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, diff --git a/src/data/placementProvider.js b/src/data/placementProvider.js new file mode 100644 index 000000000..98210a257 --- /dev/null +++ b/src/data/placementProvider.js @@ -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. + } + }, + }); +} diff --git a/src/data/placementProvider.test.mjs b/src/data/placementProvider.test.mjs new file mode 100644 index 000000000..a865ba7ba --- /dev/null +++ b/src/data/placementProvider.test.mjs @@ -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')); +}); diff --git a/src/layers/flights/clockFollow.js b/src/layers/flights/clockFollow.js index 5413d99c4..51f606079 100644 --- a/src/layers/flights/clockFollow.js +++ b/src/layers/flights/clockFollow.js @@ -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(); diff --git a/src/layers/flights/clockFollow.test.mjs b/src/layers/flights/clockFollow.test.mjs index a997c4ade..e0b08b8a5 100644 --- a/src/layers/flights/clockFollow.test.mjs +++ b/src/layers/flights/clockFollow.test.mjs @@ -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() { diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index 0261299dc..7d5b259a3 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -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, diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index a435e6793..afaba23c7 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -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(); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index a0ee22ade..ccf2ca8c1 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -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 = diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs index dd7a0ab03..1e9e059a1 100644 --- a/src/layers/flights/motion.test.mjs +++ b/src/layers/flights/motion.test.mjs @@ -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); +}); diff --git a/src/layers/flights/placed.js b/src/layers/flights/placed.js new file mode 100644 index 000000000..d8dca5a78 --- /dev/null +++ b/src/layers/flights/placed.js @@ -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} 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, + }); +} diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index cf6025dba..0306e85de 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -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); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index f01ccc45a..b3b684651 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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); diff --git a/src/layers/military/clockFollow.js b/src/layers/military/clockFollow.js index f120a54ce..641cd6b63 100644 --- a/src/layers/military/clockFollow.js +++ b/src/layers/military/clockFollow.js @@ -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(); diff --git a/src/layers/military/index.js b/src/layers/military/index.js index acc975358..24206a07a 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -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, diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index e1cf507e3..6b42d19ac 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -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(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index bcff2e34a..0073937b2 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -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( diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 0f04d7172..e70cd242f 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -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); diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index 4700e71e6..44f57f575 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -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'); +}); diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js index 8bd4a342f..109f78fd3 100644 --- a/src/layers/rail/ingestion.js +++ b/src/layers/rail/ingestion.js @@ -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, diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index 67501df7c..cc146af62 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -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(); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index 21b719980..2944fabe4 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -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); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 33723fef9..a8510117b 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -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. diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index ad5a4d109..ff0c8e019 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -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(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 71d9d1df4..101e17488 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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(); diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 8202d7a56..8ebb4d383 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -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 { diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index 692d2d3ac..6b87157a7 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -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]); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 8a1b19635..27fc56903 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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); diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 315b2ece8..80b9004db 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -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, diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index c6d1429dc..57c3e2dc1 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -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, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index fd7b6becd..0cd9d5ef8 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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(); diff --git a/src/layers/vessels/placement.test.mjs b/src/layers/vessels/placement.test.mjs new file mode 100644 index 000000000..e98fd4cec --- /dev/null +++ b/src/layers/vessels/placement.test.mjs @@ -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, []); +}); diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs index 2fec27871..a1f6ec93c 100644 --- a/src/layers/vessels/renderGovernor.test.mjs +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -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']); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 7d12366e3..dcb85e82f 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -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, diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js index 584b17417..fc605888a 100644 --- a/src/layers/vessels/snapshotRenderer.js +++ b/src/layers/vessels/snapshotRenderer.js @@ -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) { diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 436c451d4..6c8355bae 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -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, }; } diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index dfc2aae55..5e3906557 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -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; + } } } diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs index ddfcba030..43425fed3 100644 --- a/src/testSupport/railLayerHarness.mjs +++ b/src/testSupport/railLayerHarness.mjs @@ -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|Function|false, * ground?: object, * stops?: object|null, scheduleStops?: boolean, - * paths?: Record|null, fleet?: object|null}} [options] + * paths?: Record|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) {