mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor(vessels): Cesium rendering behind a renderer interface
src/layers/vessels/render/cesium.js holds the Cesium objects the layer built itself, one for one: the translucent BillboardCollection of chevrons (centred, screen-aligned, never depth-tested), the horizon occluder, window projection, canvas size, viewer.trackedEntityChanged, and the trail (data/trailRenderer's entity polyline, kept on Cesium: the engine.lines Primitive path did not draw below the photoreal mesh in round 6 — the plan's T13/T15 blocker; flights does the same). The logic imports no Cesium: geo math, Rgba WHITE, the chevron artwork in icons.js, clicks through the engine's selection dispatcher (own-rule owner, same rule). Registered as CESIUM_LAYER_RENDERERS['ais-live-vessels']; src/layers/vessels joins the boundary rule. W1 replay vs7c7422e2: vessels/flights/military/localAdsb counts, known objects, screen points, requests and shot sha256 identical. qa-vessel-cards --data synthetic 2/2 and qa-vessel-datum (?qaFeed= vessels:fixture) 4/4, the same as6eec8f6c. Tests moved with the code (same meaning): cameraHandoff (the click body is handleClick), inputOwnership (the guard precedes the click rule's read of the pick), renderers.test (vessels in the extracted list), importDirections (an unmoved example layer is satellites), the ownership services fixture (renderer service).
This commit is contained in:
1 parent
6b27b21cc4
commit
1fe8267550
23 files changed
+747
-135
No files matched your search
@@ -37,6 +37,7 @@ const RENDERED_LAYER_DIRS = [
|
|||||||
'src/layers/flights/',
|
'src/layers/flights/',
|
||||||
'src/layers/military/',
|
'src/layers/military/',
|
||||||
'src/layers/localAdsb/',
|
'src/layers/localAdsb/',
|
||||||
|
'src/layers/vessels/',
|
||||||
'src/layers/aircraft/',
|
'src/layers/aircraft/',
|
||||||
'src/layers/shared/',
|
'src/layers/shared/',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -361,6 +361,10 @@
|
|||||||
"src/layers/aircraft/classification.test.mjs",
|
"src/layers/aircraft/classification.test.mjs",
|
||||||
"src/data/aisLiveVessels.js",
|
"src/data/aisLiveVessels.js",
|
||||||
"src/layers/vessels/cards.js",
|
"src/layers/vessels/cards.js",
|
||||||
|
"src/layers/vessels/icons.js",
|
||||||
|
"src/layers/vessels/render/cesium.js",
|
||||||
|
"src/layers/vessels/render/contract.js",
|
||||||
|
"src/layers/vessels/renderer.test.mjs",
|
||||||
"src/layers/vessels/evidence.js",
|
"src/layers/vessels/evidence.js",
|
||||||
"src/layers/vessels/index.js",
|
"src/layers/vessels/index.js",
|
||||||
"src/layers/vessels/ingestion.js",
|
"src/layers/vessels/ingestion.js",
|
||||||
|
|||||||
@@ -1016,10 +1016,12 @@
|
|||||||
"src/layers/vessels/policy.js",
|
"src/layers/vessels/policy.js",
|
||||||
"src/layers/vessels/queries.js",
|
"src/layers/vessels/queries.js",
|
||||||
"src/layers/vessels/rendering.js",
|
"src/layers/vessels/rendering.js",
|
||||||
|
"src/layers/vessels/icons.js",
|
||||||
"src/layers/vessels/selection.js",
|
"src/layers/vessels/selection.js",
|
||||||
"src/layers/vessels/state.js",
|
"src/layers/vessels/state.js",
|
||||||
"src/layers/vessels/testing.js",
|
"src/layers/vessels/testing.js",
|
||||||
"src/layers/vessels/tracking.js",
|
"src/layers/vessels/tracking.js",
|
||||||
|
"src/layers/shared/color.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/vesselLabels.js",
|
"src/data/vesselLabels.js",
|
||||||
"src/layers/vessels/records.js",
|
"src/layers/vessels/records.js",
|
||||||
@@ -2170,6 +2172,7 @@
|
|||||||
"src/layers/vessels/policy.js",
|
"src/layers/vessels/policy.js",
|
||||||
"src/layers/vessels/queries.js",
|
"src/layers/vessels/queries.js",
|
||||||
"src/layers/vessels/rendering.js",
|
"src/layers/vessels/rendering.js",
|
||||||
|
"src/layers/vessels/icons.js",
|
||||||
"src/layers/vessels/selection.js",
|
"src/layers/vessels/selection.js",
|
||||||
"src/layers/vessels/state.js",
|
"src/layers/vessels/state.js",
|
||||||
"src/layers/vessels/testing.js",
|
"src/layers/vessels/testing.js",
|
||||||
@@ -2602,6 +2605,7 @@
|
|||||||
"src/layers/military/render/navara.js",
|
"src/layers/military/render/navara.js",
|
||||||
"src/layers/aircraft/qaDrawn.js",
|
"src/layers/aircraft/qaDrawn.js",
|
||||||
"src/layers/localAdsb/render/cesium.js",
|
"src/layers/localAdsb/render/cesium.js",
|
||||||
|
"src/layers/vessels/render/cesium.js",
|
||||||
"src/layers/localAdsb/render/navara.js"
|
"src/layers/localAdsb/render/navara.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
@@ -2975,6 +2979,7 @@
|
|||||||
"src/layers/vessels/policy.js",
|
"src/layers/vessels/policy.js",
|
||||||
"src/layers/vessels/queries.js",
|
"src/layers/vessels/queries.js",
|
||||||
"src/layers/vessels/rendering.js",
|
"src/layers/vessels/rendering.js",
|
||||||
|
"src/layers/vessels/icons.js",
|
||||||
"src/layers/vessels/selection.js",
|
"src/layers/vessels/selection.js",
|
||||||
"src/layers/vessels/state.js",
|
"src/layers/vessels/state.js",
|
||||||
"src/layers/vessels/testing.js",
|
"src/layers/vessels/testing.js",
|
||||||
@@ -3180,7 +3185,8 @@
|
|||||||
"src/layers/flights/render/cesium.js",
|
"src/layers/flights/render/cesium.js",
|
||||||
"src/layers/military/render/cesium.js",
|
"src/layers/military/render/cesium.js",
|
||||||
"src/layers/aircraft/qaDrawn.js",
|
"src/layers/aircraft/qaDrawn.js",
|
||||||
"src/layers/localAdsb/render/cesium.js"
|
"src/layers/localAdsb/render/cesium.js",
|
||||||
|
"src/layers/vessels/render/cesium.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ import * as sprites from '../../data/spriteOrder.js';
|
|||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
import * as worldFocus from '../../worldFocus.js';
|
import * as worldFocus from '../../worldFocus.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
import * as selection from '../../data/selectionDispatcher.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
|
import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.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. */
|
||||||
export function createApplicationVessels({ source, options = {} }) {
|
export function createApplicationVessels({
|
||||||
|
source,
|
||||||
|
options = {},
|
||||||
|
// Test seam: the renderer factory (default: the engine's, from the registry).
|
||||||
|
renderer = {
|
||||||
|
create: ({ engine = null, ...rendererOptions }) =>
|
||||||
|
engine
|
||||||
|
? rendererFor('ais-live-vessels', engine, rendererOptions)
|
||||||
|
: createVesselsCesiumRenderer(null, rendererOptions),
|
||||||
|
},
|
||||||
|
}) {
|
||||||
return createVesselLayer({
|
return createVesselLayer({
|
||||||
source,
|
source,
|
||||||
options,
|
options,
|
||||||
@@ -26,6 +39,10 @@ export function createApplicationVessels({ source, options = {} }) {
|
|||||||
focus,
|
focus,
|
||||||
worldFocus,
|
worldFocus,
|
||||||
render,
|
render,
|
||||||
|
selection,
|
||||||
|
// The engine's vessel renderer from the registry (render/cesium.js on
|
||||||
|
// Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
|
||||||
|
renderer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
|||||||
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
||||||
import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js';
|
import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js';
|
||||||
import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js';
|
import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js';
|
||||||
|
import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
|
||||||
|
|
||||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
|||||||
flights: createFlightsCesiumRenderer,
|
flights: createFlightsCesiumRenderer,
|
||||||
military: createMilitaryCesiumRenderer,
|
military: createMilitaryCesiumRenderer,
|
||||||
'local-adsb': createLocalAdsbCesiumRenderer,
|
'local-adsb': createLocalAdsbCesiumRenderer,
|
||||||
|
'ais-live-vessels': createVesselsCesiumRenderer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The other application layers still draw themselves on Cesium.
|
// The other application layers still draw themselves on Cesium.
|
||||||
|
|||||||
@@ -56,12 +56,21 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the aircraft layers have extracted Cesium renderers; the others still draw themselves', () => {
|
test('the aircraft layers and vessels have extracted Cesium renderers; the others still draw themselves', () => {
|
||||||
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||||
'flights',
|
'flights',
|
||||||
'military',
|
'military',
|
||||||
'local-adsb',
|
'local-adsb',
|
||||||
|
'ais-live-vessels',
|
||||||
]);
|
]);
|
||||||
|
const vessels = rendererFor(
|
||||||
|
'ais-live-vessels',
|
||||||
|
{ kind: 'cesium' },
|
||||||
|
{
|
||||||
|
viewer: { scene: {}, camera: {} },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(vessels.kind, 'cesium');
|
||||||
const viewer = { scene: {}, camera: {} };
|
const viewer = { scene: {}, camera: {} };
|
||||||
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
|
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
|
||||||
assert.equal(renderer.kind, 'cesium');
|
assert.equal(renderer.kind, 'cesium');
|
||||||
|
|||||||
@@ -388,9 +388,11 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
|||||||
assert.match(source, /requestWorldFocus\(\{/);
|
assert.match(source, /requestWorldFocus\(\{/);
|
||||||
assert.doesNotMatch(source, /camera\.flyTo/);
|
assert.doesNotMatch(source, /camera\.flyTo/);
|
||||||
}
|
}
|
||||||
|
// Vessels' click rule is a function the engine's selection dispatcher
|
||||||
|
// calls (phase 3, 13a): the same body, out of the old LEFT_CLICK handler.
|
||||||
const vesselClick = body(
|
const vesselClick = body(
|
||||||
vessels,
|
vessels,
|
||||||
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
|
/function handleClick\(click\) \{([\s\S]*?)\n \}/,
|
||||||
'vessel click',
|
'vessel click',
|
||||||
);
|
);
|
||||||
ordered(vesselClick, [
|
ordered(vesselClick, [
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ test('every scene click handler consults ownership before it picks', () => {
|
|||||||
'src/layers/submarineCables/interaction.js',
|
'src/layers/submarineCables/interaction.js',
|
||||||
'viewer.scene.pick(click.position)',
|
'viewer.scene.pick(click.position)',
|
||||||
],
|
],
|
||||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
// Vessels take the engine dispatcher's pick (phase 3, 13a): the guard
|
||||||
|
// comes before the click rule reads it.
|
||||||
|
['src/layers/vessels/selection.js', 'resolvePickId(picked)'],
|
||||||
];
|
];
|
||||||
for (const [file, firstPick] of guarded) {
|
for (const [file, firstPick] of guarded) {
|
||||||
const source = read(file);
|
const source = read(file);
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
|
/** Every chevron hue vesselTypeCss() can return, once each. */
|
||||||
|
export const VESSEL_TYPE_CSS_COLORS = Object.freeze([
|
||||||
|
...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)),
|
||||||
|
]);
|
||||||
|
|
||||||
const NUMERIC_TYPE_SPECIALS = {
|
const NUMERIC_TYPE_SPECIALS = {
|
||||||
30: 'FISHING',
|
30: 'FISHING',
|
||||||
31: 'TOWING',
|
31: 'TOWING',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||||
|
|
||||||
export function createEvidence({
|
export function createEvidence({
|
||||||
@@ -13,12 +12,13 @@ export function createEvidence({
|
|||||||
/** Replace live AIS rows through the production reconciliation path (DEV only). */
|
/** Replace live AIS rows through the production reconciliation path (DEV only). */
|
||||||
|
|
||||||
function _setFocusEvidenceVessels(rows = []) {
|
function _setFocusEvidenceVessels(rows = []) {
|
||||||
if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) {
|
const scene = state.viewer || state.renderer?.view;
|
||||||
|
if (!FOCUS_EVIDENCE_DEV || !scene || !state.renderer?.fleet.attached()) {
|
||||||
return { ok: false, count: 0 };
|
return { ok: false, count: 0 };
|
||||||
}
|
}
|
||||||
components.selection.clearVesselInspection();
|
components.selection.clearVesselInspection();
|
||||||
components.snapshots.reconcileVessels(
|
components.snapshots.reconcileVessels(
|
||||||
state.viewer,
|
scene,
|
||||||
Array.isArray(rows) ? rows : [],
|
Array.isArray(rows) ? rows : [],
|
||||||
);
|
);
|
||||||
state.feed.count = state.records.all.length;
|
state.feed.count = state.records.all.length;
|
||||||
@@ -37,15 +37,11 @@ export function createEvidence({
|
|||||||
/** JSON-safe vessel alpha/position snapshot for the evidence report. */
|
/** JSON-safe vessel alpha/position snapshot for the evidence report. */
|
||||||
|
|
||||||
function _focusEvidenceVesselSnapshot() {
|
function _focusEvidenceVesselSnapshot() {
|
||||||
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
|
const view = state.renderer?.view;
|
||||||
|
if (!FOCUS_EVIDENCE_DEV || !view) return [];
|
||||||
return state.records.all.map((record) => {
|
return state.records.all.map((record) => {
|
||||||
const bb = components.rendering.getVisual(record).billboard;
|
const bb = components.rendering.getVisual(record).billboard;
|
||||||
const screen = bb?.position
|
const screen = bb?.position ? view.worldToWindow(bb.position) : null;
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
||||||
state.viewer.scene,
|
|
||||||
bb.position,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
id: record.mmsi,
|
id: record.mmsi,
|
||||||
show: bb?.show === true,
|
show: bb?.show === true,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// The vessel chevron artwork, engine-neutral: one SVG data URL per colour
|
||||||
|
// and variant. Cesium's billboards load the URL; the Navara renderer draws
|
||||||
|
// the same SVGs into its atlas, keyed by the same URL.
|
||||||
|
|
||||||
|
import { VESSEL_TYPE_CSS_COLORS } from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
|
/** The chevron's artwork size (CSS px), before the billboard scale. */
|
||||||
|
export const VESSEL_ICON_PX = 32;
|
||||||
|
|
||||||
|
/** The selected variant's colour. */
|
||||||
|
export const VESSEL_SELECTED_CSS = '#ffffff';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chevron/delta-wing SVG data URL tinted `cssColor`. The shape points north
|
||||||
|
* (up) so the billboard rotation maps directly to heading.
|
||||||
|
* @param {string} cssColor
|
||||||
|
* @param {boolean} selected The white/brighter selected variant.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function vesselChevronIcon(cssColor, selected) {
|
||||||
|
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||||
|
const strokeWidth = selected ? 1.1 : 0.7;
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<g transform="translate(16,16)">
|
||||||
|
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every chevron the layer can draw: the type colours and the selected one. */
|
||||||
|
export function allVesselChevronIcons() {
|
||||||
|
return [
|
||||||
|
...VESSEL_TYPE_CSS_COLORS.map((css) => vesselChevronIcon(css, false)),
|
||||||
|
vesselChevronIcon(VESSEL_SELECTED_CSS, true),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -36,7 +36,9 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
|||||||
parts.ingestion = createIngestion({
|
parts.ingestion = createIngestion({
|
||||||
feed: vesselState.state.feed,
|
feed: vesselState.state.feed,
|
||||||
readSource: () => vesselState._source,
|
readSource: () => vesselState._source,
|
||||||
readViewer: () => vesselState.state.viewer,
|
// The viewer, or the renderer's view on an engine without one.
|
||||||
|
readViewer: () =>
|
||||||
|
vesselState.state.viewer || vesselState.state.renderer?.view || null,
|
||||||
readArea: vesselViewArea,
|
readArea: vesselViewArea,
|
||||||
getRowLimit: parts.rendering.renderRowLimit,
|
getRowLimit: parts.rendering.renderRowLimit,
|
||||||
readCount: () => vesselState.state.records.all.length,
|
readCount: () => vesselState.state.records.all.length,
|
||||||
|
|||||||
@@ -117,11 +117,13 @@ export function createLifecycle({
|
|||||||
state.feed.firstConnectDeadline = null;
|
state.feed.firstConnectDeadline = null;
|
||||||
state.feed.firstConnectTimer = null;
|
state.feed.firstConnectTimer = null;
|
||||||
state.feed.abort = null;
|
state.feed.abort = null;
|
||||||
state.billboardCollection = null;
|
state.renderer?.destroy();
|
||||||
|
state.renderer = null;
|
||||||
state.records.all = [];
|
state.records.all = [];
|
||||||
state.records.byMmsi = new Map();
|
state.records.byMmsi = new Map();
|
||||||
state.records.unkeyed = [];
|
state.records.unkeyed = [];
|
||||||
state.clickHandler = null;
|
state.clickHandler = null;
|
||||||
|
state.selectionRemove = null;
|
||||||
state.keyTarget = null;
|
state.keyTarget = null;
|
||||||
state.keydownHandler = null;
|
state.keydownHandler = null;
|
||||||
state.trackedEntityRemover = null;
|
state.trackedEntityRemover = null;
|
||||||
@@ -141,7 +143,8 @@ export function createLifecycle({
|
|||||||
const methods = {
|
const methods = {
|
||||||
/** Configure the source before initialization; an active layer keeps its owner. */
|
/** Configure the source before initialization; an active layer keeps its owner. */
|
||||||
setSource(source) {
|
setSource(source) {
|
||||||
if (state.viewer)
|
// Initialized: a viewer, or an engine's renderer without one.
|
||||||
|
if (state.viewer || state.renderer?.engine)
|
||||||
throw new Error('Configure the source before layer initialization');
|
throw new Error('Configure the source before layer initialization');
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('A snapshot source is required');
|
throw new TypeError('A snapshot source is required');
|
||||||
@@ -149,17 +152,28 @@ export function createLifecycle({
|
|||||||
this.source = source.label || this.source;
|
this.source = source.label || this.source;
|
||||||
},
|
},
|
||||||
|
|
||||||
init(viewer) {
|
/**
|
||||||
|
* Creates the engine's vessel renderer (render/*.js), its sprite
|
||||||
|
* collection, the click rule and the per-frame pass.
|
||||||
|
* @param {object|null} viewer The Cesium viewer (null on an engine
|
||||||
|
* without one).
|
||||||
|
* @param {{ engine?: object }} [context] LayerLifecycle's context.
|
||||||
|
*/
|
||||||
|
init(viewer, { engine = null } = {}) {
|
||||||
if (typeof vesselState._source?.getSnapshot !== 'function')
|
if (typeof vesselState._source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('A snapshot source is required');
|
throw new TypeError('A snapshot source is required');
|
||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
components.rendering.ensureCollections(viewer);
|
state.renderer ??= createRenderer(viewer, engine);
|
||||||
|
// The scene the layer draws in: the viewer, or the renderer's view on
|
||||||
|
// an engine without one.
|
||||||
|
const scene = viewer || state.renderer?.view || null;
|
||||||
|
components.rendering.ensureCollections(scene);
|
||||||
vesselState._vesselOverlayHost.setVisible(
|
vesselState._vesselOverlayHost.setVisible(
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
components.selection.installInteraction(viewer);
|
components.selection.installInteraction(scene);
|
||||||
components.rendering.installRuntime(viewer);
|
components.rendering.installRuntime(scene);
|
||||||
restoreSpriteOrder(viewer);
|
restoreSpriteOrder(viewer);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -169,8 +183,11 @@ export function createLifecycle({
|
|||||||
if (!wasEnabled) beginAisSession();
|
if (!wasEnabled) beginAisSession();
|
||||||
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
|
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
|
||||||
const activeViewer = viewer || state.viewer;
|
const activeViewer = viewer || state.viewer;
|
||||||
components.rendering.ensureCollections(activeViewer);
|
// The scene the layer draws in: the viewer, or the renderer's view on
|
||||||
components.selection.installInteraction(activeViewer);
|
// an engine without one.
|
||||||
|
const scene = activeViewer || state.renderer?.view || null;
|
||||||
|
components.rendering.ensureCollections(scene);
|
||||||
|
components.selection.installInteraction(scene);
|
||||||
components.rendering.setVisible(true);
|
components.rendering.setVisible(true);
|
||||||
// Height-datum fix: warm the geoid grid once per layer-enable, never
|
// Height-datum fix: warm the geoid grid once per layer-enable, never
|
||||||
// blocking a poll. The first refresh may land pre-resolve (N = 0), and
|
// blocking a poll. The first refresh may land pre-resolve (N = 0), and
|
||||||
@@ -198,12 +215,20 @@ export function createLifecycle({
|
|||||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||||
// Vessels are asked for by view, so ask again when the view moves away.
|
// Vessels are asked for by view, so ask again when the view moves away.
|
||||||
removeViewListener();
|
removeViewListener();
|
||||||
|
const onMoveEnd = () => {
|
||||||
|
components.ingestion.refreshIfMoved(scene);
|
||||||
|
};
|
||||||
|
if (state.renderer)
|
||||||
|
state.feed.removeViewListener = state.renderer.view.on(
|
||||||
|
'moveEnd',
|
||||||
|
onMoveEnd,
|
||||||
|
);
|
||||||
|
else {
|
||||||
const moveEnd = activeViewer?.camera?.moveEnd;
|
const moveEnd = activeViewer?.camera?.moveEnd;
|
||||||
if (typeof moveEnd?.addEventListener === 'function')
|
if (typeof moveEnd?.addEventListener === 'function')
|
||||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
state.feed.removeViewListener = moveEnd.addEventListener(onMoveEnd);
|
||||||
components.ingestion.refreshIfMoved(activeViewer);
|
}
|
||||||
});
|
return components.ingestion.loadLivePositions(scene);
|
||||||
return components.ingestion.loadLivePositions(activeViewer);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
@@ -225,18 +250,15 @@ export function createLifecycle({
|
|||||||
state.feed.loadingLabel = '';
|
state.feed.loadingLabel = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(viewer) {
|
destroy() {
|
||||||
removeViewListener();
|
removeViewListener();
|
||||||
const activeViewer = viewer || state.viewer;
|
|
||||||
invalidateAisSession();
|
invalidateAisSession();
|
||||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||||
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();
|
||||||
components.tracking.destroySelectedVesselTrail();
|
components.tracking.destroySelectedVesselTrail();
|
||||||
if (state.billboardCollection && activeViewer?.scene?.primitives) {
|
state.renderer?.fleet.detach();
|
||||||
activeViewer.scene.primitives.remove(state.billboardCollection);
|
|
||||||
}
|
|
||||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||||
vesselState._vesselOverlayHost.setVisible(
|
vesselState._vesselOverlayHost.setVisible(
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
@@ -250,6 +272,17 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The engine's vessel renderer (the Cesium one without an engine). */
|
||||||
|
function createRenderer(viewer, engine) {
|
||||||
|
return (
|
||||||
|
services.renderer?.create({
|
||||||
|
viewer,
|
||||||
|
engine,
|
||||||
|
createTrail: services.trails?.createTrail ?? null,
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function removeViewListener() {
|
function removeViewListener() {
|
||||||
const remove = state.feed.removeViewListener;
|
const remove = state.feed.removeViewListener;
|
||||||
state.feed.removeViewListener = null;
|
state.feed.removeViewListener = null;
|
||||||
@@ -266,6 +299,7 @@ export function createLifecycle({
|
|||||||
isDefinitiveTransportFailure,
|
isDefinitiveTransportFailure,
|
||||||
markAisUnavailable,
|
markAisUnavailable,
|
||||||
resetState,
|
resetState,
|
||||||
|
createRenderer,
|
||||||
methods,
|
methods,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 { createVesselState } from './state.js';
|
import { createVesselState } from './state.js';
|
||||||
|
import { createVesselsCesiumRenderer } from './render/cesium.js';
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
function services() {
|
function services() {
|
||||||
@@ -30,6 +31,10 @@ function services() {
|
|||||||
},
|
},
|
||||||
worldFocus: {},
|
worldFocus: {},
|
||||||
render: { releaseContinuousRender: noop },
|
render: { releaseContinuousRender: noop },
|
||||||
|
// The renderer service as the application's without an engine (Cesium).
|
||||||
|
renderer: {
|
||||||
|
create: (options) => createVesselsCesiumRenderer(null, options),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function setup(source, options = {}) {
|
function setup(source, options = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// The vessels layer's renderer on Cesium (phase 3 Task 13a): the Cesium
|
||||||
|
// objects the layer used to build itself, moved here one for one — its
|
||||||
|
// BillboardCollection of ship chevrons (translucent blend, screen-aligned
|
||||||
|
// rotation, centred, never depth-tested), the camera reads (horizon
|
||||||
|
// occluder, window projection, canvas size), the "another layer took the
|
||||||
|
// camera" signal (viewer.trackedEntityChanged) and the selected vessel's
|
||||||
|
// trail (data/trailRenderer's entity polyline). Nothing here decides
|
||||||
|
// anything: the layer's logic decides, this draws.
|
||||||
|
//
|
||||||
|
// The trail stays an entity polyline (createTrail) rather than engine.lines:
|
||||||
|
// Cesium's engine.lines is a Primitive with a depth-fail appearance, the
|
||||||
|
// path that did not draw segments below the photoreal mesh in round 6
|
||||||
|
// (plan, the T13/T15 blocker; flights keeps the same entity path in 10a).
|
||||||
|
//
|
||||||
|
// Interface (shared with render/navara.js): ./contract.js.
|
||||||
|
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
|
||||||
|
* @param {object|null} engine The Cesium globe engine (null in unit tests).
|
||||||
|
* @param {{ viewer: object, createTrail?: Function,
|
||||||
|
* adopt?: { billboardCollection?: object } }} options
|
||||||
|
* `createTrail`: data/trailRenderer's (no trail without it); `adopt`: an
|
||||||
|
* existing collection (test seam).
|
||||||
|
* @returns {import('./contract.js').VesselRenderer}
|
||||||
|
*/
|
||||||
|
export function createVesselsCesiumRenderer(
|
||||||
|
engine,
|
||||||
|
{ viewer = null, createTrail = null, adopt = {} } = {},
|
||||||
|
) {
|
||||||
|
let collection = adopt.billboardCollection ?? null;
|
||||||
|
|
||||||
|
const view = {
|
||||||
|
get camera() {
|
||||||
|
return viewer?.camera;
|
||||||
|
},
|
||||||
|
get scene() {
|
||||||
|
return viewer?.scene;
|
||||||
|
},
|
||||||
|
/** For this pass: the ellipsoid horizon at the camera, or null. */
|
||||||
|
horizon() {
|
||||||
|
const cameraPosition = viewer?.camera?.positionWC;
|
||||||
|
if (!cameraPosition) return null;
|
||||||
|
return new Cesium.EllipsoidalOccluder(
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
cameraPosition,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
worldToWindow: (position, result) =>
|
||||||
|
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
viewer.scene,
|
||||||
|
position,
|
||||||
|
result,
|
||||||
|
),
|
||||||
|
canvasSize() {
|
||||||
|
const canvas = viewer?.scene?.canvas || viewer?.canvas;
|
||||||
|
return {
|
||||||
|
width: Number(canvas?.clientWidth) || 0,
|
||||||
|
height: Number(canvas?.clientHeight) || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
requestRender: () => viewer?.scene?.requestRender?.(),
|
||||||
|
on(event, listener) {
|
||||||
|
const source =
|
||||||
|
event === 'preRender'
|
||||||
|
? viewer?.scene?.preRender
|
||||||
|
: event === 'moveEnd'
|
||||||
|
? viewer?.camera?.moveEnd
|
||||||
|
: null;
|
||||||
|
return typeof source?.addEventListener === 'function'
|
||||||
|
? source.addEventListener(listener)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fleet = {
|
||||||
|
/** The Cesium collection (data/spriteOrder registers it), or null. */
|
||||||
|
get collection() {
|
||||||
|
return collection;
|
||||||
|
},
|
||||||
|
attached: () => Boolean(collection),
|
||||||
|
attach({ show }) {
|
||||||
|
if (collection || !viewer) return;
|
||||||
|
collection = new Cesium.BillboardCollection({
|
||||||
|
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||||
|
});
|
||||||
|
collection.show = show;
|
||||||
|
viewer.scene.primitives.add(collection);
|
||||||
|
},
|
||||||
|
setVisible(show) {
|
||||||
|
if (collection) collection.show = show;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* One chevron: the Billboard is the handle (the logic writes position,
|
||||||
|
* show, rotation, image, scale and color on it).
|
||||||
|
*/
|
||||||
|
add({ position, show, image, scale, rotation, id }) {
|
||||||
|
return collection.add({
|
||||||
|
position,
|
||||||
|
show,
|
||||||
|
image,
|
||||||
|
scale,
|
||||||
|
rotation,
|
||||||
|
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||||
|
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
|
// Locked height-datum principle #2: contacts are ALWAYS visible —
|
||||||
|
// depth-test-free sprites; the EllipsoidalOccluder handles the far
|
||||||
|
// side. (The tile sea mesh ≠ the geoid exactly, so a depth-tested
|
||||||
|
// chevron at the geoid still clips out behind local tide/mesh noise.)
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
remove(handle) {
|
||||||
|
collection?.remove(handle);
|
||||||
|
},
|
||||||
|
detach() {
|
||||||
|
if (collection && viewer?.scene?.primitives)
|
||||||
|
viewer.scene.primitives.remove(collection);
|
||||||
|
collection = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The record a pick is of (Cesium: the billboard id is the record). */
|
||||||
|
const pickedRecord = (picked) => {
|
||||||
|
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||||
|
return rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi')
|
||||||
|
? rawId
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vessels never set viewer.trackedEntity: a new tracked entity belongs to
|
||||||
|
// another layer, which takes the scene.
|
||||||
|
const follow = {
|
||||||
|
onChanged: (listener) =>
|
||||||
|
viewer.trackedEntityChanged.addEventListener(listener),
|
||||||
|
active: () => Boolean(viewer?.trackedEntity),
|
||||||
|
};
|
||||||
|
|
||||||
|
const trails = {
|
||||||
|
create: ({ color, width }) =>
|
||||||
|
typeof createTrail === 'function' && viewer
|
||||||
|
? createTrail(viewer, { color, width })
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'cesium',
|
||||||
|
engine,
|
||||||
|
view,
|
||||||
|
fleet,
|
||||||
|
pickedRecord,
|
||||||
|
follow,
|
||||||
|
trails,
|
||||||
|
destroy() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// The vessel renderer: what the vessels layer's logic asks of an engine to
|
||||||
|
// draw its ship chevrons and the selected vessel's trail, and to read the
|
||||||
|
// camera. render/cesium.js implements it over Cesium (the objects the layer
|
||||||
|
// used to build itself, one for one); render/navara.js on the Navara engine
|
||||||
|
// over engine.movers / engine.lines / engine.projector. Nothing here imports
|
||||||
|
// an engine.
|
||||||
|
//
|
||||||
|
// The logic holds HANDLES and writes plain fields on them; the field names
|
||||||
|
// are Cesium's Billboard fields (the extraction is a pure move), and an
|
||||||
|
// engine other than Cesium implements them as setters:
|
||||||
|
//
|
||||||
|
// SpriteHandle (fleet.add) — one chevron: position (Cartesian3), show,
|
||||||
|
// rotation (screen angle, rad), image (a chevron data URL of
|
||||||
|
// ../icons.js), scale, color ({red, green, blue, alpha} with
|
||||||
|
// withAlpha(); reads return the engine's copy), width/height (CSS px;
|
||||||
|
// undefined until drawn on Cesium), id (the record). Data/focus keys
|
||||||
|
// its emphasis on the handle object.
|
||||||
|
// Trail (trails.create): setPositions(Cartesian3[]), clear(), destroy().
|
||||||
|
//
|
||||||
|
// The view: `camera` camera-shaped (positionWC, positionCartographic,
|
||||||
|
// rightWC, upWC, heading, pitch, roll, pickEllipsoid(screen)) and `scene`
|
||||||
|
// scene-shaped ({ camera, canvas, globe? }) for the shared helpers that take
|
||||||
|
// them (iconOrientation, the view-area request); on Navara they are adapters
|
||||||
|
// over engine.camera, with the engine registered for the scene
|
||||||
|
// (engineFromViewer) so projections go through its projector.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} VesselView
|
||||||
|
* @property {object} camera
|
||||||
|
* @property {object} scene
|
||||||
|
* @property {() => ({ isPointVisible(position: object): boolean } | null)} horizon
|
||||||
|
* For this pass: the ellipsoid horizon at the camera.
|
||||||
|
* @property {(position: object, result?: object) => ({x: number, y: number} | undefined)} worldToWindow
|
||||||
|
* @property {() => { width: number, height: number }} canvasSize CSS px.
|
||||||
|
* @property {() => void} requestRender
|
||||||
|
* @property {(event: 'preRender'|'moveEnd', listener: Function) => (() => void) | null} on
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} VesselRenderer
|
||||||
|
* @property {'cesium'|'navara'} kind
|
||||||
|
* @property {object|null} engine
|
||||||
|
* @property {VesselView} view
|
||||||
|
* @property {{ collection: object|null, attached(): boolean,
|
||||||
|
* attach(options: { show: boolean }): void, setVisible(show: boolean): void,
|
||||||
|
* add(spec: { position: object, show: boolean, image: string, scale: number,
|
||||||
|
* rotation: number, id: object }): object,
|
||||||
|
* remove(handle: object): void, detach(): void }} fleet
|
||||||
|
* @property {(picked: object) => object|null} pickedRecord The vessel record
|
||||||
|
* a pick is of (keyed or not), or null.
|
||||||
|
* @property {{ onChanged(listener: Function): () => void, active(): boolean }} follow
|
||||||
|
* The engine's follow camera (vessels never follow: a follow belongs to
|
||||||
|
* another layer).
|
||||||
|
* @property {{ create(options: { color: string, width: number }): object|null }} trails
|
||||||
|
* @property {() => void} destroy
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The members every vessel renderer has (both engines). */
|
||||||
|
export const VESSEL_RENDERER_SHAPE = Object.freeze({
|
||||||
|
top: ['kind', 'view', 'fleet', 'pickedRecord', 'follow', 'trails', 'destroy'],
|
||||||
|
view: [
|
||||||
|
'camera',
|
||||||
|
'scene',
|
||||||
|
'horizon',
|
||||||
|
'worldToWindow',
|
||||||
|
'canvasSize',
|
||||||
|
'requestRender',
|
||||||
|
'on',
|
||||||
|
],
|
||||||
|
fleet: [
|
||||||
|
'collection',
|
||||||
|
'attached',
|
||||||
|
'attach',
|
||||||
|
'setVisible',
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'detach',
|
||||||
|
],
|
||||||
|
follow: ['onChanged', 'active'],
|
||||||
|
trails: ['create'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The members of VESSEL_RENDERER_SHAPE a renderer lacks ([] when whole). */
|
||||||
|
export function missingVesselRendererMembers(renderer) {
|
||||||
|
const missing = [];
|
||||||
|
for (const name of VESSEL_RENDERER_SHAPE.top)
|
||||||
|
if (!(name in (renderer ?? {}))) missing.push(name);
|
||||||
|
for (const part of ['view', 'fleet', 'follow', 'trails'])
|
||||||
|
for (const name of VESSEL_RENDERER_SHAPE[part])
|
||||||
|
if (!(name in (renderer?.[part] ?? {}))) missing.push(`${part}.${name}`);
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
|
import * as selection from '../../data/selectionDispatcher.js';
|
||||||
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
|
import { createVesselLayer } from './index.js';
|
||||||
|
import { createVesselsCesiumRenderer } from './render/cesium.js';
|
||||||
|
import { missingVesselRendererMembers } from './render/contract.js';
|
||||||
|
import { allVesselChevronIcons, vesselChevronIcon } from './icons.js';
|
||||||
|
import {
|
||||||
|
VESSEL_TYPE_CSS_COLORS,
|
||||||
|
vesselTypeCss,
|
||||||
|
} from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
/** A renderer of plain records over the engine: what the logic asks for. */
|
||||||
|
function recordingRenderer(engine) {
|
||||||
|
const sprites = new Set();
|
||||||
|
const followListeners = new Set();
|
||||||
|
let following = false;
|
||||||
|
let attached = false;
|
||||||
|
return {
|
||||||
|
sprites,
|
||||||
|
raiseFollow(active) {
|
||||||
|
following = active;
|
||||||
|
for (const listener of followListeners) listener();
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
kind: 'plain',
|
||||||
|
engine,
|
||||||
|
view: {
|
||||||
|
camera: {
|
||||||
|
positionWC: { x: 0, y: 0, z: 20_000_000 },
|
||||||
|
positionCartographic: { latitude: 0, longitude: 0, height: 1e7 },
|
||||||
|
rightWC: { x: 1, y: 0, z: 0 },
|
||||||
|
upWC: { x: 0, y: 1, z: 0 },
|
||||||
|
heading: 0,
|
||||||
|
pitch: 0,
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
scene: { canvas: { clientWidth: 0, clientHeight: 0 } },
|
||||||
|
horizon: () => ({ isPointVisible: () => true }),
|
||||||
|
worldToWindow: () => undefined,
|
||||||
|
canvasSize: () => ({ width: 1280, height: 800 }),
|
||||||
|
requestRender: noop,
|
||||||
|
on: () => noop,
|
||||||
|
},
|
||||||
|
fleet: {
|
||||||
|
collection: null,
|
||||||
|
attached: () => attached,
|
||||||
|
attach() {
|
||||||
|
attached = true;
|
||||||
|
},
|
||||||
|
setVisible: noop,
|
||||||
|
add(spec) {
|
||||||
|
const sprite = { ...spec };
|
||||||
|
sprites.add(sprite);
|
||||||
|
return sprite;
|
||||||
|
},
|
||||||
|
remove: (sprite) => sprites.delete(sprite),
|
||||||
|
detach() {
|
||||||
|
attached = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pickedRecord: (picked) =>
|
||||||
|
picked?.id && typeof picked.id === 'object' ? picked.id : null,
|
||||||
|
follow: {
|
||||||
|
onChanged(listener) {
|
||||||
|
followListeners.add(listener);
|
||||||
|
return () => followListeners.delete(listener);
|
||||||
|
},
|
||||||
|
active: () => following,
|
||||||
|
},
|
||||||
|
trails: { create: () => null },
|
||||||
|
destroy: noop,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function services(renderer) {
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
clearSelectedEntityContextForLayer: noop,
|
||||||
|
registerEntityContext: () => null,
|
||||||
|
selectEntityContext: noop,
|
||||||
|
},
|
||||||
|
trails: { createTrail: null },
|
||||||
|
labels: {},
|
||||||
|
picking,
|
||||||
|
overlay: {
|
||||||
|
setOverlayEntries: noop,
|
||||||
|
setOverlaySourceVisible: noop,
|
||||||
|
clearOverlaySource: noop,
|
||||||
|
hitTestWorldOverlay: () => null,
|
||||||
|
},
|
||||||
|
geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 },
|
||||||
|
sprites: {
|
||||||
|
registerSpriteCollection: noop,
|
||||||
|
restoreSpriteOrder: noop,
|
||||||
|
restoreSpriteOrderOnEnable: noop,
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
focusNowMs: (now) => now,
|
||||||
|
getFocusTarget: () => null,
|
||||||
|
focusPassIsNeeded: () => false,
|
||||||
|
forgetSpriteFocus: noop,
|
||||||
|
},
|
||||||
|
worldFocus: { requestWorldFocus: noop },
|
||||||
|
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||||
|
selection,
|
||||||
|
renderer: { create: () => renderer },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (mmsi, latitude, longitude) => ({
|
||||||
|
id: mmsi,
|
||||||
|
reference: mmsi,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
name: `SHIP ${mmsi}`,
|
||||||
|
type: 'Cargo',
|
||||||
|
observedAtMs: 500,
|
||||||
|
speedMps: 2,
|
||||||
|
headingDeg: 90,
|
||||||
|
courseDeg: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the vessels Cesium renderer has every member of the contract', () => {
|
||||||
|
const renderer = createVesselsCesiumRenderer(null, {
|
||||||
|
viewer: { scene: {}, camera: {} },
|
||||||
|
});
|
||||||
|
assert.deepEqual(missingVesselRendererMembers(renderer), []);
|
||||||
|
assert.equal(renderer.kind, 'cesium');
|
||||||
|
// The record a pick is of: Cesium's billboard id is the record.
|
||||||
|
const record = { mmsi: '111' };
|
||||||
|
assert.equal(renderer.pickedRecord({ id: record }), record);
|
||||||
|
assert.equal(renderer.pickedRecord({ primitive: { id: record } }), record);
|
||||||
|
assert.equal(renderer.pickedRecord({ id: 'ae01ce' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the chevron artwork: one icon per type colour plus the selected one', () => {
|
||||||
|
const icons = allVesselChevronIcons();
|
||||||
|
assert.equal(icons.length, VESSEL_TYPE_CSS_COLORS.length + 1);
|
||||||
|
assert.equal(new Set(icons).size, icons.length);
|
||||||
|
assert.ok(icons.includes(vesselChevronIcon(vesselTypeCss('Tanker'), false)));
|
||||||
|
assert.ok(icons.includes(vesselChevronIcon('#ffffff', true)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vessel logic draws and selects through a plain renderer on an engine, with no Cesium', async () => {
|
||||||
|
const engine = createFakeEngine();
|
||||||
|
const recording = recordingRenderer(engine);
|
||||||
|
const layer = createVesselLayer({
|
||||||
|
source: {
|
||||||
|
label: 'Fixture vessels',
|
||||||
|
getSnapshot: async () => ({
|
||||||
|
records: [row('111', 40.6, -73.9), row('222', 40.7, -74.0)],
|
||||||
|
source: 'Fixture vessels',
|
||||||
|
complete: true,
|
||||||
|
stale: false,
|
||||||
|
freshness: 'current',
|
||||||
|
observedAtMs: 500,
|
||||||
|
transportStatus: 'live',
|
||||||
|
rawRowCount: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
services: services(recording.renderer),
|
||||||
|
});
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
getElementById: () => null,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
layer.init(null, { engine });
|
||||||
|
await layer.enable(null, { engine });
|
||||||
|
assert.equal(recording.sprites.size, 2);
|
||||||
|
const sprite = [...recording.sprites].find((s) => s.id.mmsi === '111');
|
||||||
|
assert.equal(sprite.scale, 0.6);
|
||||||
|
assert.equal(sprite.rotation, 0);
|
||||||
|
assert.equal(
|
||||||
|
sprite.image,
|
||||||
|
vesselChevronIcon(vesselTypeCss('Cargo'), false),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A click on its sprite selects it (the engine's dispatcher, own rule).
|
||||||
|
engine.fake.script('pick', () => ({ id: sprite.id }));
|
||||||
|
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||||
|
assert.equal(layer.getSelectedInfo()?.mmsi, '111');
|
||||||
|
assert.equal(sprite.scale, 0.6 * 1.2);
|
||||||
|
assert.equal(sprite.image, vesselChevronIcon('#ffffff', true));
|
||||||
|
|
||||||
|
// Another layer takes the camera: the selection goes.
|
||||||
|
recording.raiseFollow(true);
|
||||||
|
assert.equal(layer.getSelectedInfo(), null);
|
||||||
|
recording.raiseFollow(false);
|
||||||
|
|
||||||
|
// Select again, then a click on empty map releases it.
|
||||||
|
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||||
|
assert.equal(layer.getSelectedInfo()?.mmsi, '111');
|
||||||
|
engine.fake.script('pick', () => undefined);
|
||||||
|
engine.fake.input('click', { position: { x: 500, y: 10 } });
|
||||||
|
assert.equal(layer.getSelectedInfo(), null);
|
||||||
|
|
||||||
|
layer.disable(null, { engine });
|
||||||
|
layer.destroy(null, { engine });
|
||||||
|
assert.equal(recording.renderer.fleet.attached(), false);
|
||||||
|
} finally {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { WHITE } from '../shared/color.js';
|
||||||
|
import { vesselChevronIcon, VESSEL_SELECTED_CSS } from './icons.js';
|
||||||
import {
|
import {
|
||||||
vesselTypeCss,
|
vesselTypeCss,
|
||||||
vesselOverlayCohortLimit,
|
vesselOverlayCohortLimit,
|
||||||
@@ -45,19 +47,19 @@ export function createRendering({
|
|||||||
components.tracking.currentGeoidN(record.lat, record.lon),
|
components.tracking.currentGeoidN(record.lat, record.lon),
|
||||||
VESSEL_LIFT_M,
|
VESSEL_LIFT_M,
|
||||||
);
|
);
|
||||||
visual.position = Cesium.Cartesian3.fromDegrees(
|
visual.position = geo.cartesian3.fromDegrees(
|
||||||
record.lon,
|
record.lon,
|
||||||
record.lat,
|
record.lat,
|
||||||
heightM,
|
heightM,
|
||||||
);
|
);
|
||||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(
|
visual.surfacePosition = geo.cartesian3.fromDegrees(
|
||||||
record.lon,
|
record.lon,
|
||||||
record.lat,
|
record.lat,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
visual.normal = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||||
visual.position,
|
visual.position,
|
||||||
new Cesium.Cartesian3(),
|
geo.cartesian3.create(),
|
||||||
);
|
);
|
||||||
return visual;
|
return visual;
|
||||||
}
|
}
|
||||||
@@ -92,14 +94,13 @@ export function createRendering({
|
|||||||
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
|
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCollections(viewer) {
|
// The renderer's sprite collection, once a scene is there (the argument:
|
||||||
if (!viewer || state.billboardCollection) return;
|
// the scene the caller holds — the viewer, or the renderer's view).
|
||||||
state.billboardCollection = new Cesium.BillboardCollection({
|
function ensureCollections(scene) {
|
||||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
const fleet = state.renderer?.fleet;
|
||||||
});
|
if (!scene || !fleet || fleet.attached()) return;
|
||||||
state.billboardCollection.show = state.feed.enabled;
|
fleet.attach({ show: state.feed.enabled });
|
||||||
viewer.scene.primitives.add(state.billboardCollection);
|
if (fleet.collection) registerSpriteCollection('ais', fleet.collection);
|
||||||
registerSpriteCollection('ais', state.billboardCollection);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,28 +108,21 @@ export function createRendering({
|
|||||||
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
||||||
* per-record label primitive exists anymore.
|
* per-record label primitive exists anymore.
|
||||||
* @param {Object} record - Normalized vessel record.
|
* @param {Object} record - Normalized vessel record.
|
||||||
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
|
* @param {{isPointVisible: Function}|null} occluder - Horizon occluder for initial visibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function addRecordPrimitives(record, occluder) {
|
function addRecordPrimitives(record, occluder) {
|
||||||
const visible =
|
const visible =
|
||||||
state.feed.enabled &&
|
state.feed.enabled &&
|
||||||
isVisible(getVisual(record).surfacePosition, occluder);
|
isVisible(getVisual(record).surfacePosition, occluder);
|
||||||
getVisual(record).billboard = state.billboardCollection.add({
|
// Depth-test-free, centred, screen-aligned (the renderer's sprite).
|
||||||
|
getVisual(record).billboard = state.renderer.fleet.add({
|
||||||
position: getVisual(record).position,
|
position: getVisual(record).position,
|
||||||
show: visible,
|
show: visible,
|
||||||
image: shipIcon(record, false),
|
image: shipIcon(record, false),
|
||||||
scale: shipScale(record),
|
scale: shipScale(record),
|
||||||
// Screen-projected rotation lands on the next visibility/rotation pass.
|
// Screen-projected rotation lands on the next visibility/rotation pass.
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
|
||||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
|
||||||
// Locked height-datum principle #2: contacts are ALWAYS visible —
|
|
||||||
// depth-test-free sprites; the EllipsoidalOccluder handles the far side.
|
|
||||||
// (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
|
|
||||||
// the geoid still clips out behind local tide/mesh noise.)
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
id: record,
|
id: record,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -140,9 +134,9 @@ export function createRendering({
|
|||||||
|
|
||||||
function removeRecordPrimitives(record) {
|
function removeRecordPrimitives(record) {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (getVisual(record).billboard && state.billboardCollection) {
|
if (getVisual(record).billboard && state.renderer?.fleet.attached()) {
|
||||||
forgetSpriteFocus(getVisual(record).billboard);
|
forgetSpriteFocus(getVisual(record).billboard);
|
||||||
state.billboardCollection.remove(getVisual(record).billboard);
|
state.renderer.fleet.remove(getVisual(record).billboard);
|
||||||
}
|
}
|
||||||
getVisual(record).billboard = null;
|
getVisual(record).billboard = null;
|
||||||
}
|
}
|
||||||
@@ -178,26 +172,21 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function shipIcon(record, selected) {
|
function shipIcon(record, selected) {
|
||||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
const cssColor = selected
|
||||||
|
? VESSEL_SELECTED_CSS
|
||||||
|
: vesselTypeCss(record.type);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
||||||
if (vesselState.shipIconCache.has(key))
|
if (vesselState.shipIconCache.has(key))
|
||||||
return vesselState.shipIconCache.get(key);
|
return vesselState.shipIconCache.get(key);
|
||||||
|
|
||||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
const icon = vesselChevronIcon(cssColor, selected);
|
||||||
const strokeWidth = selected ? 1.1 : 0.7;
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
|
||||||
vesselState.shipIconCache.set(key, icon);
|
vesselState.shipIconCache.set(key, icon);
|
||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installRuntime(viewer) {
|
function installRuntime(viewer) {
|
||||||
if (state.preRenderRemover || !viewer) return;
|
if (state.preRenderRemover || !viewer || !state.renderer) return;
|
||||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
state.preRenderRemover = state.renderer.view.on('preRender', () =>
|
||||||
updateVisibility(),
|
updateVisibility(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,8 +210,9 @@ export function createRendering({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scene = state.viewer?.scene;
|
const view = state.renderer?.view;
|
||||||
const camera = state.viewer?.camera;
|
const scene = view?.scene;
|
||||||
|
const camera = view?.camera;
|
||||||
if (regularPass) {
|
if (regularPass) {
|
||||||
// Candidate construction stays on the original 800 ms selector cadence.
|
// Candidate construction stays on the original 800 ms selector cadence.
|
||||||
// The 80 ms focus-only pass below never allocates label candidates.
|
// The 80 ms focus-only pass below never allocates label candidates.
|
||||||
@@ -262,13 +252,9 @@ export function createRendering({
|
|||||||
previousActiveCount: state.activeFocusCount,
|
previousActiveCount: state.activeFocusCount,
|
||||||
nowMs: now,
|
nowMs: now,
|
||||||
screenPositionFor: (position) =>
|
screenPositionFor: (position) =>
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
view.worldToWindow(position, vesselState._scratchFocusScreen),
|
||||||
scene,
|
|
||||||
position,
|
|
||||||
vesselState._scratchFocusScreen,
|
|
||||||
),
|
|
||||||
cameraDistanceFor: (position) =>
|
cameraDistanceFor: (position) =>
|
||||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
geo.cartesian3.distance(camera.positionWC, position),
|
||||||
});
|
});
|
||||||
state.activeFocusCount = result.activeCount;
|
state.activeFocusCount = result.activeCount;
|
||||||
}
|
}
|
||||||
@@ -321,7 +307,7 @@ export function createRendering({
|
|||||||
// the non-zero floor while it competes with the tracked target. Preserve
|
// the non-zero floor while it competes with the tracked target. Preserve
|
||||||
// the billboard's existing base RGB, matching the other layer patterns,
|
// the billboard's existing base RGB, matching the other layer patterns,
|
||||||
// rather than repainting every chevron from a hard-coded WHITE base.
|
// rather than repainting every chevron from a hard-coded WHITE base.
|
||||||
const baseColor = bb.color || Cesium.Color.WHITE;
|
const baseColor = bb.color || WHITE;
|
||||||
bb.color = baseColor.withAlpha(focus.factor);
|
bb.color = baseColor.withAlpha(focus.factor);
|
||||||
writes += 1;
|
writes += 1;
|
||||||
}
|
}
|
||||||
@@ -330,12 +316,7 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeOccluder() {
|
function makeOccluder() {
|
||||||
const cameraPosition = state.viewer?.camera?.positionWC;
|
return state.renderer?.view.horizon() ?? null;
|
||||||
if (!cameraPosition) return null;
|
|
||||||
return new Cesium.EllipsoidalOccluder(
|
|
||||||
Cesium.Ellipsoid.WGS84,
|
|
||||||
cameraPosition,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisible(surfacePosition, occluder) {
|
function isVisible(surfacePosition, occluder) {
|
||||||
@@ -355,8 +336,8 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function updateClusteredLabels(records) {
|
function updateClusteredLabels(records) {
|
||||||
const viewer = state.viewer;
|
const view = state.renderer?.view;
|
||||||
const scene = viewer?.scene;
|
const scene = view?.scene;
|
||||||
const selected = state.selectedRecord;
|
const selected = state.selectedRecord;
|
||||||
const entries = selected
|
const entries = selected
|
||||||
? [components.cards.buildSelectedVesselCard(selected)]
|
? [components.cards.buildSelectedVesselCard(selected)]
|
||||||
@@ -373,10 +354,7 @@ export function createRendering({
|
|||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const visual = getVisual(record);
|
const visual = getVisual(record);
|
||||||
if (record === selected) continue;
|
if (record === selected) continue;
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
const screen = view.worldToWindow(visual.position);
|
||||||
scene,
|
|
||||||
visual.position,
|
|
||||||
);
|
|
||||||
if (!screen) continue;
|
if (!screen) continue;
|
||||||
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
||||||
const candidate = {
|
const candidate = {
|
||||||
@@ -395,8 +373,7 @@ export function createRendering({
|
|||||||
// selected card's anchor seeds the accepted set so ambient cards keep clear.
|
// selected card's anchor seeds the accepted set so ambient cards keep clear.
|
||||||
const accepted = [];
|
const accepted = [];
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
const screen = view.worldToWindow(
|
||||||
scene,
|
|
||||||
getVisual(selected).billboard?.position || getVisual(selected).position,
|
getVisual(selected).billboard?.position || getVisual(selected).position,
|
||||||
);
|
);
|
||||||
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
||||||
@@ -427,9 +404,10 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function publishVesselOverlayEntries(entries) {
|
function publishVesselOverlayEntries(entries) {
|
||||||
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
|
const { width, height } = state.renderer?.view.canvasSize() ?? {
|
||||||
const width = Number(canvas?.clientWidth) || 0;
|
width: 0,
|
||||||
const height = Number(canvas?.clientHeight) || 0;
|
height: 0,
|
||||||
|
};
|
||||||
const ambientLimit = vesselOverlayCohortLimit(
|
const ambientLimit = vesselOverlayCohortLimit(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -487,9 +465,7 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setVisible(show) {
|
function setVisible(show) {
|
||||||
if (state.billboardCollection) {
|
state.renderer?.fleet.setVisible(show);
|
||||||
state.billboardCollection.show = show;
|
|
||||||
}
|
|
||||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
|
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
@@ -19,30 +18,77 @@ export function createSelection({
|
|||||||
} = services.context;
|
} = services.context;
|
||||||
const aisLiveVesselsLayer = layer;
|
const aisLiveVesselsLayer = layer;
|
||||||
|
|
||||||
function installInteraction(viewer) {
|
/**
|
||||||
if (state.clickHandler || !viewer) return;
|
* Join the engine's selection dispatcher with this layer's own click rule
|
||||||
const handler = state.interactionHandlerFactory
|
* (`onClick`: every click with its top pick, no gesture rule, as its old
|
||||||
? state.interactionHandlerFactory(viewer)
|
* LEFT_CLICK handler), plus Escape and the "another layer took the camera"
|
||||||
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
* signal. The test seam (interactionHandlerFactory) gives a handler-like
|
||||||
|
* click source over a fake viewer's scene.pick instead.
|
||||||
|
* @param {object|null} scene The viewer, or the renderer's view.
|
||||||
|
*/
|
||||||
|
function installInteraction(scene) {
|
||||||
|
if (state.clickHandler || state.selectionRemove || !scene) return;
|
||||||
|
const keyTarget = state.interactionKeyTarget || document;
|
||||||
|
if (state.interactionHandlerFactory) {
|
||||||
bindVesselInteraction(
|
bindVesselInteraction(
|
||||||
viewer,
|
scene,
|
||||||
handler,
|
state.interactionHandlerFactory(scene),
|
||||||
state.interactionKeyTarget || document,
|
keyTarget,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renderer = state.renderer;
|
||||||
|
const dispatcher = services.selection?.selectionDispatcherFor?.(
|
||||||
|
renderer?.engine,
|
||||||
|
);
|
||||||
|
if (!dispatcher) return;
|
||||||
|
state.selectionRemove = dispatcher.register('ais-live-vessels', {
|
||||||
|
// Owned while enabled (the lifecycle registers the same predicate on
|
||||||
|
// enable and drops it on disable).
|
||||||
|
owns: (id) => state.feed.enabled && state.records.byMmsi.has(id),
|
||||||
|
onClick: (ctx) => handleClick(ctx),
|
||||||
|
});
|
||||||
|
bindKeysAndFollow(renderer, keyTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: a handler-like click source over `viewer.scene.pick`. */
|
||||||
|
function bindVesselInteraction(viewer, handler, keyTarget) {
|
||||||
|
state.clickHandler = handler;
|
||||||
|
handler.setInputAction((click) =>
|
||||||
|
handleClick({
|
||||||
|
position: click.position,
|
||||||
|
picked: viewer.scene.pick(click.position),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The follow signal of the Cesium renderer over that viewer.
|
||||||
|
bindKeysAndFollow(
|
||||||
|
services.renderer?.create({ viewer, engine: null }),
|
||||||
|
keyTarget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindVesselInteraction(viewer, handler, keyTarget) {
|
function bindKeysAndFollow(renderer, keyTarget) {
|
||||||
state.clickHandler = handler;
|
state.keyTarget = keyTarget;
|
||||||
handler.setInputAction((click) => {
|
state.keydownHandler = onVesselKeyDown;
|
||||||
|
keyTarget.addEventListener('keydown', state.keydownHandler);
|
||||||
|
// Vessels never follow, so any new follow belongs to another layer and
|
||||||
|
// takes interaction ownership of the scene.
|
||||||
|
const follow = renderer?.follow;
|
||||||
|
state.trackedEntityRemover =
|
||||||
|
follow?.onChanged(() => {
|
||||||
|
if (follow.active() && state.selectedRecord) clearVesselInspection();
|
||||||
|
}) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vessel click rule (the old LEFT_CLICK handler's, as is). */
|
||||||
|
function handleClick(click) {
|
||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||||
if (!isPointerFree()) return;
|
if (!isPointerFree()) return;
|
||||||
if (!state.feed.enabled) return;
|
if (!state.feed.enabled) return;
|
||||||
const picked = viewer.scene.pick(click.position);
|
const picked = click.picked;
|
||||||
const pickedId = resolvePickId(picked);
|
const pickedId = resolvePickId(picked);
|
||||||
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
||||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
const ownRecordPick = Boolean(pickedRecordOf(picked));
|
||||||
const ownRecordPick =
|
|
||||||
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
|
||||||
|
|
||||||
// An own-layer record without a live map key is a strict no-op (FB-1
|
// An own-layer record without a live map key is a strict no-op (FB-1
|
||||||
// residual). Trails carry no layer identity and hug their contacts, so any
|
// residual). Trails carry no layer identity and hug their contacts, so any
|
||||||
@@ -101,18 +147,11 @@ export function createSelection({
|
|||||||
});
|
});
|
||||||
if (transition.action === 'deselect') clearVesselInspection();
|
if (transition.action === 'deselect') clearVesselInspection();
|
||||||
}
|
}
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
}
|
||||||
state.keyTarget = keyTarget;
|
|
||||||
state.keydownHandler = onVesselKeyDown;
|
/** The vessel record a pick is of (keyed or not), through the renderer. */
|
||||||
keyTarget.addEventListener('keydown', state.keydownHandler);
|
function pickedRecordOf(picked) {
|
||||||
// Vessels never set viewer.trackedEntity, so any new tracked entity belongs
|
return state.renderer?.pickedRecord(picked) ?? null;
|
||||||
// to another layer and takes interaction ownership of the scene.
|
|
||||||
state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(
|
|
||||||
() => {
|
|
||||||
if (viewer.trackedEntity && state.selectedRecord)
|
|
||||||
clearVesselInspection();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select one live vessel and request one UI-owned camera transfer. */
|
/** Select one live vessel and request one UI-owned camera transfer. */
|
||||||
@@ -141,6 +180,8 @@ export function createSelection({
|
|||||||
state.clickHandler.destroy();
|
state.clickHandler.destroy();
|
||||||
state.clickHandler = null;
|
state.clickHandler = null;
|
||||||
}
|
}
|
||||||
|
state.selectionRemove?.();
|
||||||
|
state.selectionRemove = null;
|
||||||
if (state.keyTarget && state.keydownHandler) {
|
if (state.keyTarget && state.keydownHandler) {
|
||||||
state.keyTarget.removeEventListener('keydown', state.keydownHandler);
|
state.keyTarget.removeEventListener('keydown', state.keydownHandler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,13 +51,17 @@ export function createVesselState({ source, services }) {
|
|||||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||||
viewer: null,
|
viewer: null,
|
||||||
|
|
||||||
billboardCollection: null,
|
/** The engine's vessel renderer (render/*.js), per lifecycle. */
|
||||||
|
renderer: null,
|
||||||
|
/** Test-only: the handler-like click source (interactionHandlerFactory). */
|
||||||
clickHandler: null,
|
clickHandler: null,
|
||||||
|
/** Leaves the engine's selection dispatcher. */
|
||||||
|
selectionRemove: null,
|
||||||
/** Exact EventTarget currently holding the Escape listener. */
|
/** Exact EventTarget currently holding the Escape listener. */
|
||||||
keyTarget: null,
|
keyTarget: null,
|
||||||
/** Exact callback registered on keyTarget. */
|
/** Exact callback registered on keyTarget. */
|
||||||
keydownHandler: null,
|
keydownHandler: null,
|
||||||
/** Cesium trackedEntityChanged listener disposer. */
|
/** The renderer's follow-changed listener disposer. */
|
||||||
trackedEntityRemover: null,
|
trackedEntityRemover: null,
|
||||||
/** Test-only factory used to exercise enable-time interaction installation. */
|
/** Test-only factory used to exercise enable-time interaction installation. */
|
||||||
interactionHandlerFactory: null,
|
interactionHandlerFactory: null,
|
||||||
@@ -72,7 +76,7 @@ export function createVesselState({ source, services }) {
|
|||||||
selectedRecord: null,
|
selectedRecord: null,
|
||||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||||
trail: null,
|
trail: null,
|
||||||
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
|
/** @type {object[]} Chronological trail vertices, Cartesian3 (oldest first) */
|
||||||
trailPositions: [],
|
trailPositions: [],
|
||||||
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
|
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
|
||||||
trailMmsi: null,
|
trailMmsi: null,
|
||||||
|
|||||||
@@ -48,7 +48,16 @@ export function createTesting({
|
|||||||
.map((record) => [record.mmsi, record]),
|
.map((record) => [record.mmsi, record]),
|
||||||
);
|
);
|
||||||
state.selectedRecord = options.selectedRecord || null;
|
state.selectedRecord = options.selectedRecord || null;
|
||||||
state.billboardCollection = options.billboardCollection || { remove() {} };
|
// The Cesium renderer over the test viewer, adopting the test collection.
|
||||||
|
state.renderer =
|
||||||
|
services.renderer?.create({
|
||||||
|
viewer: state.viewer,
|
||||||
|
engine: null,
|
||||||
|
createTrail: services.trails?.createTrail ?? null,
|
||||||
|
adopt: {
|
||||||
|
billboardCollection: options.billboardCollection || { remove() {} },
|
||||||
|
},
|
||||||
|
}) ?? null;
|
||||||
state.trail = options.trail || null;
|
state.trail = options.trail || null;
|
||||||
state.trailMmsi = options.trailMmsi || null;
|
state.trailMmsi = options.trailMmsi || null;
|
||||||
state.trailPositions = Array.isArray(options.trailPositions)
|
state.trailPositions = Array.isArray(options.trailPositions)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function createTracking({
|
|||||||
}) {
|
}) {
|
||||||
const { state } = vesselState;
|
const { state } = vesselState;
|
||||||
const { geoidHeight } = services.geoid;
|
const { geoidHeight } = services.geoid;
|
||||||
const { createTrail } = services.trails;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
|
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
|
||||||
@@ -34,7 +33,7 @@ export function createTracking({
|
|||||||
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
|
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
|
||||||
* to avoid z-fighting.
|
* to avoid z-fighting.
|
||||||
* @param {Object} record - Vessel record with lat/lon.
|
* @param {Object} record - Vessel record with lat/lon.
|
||||||
* @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
|
* @returns {object|null} Lifted position (Cartesian3), or null without a fix.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselTrailPosition(record) {
|
function vesselTrailPosition(record) {
|
||||||
@@ -89,8 +88,8 @@ export function createTracking({
|
|||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
const current = vesselTrailPosition(record);
|
const current = vesselTrailPosition(record);
|
||||||
if (current) state.trailPositions.push(current);
|
if (current) state.trailPositions.push(current);
|
||||||
if (!state.trail && state.viewer) {
|
if (!state.trail && state.renderer) {
|
||||||
state.trail = createTrail(state.viewer, {
|
state.trail = state.renderer.trails.create({
|
||||||
color: TRAIL_COLOR,
|
color: TRAIL_COLOR,
|
||||||
width: 2.5,
|
width: 2.5,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ test('layers moved onto renderers keep each engine in its render file', (t) => {
|
|||||||
"export const load = () => import('../layers/flights/render/navara.js');",
|
"export const load = () => import('../layers/flights/render/navara.js');",
|
||||||
);
|
);
|
||||||
// A layer not moved yet still imports cesium anywhere (until its *a task).
|
// A layer not moved yet still imports cesium anywhere (until its *a task).
|
||||||
write('src/layers/vessels/rendering.js', "import 'cesium';");
|
write('src/layers/satellites/rendering.js', "import 'cesium';");
|
||||||
assert.doesNotThrow(() => checkImportDirections(root));
|
assert.doesNotThrow(() => checkImportDirections(root));
|
||||||
|
|
||||||
write('src/layers/flights/tracking.js', "import 'cesium';");
|
write('src/layers/flights/tracking.js', "import 'cesium';");
|
||||||
|
|||||||
Reference in new issue
Block a user