mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge pull request #1737 from bilawalsidhu/motion/placement-provider
Let a host application place live contacts
This commit is contained in:
46 files changed
+1417
-34
No files matched your search
@@ -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).
|
||||||
@@ -365,6 +365,25 @@ The source factories are exported at `layers/directions/source` and
|
|||||||
`layers/recent-imagery/source`. Configuration is applied at construction time;
|
`layers/recent-imagery/source`. Configuration is applied at construction time;
|
||||||
this does not introduce live source replacement.
|
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.
|
Application catalog composition accepts an optional `streetLevelProviders` list.
|
||||||
An explicit list replaces the default Mapillary provider; an empty list removes
|
An explicit list replaces the default Mapillary provider; an empty list removes
|
||||||
all imagery providers. Without that option, Mapillary is registered only when
|
all imagery providers. Without that option, Mapillary is registered only when
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"src/ui/styles/rail-edge.css",
|
"src/ui/styles/rail-edge.css",
|
||||||
"src/ui/styles/rail-split.css",
|
"src/ui/styles/rail-split.css",
|
||||||
"src/ui/styles/left-tools.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"
|
||||||
]
|
]
|
||||||
@@ -1588,7 +1588,8 @@
|
|||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/time/worldClock.js",
|
"src/time/worldClock.js",
|
||||||
"src/time/julian.js",
|
"src/time/julian.js",
|
||||||
"src/data/planespottersPhoto.js"
|
"src/data/planespottersPhoto.js",
|
||||||
|
"src/layers/flights/placed.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1661,7 +1662,8 @@
|
|||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/time/worldClock.js",
|
"src/time/worldClock.js",
|
||||||
"src/time/julian.js"
|
"src/time/julian.js",
|
||||||
|
"src/layers/flights/placed.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -3824,7 +3826,9 @@
|
|||||||
"src/data/planespottersPhoto.js",
|
"src/data/planespottersPhoto.js",
|
||||||
"src/ui/stores/dataLibrary.js",
|
"src/ui/stores/dataLibrary.js",
|
||||||
"src/data/openVesselPhoto.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -4542,7 +4546,9 @@
|
|||||||
"src/data/imagingSensors.js",
|
"src/data/imagingSensors.js",
|
||||||
"src/data/planespottersPhoto.js",
|
"src/data/planespottersPhoto.js",
|
||||||
"src/layers/vessels/photos.js",
|
"src/layers/vessels/photos.js",
|
||||||
"src/data/openVesselPhoto.js"
|
"src/data/openVesselPhoto.js",
|
||||||
|
"src/layers/flights/placed.js",
|
||||||
|
"src/data/placementProvider.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createWeatherLayer } from '../layers/weather/index.js';
|
|||||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
import { createLayerCatalog } from './catalog.js';
|
import { createLayerCatalog } from './catalog.js';
|
||||||
|
import { layerPlacement } from '../data/placementProvider.js';
|
||||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||||
import { createApplicationFlights } from './layers/flights.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.
|
/** Construct the current catalog without choosing any source provider.
|
||||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
* 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.
|
* 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({
|
export function createApplicationCatalog({
|
||||||
surface,
|
surface,
|
||||||
@@ -74,6 +78,7 @@ export function createApplicationCatalog({
|
|||||||
resolveAsset,
|
resolveAsset,
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
streetLevelProviders,
|
streetLevelProviders,
|
||||||
|
placement = null,
|
||||||
}) {
|
}) {
|
||||||
if (!signal?.addEventListener)
|
if (!signal?.addEventListener)
|
||||||
throw new TypeError('An application lifetime signal is required');
|
throw new TypeError('An application lifetime signal is required');
|
||||||
@@ -97,17 +102,20 @@ export function createApplicationCatalog({
|
|||||||
const flights = createApplicationFlights({
|
const flights = createApplicationFlights({
|
||||||
surface,
|
surface,
|
||||||
source: sources.flights,
|
source: sources.flights,
|
||||||
|
placement: layerPlacement(placement, 'aircraft'),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
});
|
});
|
||||||
const military = createApplicationMilitary({
|
const military = createApplicationMilitary({
|
||||||
surface,
|
surface,
|
||||||
source: sources.military,
|
source: sources.military,
|
||||||
|
placement: layerPlacement(placement, 'military'),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
});
|
});
|
||||||
const vessels = createApplicationVessels({
|
const vessels = createApplicationVessels({
|
||||||
source: sources.vessels,
|
source: sources.vessels,
|
||||||
|
placement: layerPlacement(placement, 'vessels'),
|
||||||
options: vesselOptions,
|
options: vesselOptions,
|
||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
@@ -172,6 +180,7 @@ export function createApplicationCatalog({
|
|||||||
createApplicationTransit({
|
createApplicationTransit({
|
||||||
surface,
|
surface,
|
||||||
source: sources.transit,
|
source: sources.transit,
|
||||||
|
placement: layerPlacement(placement, 'transit'),
|
||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
@@ -210,6 +219,7 @@ export function createApplicationCatalog({
|
|||||||
createApplicationRail({
|
createApplicationRail({
|
||||||
surface,
|
surface,
|
||||||
source: sources.rail,
|
source: sources.rail,
|
||||||
|
placement: layerPlacement(placement, 'rail'),
|
||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { fleetModelServices } from './fleetModels.js';
|
|||||||
* Construct one layer using the application scene owners and a supplied
|
* Construct one layer using the application scene owners and a supplied
|
||||||
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
|
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
|
||||||
* preference, under which nearby vessels are drawn as fleet models.
|
* preference, under which nearby vessels are drawn as fleet models.
|
||||||
|
* `placement`: a host-supplied placement for its live vessels, or null.
|
||||||
*/
|
*/
|
||||||
export function createApplicationVessels({
|
export function createApplicationVessels({
|
||||||
source,
|
source,
|
||||||
|
placement = null,
|
||||||
options = {},
|
options = {},
|
||||||
displayParams = null,
|
displayParams = null,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
@@ -38,6 +40,7 @@ export function createApplicationVessels({
|
|||||||
worldFocus,
|
worldFocus,
|
||||||
render,
|
render,
|
||||||
readouts,
|
readouts,
|
||||||
|
placement,
|
||||||
fleet: fleetModelServices({
|
fleet: fleetModelServices({
|
||||||
display: displayParams,
|
display: displayParams,
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as recession from '../../data/aircraftRecession.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({
|
export function createApplicationFlights({
|
||||||
surface,
|
surface,
|
||||||
source,
|
source,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
|
placement = null,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||||
}) {
|
}) {
|
||||||
@@ -41,6 +43,7 @@ export function createApplicationFlights({
|
|||||||
render,
|
render,
|
||||||
groundSnap,
|
groundSnap,
|
||||||
recession,
|
recession,
|
||||||
|
placement,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as recession from '../../data/aircraftRecession.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({
|
export function createApplicationMilitary({
|
||||||
surface,
|
surface,
|
||||||
source,
|
source,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
|
placement = null,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||||
}) {
|
}) {
|
||||||
@@ -41,6 +43,7 @@ export function createApplicationMilitary({
|
|||||||
render,
|
render,
|
||||||
groundSnap,
|
groundSnap,
|
||||||
recession,
|
recession,
|
||||||
|
placement,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,15 @@ import {
|
|||||||
* ground floor, the same floor Transit and the aircraft stand on.
|
* ground floor, the same floor Transit and the aircraft stand on.
|
||||||
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
|
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
|
||||||
* which nearby trains are drawn as fleet models.
|
* 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
|
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
export function createApplicationRail({
|
export function createApplicationRail({
|
||||||
surface,
|
surface,
|
||||||
source,
|
source,
|
||||||
|
placement = null,
|
||||||
displayParams = null,
|
displayParams = null,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
@@ -36,6 +38,7 @@ export function createApplicationRail({
|
|||||||
sprites,
|
sprites,
|
||||||
picking,
|
picking,
|
||||||
overlays,
|
overlays,
|
||||||
|
placement,
|
||||||
credits: { registerDynamicCredit, railFeedCredit },
|
credits: { registerDynamicCredit, railFeedCredit },
|
||||||
ground: {
|
ground: {
|
||||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
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
|
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
|
||||||
* which nearby vehicles are drawn as fleet models.
|
* 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
|
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
export function createApplicationTransit({
|
export function createApplicationTransit({
|
||||||
surface,
|
surface,
|
||||||
source,
|
source,
|
||||||
|
placement = null,
|
||||||
displayParams = null,
|
displayParams = null,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
@@ -43,6 +45,7 @@ export function createApplicationTransit({
|
|||||||
sprites,
|
sprites,
|
||||||
picking,
|
picking,
|
||||||
overlays,
|
overlays,
|
||||||
|
placement,
|
||||||
credits: { registerDynamicCredit, transitFeedCredit },
|
credits: { registerDynamicCredit, transitFeedCredit },
|
||||||
ground: {
|
ground: {
|
||||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
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
|
* What the layer drops when the world clock moves it to another time (its
|
||||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
* 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
|
* Forget how every contact moved, before the snapshot at another time takes
|
||||||
* the place of every contact: their fixes, courses, floors and the tracked
|
* the place of every contact: their fixes, courses, floors and the tracked
|
||||||
* trail belong to the time the clock left.
|
* trail belong to the time the clock left.
|
||||||
*/
|
*/
|
||||||
function resetMotion() {
|
function resetMotion() {
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services?.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState.records.reset();
|
flightState.records.reset();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.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'));
|
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 () => {
|
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
|
||||||
const probe = ingestion({
|
const probe = ingestion({
|
||||||
async getSnapshot() {
|
async getSnapshot() {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function createCivilFlightLayer({
|
|||||||
parts.clockFollow = createClockFollow(context);
|
parts.clockFollow = createClockFollow(context);
|
||||||
const applySnapshot = createFlightSnapshotRenderer({
|
const applySnapshot = createFlightSnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
|
placement: services.placement ?? null,
|
||||||
records: flightState.records,
|
records: flightState.records,
|
||||||
militaryRegistry: services.militaryRegistry,
|
militaryRegistry: services.militaryRegistry,
|
||||||
groundFloor: services.groundFloor,
|
groundFloor: services.groundFloor,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||||
|
import { forgetPlacedAircraft } from './placed.js';
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -72,6 +73,10 @@ export function createLifecycle({
|
|||||||
flightState._cullPositions.clear();
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects = new Map();
|
flightState._detectionObjects = new Map();
|
||||||
flightState.records.data = new Map();
|
flightState.records.data = new Map();
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState._positionHistory = new Map();
|
flightState._positionHistory = new Map();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
@@ -291,6 +296,10 @@ export function createLifecycle({
|
|||||||
flightState._cullPositions.clear();
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects.clear();
|
flightState._detectionObjects.clear();
|
||||||
flightState.records.data.clear();
|
flightState.records.data.clear();
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
COAST_MAX_SPEED_MPS,
|
COAST_MAX_SPEED_MPS,
|
||||||
} from '../../data/motionModel.js';
|
} from '../../data/motionModel.js';
|
||||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
|
import { placeLiveAircraft } from './placed.js';
|
||||||
import {
|
import {
|
||||||
modelVisualAnchor,
|
modelVisualAnchor,
|
||||||
modelAnchorWorld,
|
modelAnchorWorld,
|
||||||
@@ -173,6 +174,19 @@ export function createMotion({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const out = result || new Cesium.Cartesian3();
|
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
|
// Render one poll interval behind real time so we interpolate between
|
||||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||||
const renderTime =
|
const renderTime =
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import assert from 'node:assert/strict';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createMotion } from './motion.js';
|
import { createMotion } from './motion.js';
|
||||||
import { arcOffsetEnu } from '../../data/motionModel.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 = {
|
const flightState = {
|
||||||
records: { data: new Map() },
|
records: { data: new Map() },
|
||||||
_positionHistory: new Map(),
|
_positionHistory: new Map(),
|
||||||
@@ -16,7 +19,7 @@ function fixture() {
|
|||||||
};
|
};
|
||||||
const motion = createMotion({
|
const motion = createMotion({
|
||||||
flightState,
|
flightState,
|
||||||
services: { groundFloor: {}, camera: {}, focus: {} },
|
services: { groundFloor: {}, camera: {}, focus: {}, placement },
|
||||||
parts: {},
|
parts: {},
|
||||||
});
|
});
|
||||||
return { flightState, motion };
|
return { flightState, motion };
|
||||||
@@ -184,3 +187,189 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
|||||||
);
|
);
|
||||||
assert.equal(reads, 2);
|
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 { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||||
import { followsClock } from './policy.js';
|
import { followsClock } from './policy.js';
|
||||||
|
import { observeLiveAircraft } from './placed.js';
|
||||||
|
|
||||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||||
export function createFlightSnapshotRenderer({
|
export function createFlightSnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
|
placement = null,
|
||||||
records,
|
records,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
groundFloor,
|
groundFloor,
|
||||||
@@ -65,6 +67,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||||
records.forget(icao24);
|
records.forget(icao24);
|
||||||
|
placement?.forget(icao24);
|
||||||
flightState._cullPositions.delete(icao24);
|
flightState._cullPositions.delete(icao24);
|
||||||
flightState._positionHistory.delete(icao24);
|
flightState._positionHistory.delete(icao24);
|
||||||
flightState._displayCourse.delete(icao24);
|
flightState._displayCourse.delete(icao24);
|
||||||
@@ -98,6 +101,17 @@ export function createFlightSnapshotRenderer({
|
|||||||
);
|
);
|
||||||
const isTracked = icao24 === flightState._trackedIcao;
|
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
|
// Append to position history stamped with the FEED's fix epoch
|
||||||
// (time_position), not client receipt time — OpenSky positions arrive
|
// (time_position), not client receipt time — OpenSky positions arrive
|
||||||
// 5-15s stale and receipt-time stamping is what caused the
|
// 5-15s stale and receipt-time stamping is what caused the
|
||||||
@@ -311,6 +325,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||||
records.forget(icao24);
|
records.forget(icao24);
|
||||||
|
placement?.forget(icao24);
|
||||||
flightState._cullPositions.delete(icao24);
|
flightState._cullPositions.delete(icao24);
|
||||||
flightState._positionHistory.delete(icao24);
|
flightState._positionHistory.delete(icao24);
|
||||||
flightState._displayCourse.delete(icao24);
|
flightState._displayCourse.delete(icao24);
|
||||||
|
|||||||
@@ -1289,6 +1289,7 @@ export function createTracking({
|
|||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||||
flightState.records.data.delete(icao24);
|
flightState.records.data.delete(icao24);
|
||||||
|
services.placement?.forget(icao24);
|
||||||
flightState._cullPositions.delete(icao24);
|
flightState._cullPositions.delete(icao24);
|
||||||
flightState._positionHistory.delete(icao24);
|
flightState._positionHistory.delete(icao24);
|
||||||
flightState._displayCourse.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
|
* What the layer drops when the world clock moves it to another time (its
|
||||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
* 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
|
* Forget how every contact moved, before the snapshot at another time takes
|
||||||
* the place of every contact: their fixes, courses and the tracked trail
|
* the place of every contact: their fixes, courses and the tracked trail
|
||||||
* belong to the time the clock left.
|
* belong to the time the clock left.
|
||||||
*/
|
*/
|
||||||
function resetMotion() {
|
function resetMotion() {
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services?.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState.records.reset();
|
flightState.records.reset();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function createMilitaryFlightLayer({
|
|||||||
parts.clockFollow = createClockFollow(context);
|
parts.clockFollow = createClockFollow(context);
|
||||||
const applySnapshot = createMilitarySnapshotRenderer({
|
const applySnapshot = createMilitarySnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
|
placement: services.placement ?? null,
|
||||||
records: flightState.records,
|
records: flightState.records,
|
||||||
groundFloor: services.groundFloor,
|
groundFloor: services.groundFloor,
|
||||||
meshFloor: services.meshFloor,
|
meshFloor: services.meshFloor,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||||
|
import { forgetPlacedAircraft } from '../flights/placed.js';
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -66,6 +67,10 @@ export function createLifecycle({
|
|||||||
flightState._cullPositions.clear();
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects = new Map();
|
flightState._detectionObjects = new Map();
|
||||||
flightState.records.data = new Map();
|
flightState.records.data = new Map();
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState._positionHistory = new Map();
|
flightState._positionHistory = new Map();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
@@ -264,6 +269,10 @@ export function createLifecycle({
|
|||||||
flightState._cullPositions.clear();
|
flightState._cullPositions.clear();
|
||||||
flightState._detectionObjects.clear();
|
flightState._detectionObjects.clear();
|
||||||
flightState.records.data.clear();
|
flightState.records.data.clear();
|
||||||
|
forgetPlacedAircraft(
|
||||||
|
services.placement,
|
||||||
|
flightState._positionHistory.keys(),
|
||||||
|
);
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
flightState._displayCourse.clear();
|
flightState._displayCourse.clear();
|
||||||
flightState._groundSnap.clear();
|
flightState._groundSnap.clear();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
COAST_MAX_SPEED_MPS,
|
COAST_MAX_SPEED_MPS,
|
||||||
} from '../../data/motionModel.js';
|
} from '../../data/motionModel.js';
|
||||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
|
import { placeLiveAircraft } from '../flights/placed.js';
|
||||||
import {
|
import {
|
||||||
modelVisualAnchor,
|
modelVisualAnchor,
|
||||||
modelAnchorWorld,
|
modelAnchorWorld,
|
||||||
@@ -70,6 +71,19 @@ export function createMotion({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const out = result || new Cesium.Cartesian3();
|
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
|
// Render one poll interval behind real time so we interpolate between
|
||||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
TRACKED_ICON_COLOR,
|
TRACKED_ICON_COLOR,
|
||||||
followsClock,
|
followsClock,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { observeLiveAircraft } from '../flights/placed.js';
|
||||||
|
|
||||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||||
export function createMilitarySnapshotRenderer({
|
export function createMilitarySnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
|
placement = null,
|
||||||
records,
|
records,
|
||||||
groundFloor,
|
groundFloor,
|
||||||
meshFloor,
|
meshFloor,
|
||||||
@@ -73,6 +75,17 @@ export function createMilitarySnapshotRenderer({
|
|||||||
);
|
);
|
||||||
const isTracked = icao24 === flightState._trackedIcao;
|
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.
|
// 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
|
// 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
|
// report, so the fix epoch is receipt time minus that age. Only
|
||||||
@@ -324,6 +337,7 @@ export function createMilitarySnapshotRenderer({
|
|||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||||
records.forget(icao24);
|
records.forget(icao24);
|
||||||
|
placement?.forget(icao24);
|
||||||
flightState._cullPositions.delete(icao24);
|
flightState._cullPositions.delete(icao24);
|
||||||
flightState._positionHistory.delete(icao24);
|
flightState._positionHistory.delete(icao24);
|
||||||
flightState._displayCourse.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 { publicRailCatalog } from '../../data/railFeeds.js';
|
||||||
import { pointAtS } from '../../data/railPaths.js';
|
import { pointAtS } from '../../data/railPaths.js';
|
||||||
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
||||||
|
import { PLACED_WAKE_MS } from '../transit/movement.js';
|
||||||
|
|
||||||
const M_PER_DEG = 111_320;
|
const M_PER_DEG = 111_320;
|
||||||
const LAT = 42.36;
|
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.state()._cockpitLostFeedId, null);
|
||||||
assert.equal(app.layer.getCockpitSubject(), 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);
|
state._moving.delete(entry);
|
||||||
parts.rendering.cancelWake(entry);
|
parts.rendering.cancelWake(entry);
|
||||||
destroyTrack(entry.track);
|
destroyTrack(entry.track);
|
||||||
|
entry.placement?.forget(key);
|
||||||
state._trains.delete(key);
|
state._trains.delete(key);
|
||||||
state._revision += 1;
|
state._revision += 1;
|
||||||
// A copy it hid may now be the one to draw.
|
// A copy it hid may now be the one to draw.
|
||||||
@@ -274,6 +275,8 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
const entry = {
|
const entry = {
|
||||||
key,
|
key,
|
||||||
feedId: feed.id,
|
feedId: feed.id,
|
||||||
|
// A host-supplied placement, if any, draws it while live.
|
||||||
|
placement: services.placement ?? null,
|
||||||
marker: null,
|
marker: null,
|
||||||
category: record.category,
|
category: record.category,
|
||||||
railMode: record.mode,
|
railMode: record.mode,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
|
|||||||
for (const entry of state._trains.values()) {
|
for (const entry of state._trains.values()) {
|
||||||
parts.rendering.cancelWake(entry);
|
parts.rendering.cancelWake(entry);
|
||||||
destroyTrack(entry.track);
|
destroyTrack(entry.track);
|
||||||
|
entry.placement?.forget(entry.key);
|
||||||
}
|
}
|
||||||
state._trains.clear();
|
state._trains.clear();
|
||||||
state._moving.clear();
|
state._moving.clear();
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import * as Cesium from 'cesium';
|
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 { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
||||||
import {
|
import {
|
||||||
COLD_CELL_FLUSH_MS,
|
COLD_CELL_FLUSH_MS,
|
||||||
@@ -551,8 +556,11 @@ export function createRendering({ state, services, parts }) {
|
|||||||
*/
|
*/
|
||||||
function place(entry) {
|
function place(entry) {
|
||||||
const sample = entry.sample;
|
const sample = entry.sample;
|
||||||
|
// Placed by a host-supplied placement, it is drawn where that puts it.
|
||||||
const onPath =
|
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
|
const lat = onPath
|
||||||
? pathPoint.lat
|
? pathPoint.lat
|
||||||
: Number.isFinite(sample?.lat)
|
: Number.isFinite(sample?.lat)
|
||||||
@@ -629,16 +637,26 @@ export function createRendering({ state, services, parts }) {
|
|||||||
if (!entry.marker) return;
|
if (!entry.marker) return;
|
||||||
place(entry);
|
place(entry);
|
||||||
const sample = entry.sample;
|
const sample = entry.sample;
|
||||||
const moving =
|
const moving = playing(entry) && playbackMoving(entry);
|
||||||
playing(entry) &&
|
|
||||||
sample?.phase === 'playing' &&
|
|
||||||
sample.segmentSpeedMps > 0;
|
|
||||||
if (moving) {
|
if (moving) {
|
||||||
state._moving.add(entry);
|
state._moving.add(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state._moving.delete(entry);
|
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 = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
entry.wakeTimer = null;
|
entry.wakeTimer = null;
|
||||||
@@ -646,9 +664,10 @@ export function createRendering({ state, services, parts }) {
|
|||||||
updatePlayback(entry, Date.now(), performance.now());
|
updatePlayback(entry, Date.now(), performance.now());
|
||||||
schedulePlayback(entry);
|
schedulePlayback(entry);
|
||||||
syncRenderHold();
|
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);
|
place(entry);
|
||||||
if (
|
if (
|
||||||
entry.sample.phase !== 'playing' ||
|
entry.sample.phase !== 'playing' ||
|
||||||
entry.sample.segmentSpeedMps === 0
|
entry.sample.segmentSpeedMps === 0 ||
|
||||||
|
(entry.placed === true && entry.placedMoving !== true)
|
||||||
)
|
)
|
||||||
schedulePlayback(entry);
|
schedulePlayback(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
state._heightDirty.delete(entry);
|
state._heightDirty.delete(entry);
|
||||||
parts.rendering.cancelWake(entry);
|
parts.rendering.cancelWake(entry);
|
||||||
destroyTrack(entry.track);
|
destroyTrack(entry.track);
|
||||||
|
entry.placement?.forget(key);
|
||||||
state._vehicles.delete(key);
|
state._vehicles.delete(key);
|
||||||
state._detectRevision += 1;
|
state._detectRevision += 1;
|
||||||
}
|
}
|
||||||
@@ -327,6 +328,8 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
entry = {
|
entry = {
|
||||||
key,
|
key,
|
||||||
feedId: feed.id,
|
feedId: feed.id,
|
||||||
|
// A host-supplied placement, if any, draws it while live.
|
||||||
|
placement: services.placement ?? null,
|
||||||
marker: null,
|
marker: null,
|
||||||
mode,
|
mode,
|
||||||
// The GTFS route type its model is chosen by, when it has one.
|
// 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;
|
entry.detectContact = null;
|
||||||
parts.rendering.cancelWake(entry);
|
parts.rendering.cancelWake(entry);
|
||||||
destroyTrack(entry.track);
|
destroyTrack(entry.track);
|
||||||
|
entry.placement?.forget(entry.key);
|
||||||
}
|
}
|
||||||
state._vehicles.clear();
|
state._vehicles.clear();
|
||||||
parts.height.clear();
|
parts.height.clear();
|
||||||
@@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
entry.detectContact = null;
|
entry.detectContact = null;
|
||||||
parts.rendering.cancelWake(entry);
|
parts.rendering.cancelWake(entry);
|
||||||
destroyTrack(entry.track);
|
destroyTrack(entry.track);
|
||||||
|
entry.placement?.forget(entry.key);
|
||||||
}
|
}
|
||||||
state._vehicles.clear();
|
state._vehicles.clear();
|
||||||
parts.rendering.stopModels();
|
parts.rendering.stopModels();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { FLOOR_WARM_PER_POLL } from './policy.js';
|
import { FLOOR_WARM_PER_POLL } from './policy.js';
|
||||||
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
||||||
|
import { layerPlacement } from '../../data/placementProvider.js';
|
||||||
|
import { PLACED_WAKE_MS } from './movement.js';
|
||||||
import {
|
import {
|
||||||
FUTURE_STAMP_TOLERANCE_MS,
|
FUTURE_STAMP_TOLERANCE_MS,
|
||||||
ORDER_REJECTS_BEFORE_RESET,
|
ORDER_REJECTS_BEFORE_RESET,
|
||||||
@@ -183,6 +185,8 @@ function harness(
|
|||||||
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
|
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
|
||||||
// looking straight down for them; without them vehicles stay icons.
|
// looking straight down for them; without them vehicles stay icons.
|
||||||
fleet = null,
|
fleet = null,
|
||||||
|
// A host-supplied placement (../../data/placementProvider.js), or none.
|
||||||
|
placement = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const viewRect = { ...view };
|
const viewRect = { ...view };
|
||||||
@@ -291,6 +295,7 @@ function harness(
|
|||||||
source,
|
source,
|
||||||
services: {
|
services: {
|
||||||
...(fleet ? { fleet } : {}),
|
...(fleet ? { fleet } : {}),
|
||||||
|
...(placement ? { placement: layerPlacement(placement, 'transit') } : {}),
|
||||||
ground,
|
ground,
|
||||||
mesh: {
|
mesh: {
|
||||||
sampleMeshFloorCells(scene, points, options) {
|
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',
|
'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) => {
|
test('the last vehicle leaving mid-segment takes the render hold with it', async (t) => {
|
||||||
const app = harness(t);
|
const app = harness(t);
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
|
|||||||
@@ -186,13 +186,57 @@ export function recordFix(entry, fix, observation = {}) {
|
|||||||
);
|
);
|
||||||
entry.currentEpoch = latest.epoch;
|
entry.currentEpoch = latest.epoch;
|
||||||
if (context) entry.track.epochs.set(latest.epoch, context);
|
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;
|
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) {
|
export function updatePlayback(entry, wallNowMs, monoNowMs) {
|
||||||
entry.clocks.wallNowMs = wallNowMs;
|
entry.clocks.wallNowMs = wallNowMs;
|
||||||
entry.clocks.monoNowMs = monoNowMs;
|
entry.clocks.monoNowMs = monoNowMs;
|
||||||
advance(entry.track, entry.clocks, entry.sample);
|
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;
|
entry.resets = entry.track.resets;
|
||||||
const segment = entry.segment;
|
const segment = entry.segment;
|
||||||
readFix(
|
readFix(
|
||||||
@@ -217,6 +261,16 @@ export function syncPlayback(entry, wallNowMs, monoNowMs = performance.now()) {
|
|||||||
}
|
}
|
||||||
updatePlayback(entry, wallNowMs, monoNowMs);
|
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) {
|
export function playbackPosition(entry) {
|
||||||
if (!entry?.track) return null;
|
if (!entry?.track) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from './movement.js';
|
} from './movement.js';
|
||||||
import { buildTransitSelectionCopy } from './policy.js';
|
import { buildTransitSelectionCopy } from './policy.js';
|
||||||
import { sampleAt } from '../../data/contactPlayback.js';
|
import { sampleAt } from '../../data/contactPlayback.js';
|
||||||
|
import { layerPlacement } from '../../data/placementProvider.js';
|
||||||
const entry = () => {
|
const entry = () => {
|
||||||
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
|
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
|
||||||
initializePlayback(e);
|
initializePlayback(e);
|
||||||
@@ -163,3 +164,101 @@ test('course fallback prefers held course, then finite reported bearing, then un
|
|||||||
assert.equal(e.courseDeg, expected);
|
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,
|
perspectiveProjectedRotation,
|
||||||
} from '../../data/iconOrientation.js';
|
} from '../../data/iconOrientation.js';
|
||||||
import {
|
import {
|
||||||
|
PLACED_WAKE_MS,
|
||||||
updatePlayback,
|
updatePlayback,
|
||||||
applyDisplayCourse,
|
applyDisplayCourse,
|
||||||
|
playbackMoving,
|
||||||
syncPlayback,
|
syncPlayback,
|
||||||
} from './movement.js';
|
} from './movement.js';
|
||||||
import {
|
import {
|
||||||
@@ -165,7 +167,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
migrateMarker(entry, false);
|
migrateMarker(entry, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const positional = sample.phase === 'playing' && sample.segmentSpeedMps > 0;
|
const positional = playbackMoving(entry);
|
||||||
const turning =
|
const turning =
|
||||||
Number.isFinite(sample.segmentCourseDeg) &&
|
Number.isFinite(sample.segmentCourseDeg) &&
|
||||||
Number.isFinite(entry.courseDeg) &&
|
Number.isFinite(entry.courseDeg) &&
|
||||||
@@ -178,7 +180,19 @@ export function createRendering({ state, services, parts }) {
|
|||||||
} else {
|
} else {
|
||||||
state._moving.delete(entry);
|
state._moving.delete(entry);
|
||||||
migrateMarker(entry, false);
|
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 = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
entry.wakeTimer = null;
|
entry.wakeTimer = null;
|
||||||
@@ -187,9 +201,10 @@ export function createRendering({ state, services, parts }) {
|
|||||||
placeSample(entry);
|
placeSample(entry);
|
||||||
schedulePlayback(entry);
|
schedulePlayback(entry);
|
||||||
syncRenderHold();
|
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 (
|
if (
|
||||||
!entry.surfaceReady ||
|
!entry.surfaceReady ||
|
||||||
entry.sample.phase !== 'playing' ||
|
entry.sample.phase !== 'playing' ||
|
||||||
entry.sample.segmentSpeedMps === 0
|
entry.sample.segmentSpeedMps === 0 ||
|
||||||
|
(entry.placed === true && entry.placedMoving !== true)
|
||||||
)
|
)
|
||||||
schedulePlayback(entry);
|
schedulePlayback(entry);
|
||||||
if (!state._moving.has(entry)) settleSelectedCard(entry);
|
if (!state._moving.has(entry)) settleSelectedCard(entry);
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
nearGround = parts.height.nearGround(),
|
nearGround = parts.height.nearGround(),
|
||||||
) {
|
) {
|
||||||
pruneMarkerPaths(entry, sample);
|
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) {
|
if (!nearGround) {
|
||||||
const a = entry.fromCart,
|
const a = entry.fromCart,
|
||||||
b = entry.toCart,
|
b = entry.toCart,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
|||||||
parts.queries = createQueries(context);
|
parts.queries = createQueries(context);
|
||||||
parts.snapshots = createVesselSnapshotRenderer({
|
parts.snapshots = createVesselSnapshotRenderer({
|
||||||
state: vesselState.state,
|
state: vesselState.state,
|
||||||
|
placement: services?.placement ?? null,
|
||||||
records: vesselState.state.records,
|
records: vesselState.state.records,
|
||||||
rendering: parts.rendering,
|
rendering: parts.rendering,
|
||||||
tracking: parts.tracking,
|
tracking: parts.tracking,
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export function createLifecycle({
|
|||||||
state.feed.firstConnectTimer = null;
|
state.feed.firstConnectTimer = null;
|
||||||
state.feed.abort = null;
|
state.feed.abort = null;
|
||||||
state.billboardCollection = null;
|
state.billboardCollection = null;
|
||||||
|
if (services.placement)
|
||||||
|
for (const id of state.records.byId.keys()) services.placement.forget(id);
|
||||||
state.records.all = [];
|
state.records.all = [];
|
||||||
state.records.byId = new Map();
|
state.records.byId = new Map();
|
||||||
state.records.unkeyed = [];
|
state.records.unkeyed = [];
|
||||||
@@ -211,6 +213,7 @@ export function createLifecycle({
|
|||||||
invalidateAisSession();
|
invalidateAisSession();
|
||||||
releaseContinuousRender('ais-vessels');
|
releaseContinuousRender('ais-vessels');
|
||||||
components.rendering.cancelTrailingVisibilityPass();
|
components.rendering.cancelTrailingVisibilityPass();
|
||||||
|
components.rendering.cancelPlacementFrame();
|
||||||
unregisterPickOwner('ais-live-vessels');
|
unregisterPickOwner('ais-live-vessels');
|
||||||
components.rendering.setVisible(false);
|
components.rendering.setVisible(false);
|
||||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||||
@@ -232,6 +235,7 @@ export function createLifecycle({
|
|||||||
invalidateAisSession();
|
invalidateAisSession();
|
||||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||||
components.rendering.cancelTrailingVisibilityPass();
|
components.rendering.cancelTrailingVisibilityPass();
|
||||||
|
components.rendering.cancelPlacementFrame();
|
||||||
if (state.feed.abort) state.feed.abort.abort();
|
if (state.feed.abort) state.feed.abort.abort();
|
||||||
unregisterPickOwner('ais-live-vessels');
|
unregisterPickOwner('ais-live-vessels');
|
||||||
components.selection.clearVesselInspection();
|
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 assert from 'node:assert/strict';
|
||||||
import { createVesselLayer } from './index.js';
|
import { createVesselLayer } from './index.js';
|
||||||
import { VISIBILITY_UPDATE_MS } from './policy.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:
|
// 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
|
// 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 = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
function setup() {
|
function setup({ placement = null } = {}) {
|
||||||
let clock = 10_000;
|
let clock = 10_000;
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const holds = new Set();
|
const holds = new Set();
|
||||||
@@ -46,6 +47,7 @@ function setup() {
|
|||||||
releaseContinuousRender: (id) => holds.delete(id),
|
releaseContinuousRender: (id) => holds.delete(id),
|
||||||
governorRequestRender: (reason) => requests.push(reason),
|
governorRequestRender: (reason) => requests.push(reason),
|
||||||
},
|
},
|
||||||
|
...(placement ? { placement: layerPlacement(placement, 'vessels') } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const camera = {
|
const camera = {
|
||||||
@@ -136,3 +138,61 @@ test('destroy cancels a pending trailing frame', (t) => {
|
|||||||
advance(VISIBILITY_UPDATE_MS);
|
advance(VISIBILITY_UPDATE_MS);
|
||||||
assert.deepEqual(requests, []);
|
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). */
|
/** How often vessels move on as the world clock plays (wall ms). */
|
||||||
const FOLLOW_POSITION_MS = 250;
|
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). */
|
/** How often the vessels near enough for a model are gathered again (wall ms). */
|
||||||
const MODEL_CANDIDATES_MS = 500;
|
const MODEL_CANDIDATES_MS = 500;
|
||||||
@@ -58,10 +60,12 @@ export function createRendering({
|
|||||||
visual = { billboard: record.billboard || null };
|
visual = { billboard: record.billboard || null };
|
||||||
visualRecords.set(record, visual);
|
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)
|
const { lat, lon } = followsClock(vesselState)
|
||||||
? vesselPositionAt(record, vesselState.worldNow())
|
? vesselPositionAt(record, vesselState.worldNow())
|
||||||
: record;
|
: (services.placement?.place(record.id, vesselState._aisRuntime.now()) ??
|
||||||
|
record);
|
||||||
const heightM = components.queries.vesselDatumHeightM(
|
const heightM = components.queries.vesselDatumHeightM(
|
||||||
components.tracking.currentGeoidN(lat, lon),
|
components.tracking.currentGeoidN(lat, lon),
|
||||||
VESSEL_LIFT_M,
|
VESSEL_LIFT_M,
|
||||||
@@ -78,9 +82,50 @@ export function createRendering({
|
|||||||
visual.position,
|
visual.position,
|
||||||
new Cesium.Cartesian3(),
|
new Cesium.Cartesian3(),
|
||||||
);
|
);
|
||||||
|
if (services.placement) {
|
||||||
|
visual.drawnLat = lat;
|
||||||
|
visual.drawnLon = lon;
|
||||||
|
}
|
||||||
return visual;
|
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() {
|
function resetRecordVisuals() {
|
||||||
visualRecords = new WeakMap();
|
visualRecords = new WeakMap();
|
||||||
}
|
}
|
||||||
@@ -391,6 +436,7 @@ export function createRendering({
|
|||||||
if (state.preRenderRemover || !viewer) return;
|
if (state.preRenderRemover || !viewer) return;
|
||||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||||
if (followsClock(vesselState)) followClockPositions();
|
if (followsClock(vesselState)) followClockPositions();
|
||||||
|
else if (services.placement && state.feed.enabled) followPlacements();
|
||||||
updateVisibility();
|
updateVisibility();
|
||||||
if (state.feed.enabled) updateModels();
|
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) {
|
function scheduleTrailingVisibilityPass(now) {
|
||||||
if (state.trailingVisibilityTimer) return;
|
if (state.trailingVisibilityTimer) return;
|
||||||
const waitMs = Math.max(
|
const waitMs = Math.max(
|
||||||
@@ -787,6 +877,8 @@ export function createRendering({
|
|||||||
installRuntime,
|
installRuntime,
|
||||||
updateVisibility,
|
updateVisibility,
|
||||||
cancelTrailingVisibilityPass,
|
cancelTrailingVisibilityPass,
|
||||||
|
cancelPlacementFrame,
|
||||||
|
followPlacements,
|
||||||
applyVesselFocusDeemphasis,
|
applyVesselFocusDeemphasis,
|
||||||
makeOccluder,
|
makeOccluder,
|
||||||
isVisible,
|
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({
|
export function createVesselSnapshotRenderer({
|
||||||
state,
|
state,
|
||||||
|
placement = null,
|
||||||
records,
|
records,
|
||||||
rendering,
|
rendering,
|
||||||
tracking,
|
tracking,
|
||||||
@@ -17,6 +41,13 @@ export function createVesselSnapshotRenderer({
|
|||||||
rendering.ensureCollections(viewer);
|
rendering.ensureCollections(viewer);
|
||||||
const occluder = rendering.makeOccluder();
|
const occluder = rendering.makeOccluder();
|
||||||
const follow = followsClock();
|
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(
|
records.reconcile(
|
||||||
rows,
|
rows,
|
||||||
{
|
{
|
||||||
@@ -28,6 +59,7 @@ export function createVesselSnapshotRenderer({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
add(record) {
|
add(record) {
|
||||||
|
observe(record);
|
||||||
rendering.prepareRecordVisual(record);
|
rendering.prepareRecordVisual(record);
|
||||||
rendering.addRecordPrimitives(record, occluder);
|
rendering.addRecordPrimitives(record, occluder);
|
||||||
},
|
},
|
||||||
@@ -35,6 +67,7 @@ export function createVesselSnapshotRenderer({
|
|||||||
return rendering.shipIcon(record, record === state.selectedRecord);
|
return rendering.shipIcon(record, record === state.selectedRecord);
|
||||||
},
|
},
|
||||||
updated(record, prevIcon) {
|
updated(record, prevIcon) {
|
||||||
|
observe(record);
|
||||||
const selected = record === state.selectedRecord;
|
const selected = record === state.selectedRecord;
|
||||||
const visual = rendering.prepareRecordVisual(record);
|
const visual = rendering.prepareRecordVisual(record);
|
||||||
if (visual.billboard) {
|
if (visual.billboard) {
|
||||||
@@ -56,6 +89,7 @@ export function createVesselSnapshotRenderer({
|
|||||||
rendering.removeRecordPrimitives(record);
|
rendering.removeRecordPrimitives(record);
|
||||||
},
|
},
|
||||||
removed(id) {
|
removed(id) {
|
||||||
|
placement?.forget(id);
|
||||||
if (state.trailId === id) tracking.clearSelectedVesselTrail();
|
if (state.trailId === id) tracking.clearSelectedVesselTrail();
|
||||||
},
|
},
|
||||||
staleSelected(record) {
|
staleSelected(record) {
|
||||||
|
|||||||
@@ -162,6 +162,18 @@ export function createTesting({
|
|||||||
components.rendering.updateVisibility(force);
|
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. */
|
/** True while a trailing visibility frame is scheduled. Test-only seam. */
|
||||||
|
|
||||||
function _hasTrailingVisibilityPassForTest() {
|
function _hasTrailingVisibilityPassForTest() {
|
||||||
@@ -194,6 +206,8 @@ export function createTesting({
|
|||||||
_getVesselFeedStateForTest,
|
_getVesselFeedStateForTest,
|
||||||
_getVesselStateForTest,
|
_getVesselStateForTest,
|
||||||
_runVisibilityPassForTest,
|
_runVisibilityPassForTest,
|
||||||
|
_prepareVesselVisualForTest,
|
||||||
|
_runPlacementPassForTest,
|
||||||
_hasTrailingVisibilityPassForTest,
|
_hasTrailingVisibilityPassForTest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,11 @@ export function createTracking({
|
|||||||
heightM,
|
heightM,
|
||||||
);
|
);
|
||||||
if (visual.billboard) visual.billboard.position = visual.position;
|
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).
|
// height, layer stats, selected route lines).
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createRailLayer } from '../layers/rail/index.js';
|
import { createRailLayer } from '../layers/rail/index.js';
|
||||||
|
import { layerPlacement } from '../data/placementProvider.js';
|
||||||
import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js';
|
import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js';
|
||||||
import { WORLD_FOCUS_FRAMING } from '../worldFocus.js';
|
import { WORLD_FOCUS_FRAMING } from '../worldFocus.js';
|
||||||
import { publicRailCatalog } from '../data/railFeeds.js';
|
import { publicRailCatalog } from '../data/railFeeds.js';
|
||||||
@@ -82,7 +83,8 @@ const headersOf = (values = {}) => ({
|
|||||||
* catalog?: object[]|Promise<object[]>|Function|false,
|
* catalog?: object[]|Promise<object[]>|Function|false,
|
||||||
* ground?: object,
|
* ground?: object,
|
||||||
* stops?: object|null, scheduleStops?: boolean,
|
* 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`:
|
* `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
|
* 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
|
* 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
|
* 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
|
* source has no path route at all); `fleet`: the fleet-model services
|
||||||
* (testSupport/fleetModelFakes.mjs), with a camera that looks straight
|
* (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(
|
export function railLayerHarness(
|
||||||
t,
|
t,
|
||||||
@@ -107,6 +110,7 @@ export function railLayerHarness(
|
|||||||
scheduleStops = false,
|
scheduleStops = false,
|
||||||
paths = null,
|
paths = null,
|
||||||
fleet = null,
|
fleet = null,
|
||||||
|
placement = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const T0 = RAIL_HARNESS_T0;
|
const T0 = RAIL_HARNESS_T0;
|
||||||
@@ -332,6 +336,7 @@ export function railLayerHarness(
|
|||||||
warmGroundFloor() {},
|
warmGroundFloor() {},
|
||||||
},
|
},
|
||||||
...(fleet ? { fleet } : {}),
|
...(fleet ? { fleet } : {}),
|
||||||
|
...(placement ? { placement: layerPlacement(placement, 'rail') } : {}),
|
||||||
camera: {
|
camera: {
|
||||||
framing: WORLD_FOCUS_FRAMING.train,
|
framing: WORLD_FOCUS_FRAMING.train,
|
||||||
requestWorldFocus(detail) {
|
requestWorldFocus(detail) {
|
||||||
|
|||||||
Reference in new issue
Block a user