mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
feat(vessels): render on Navara (WIP: browser parity not yet run)
render/navara.js: one always-on-top engine.movers chevron bank (atlas of every chevron icon at 32 px), MMSI pick ids, trail on engine.lines, camera reads through the shared Navara layer view (extracted from the aircraft renderer to src/layers/shared/render/navaraView.js, + camera pickEllipsoid and canvas clientWidth). Registered in navaraMode. QA: vessel qaDrawn; probe selected() falls back to getSelectedInfo; qa-w1-layers vessel section; qa-vessels-engines side by side; --engine for qa-vessel-cards/datum; qa-flights-engines military near pose 100 km. Unit tests on the fake engine pass; browser runs NOT done (see progress).
This commit is contained in:
1 parent
620ada6df3
commit
69a78710cc
14 files changed
+850
-154
No files matched your search
@@ -10,20 +10,20 @@ import {
|
||||
rendererFor,
|
||||
} from './layers/renderers.js';
|
||||
|
||||
test('the Navara mode registers the aircraft layers’ renderers, once', async () => {
|
||||
test('the Navara mode registers the aircraft layers’ and vessels’ renderers, once', async () => {
|
||||
assert.equal(layerAvailableOn('military', 'navara'), false);
|
||||
await Promise.all([
|
||||
registerNavaraLayerRenderers(),
|
||||
registerNavaraLayerRenderers(),
|
||||
]);
|
||||
await registerNavaraLayerRenderers();
|
||||
for (const id of ['flights', 'military', 'local-adsb'])
|
||||
for (const id of ['flights', 'military', 'local-adsb', 'ais-live-vessels'])
|
||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||
assert.deepEqual(
|
||||
createLayerAvailability('navara')
|
||||
.available()
|
||||
.map(({ id }) => id),
|
||||
['flights', 'military', 'local-adsb'],
|
||||
['flights', 'military', 'local-adsb', 'ais-live-vessels'],
|
||||
);
|
||||
assert.equal(rendererFor('satellites', { kind: 'navara' }), null);
|
||||
});
|
||||
@@ -64,6 +64,9 @@ export async function registerNavaraLayerRenderers() {
|
||||
'local-adsb': async () =>
|
||||
(await import('../layers/localAdsb/render/navara.js'))
|
||||
.createLocalAdsbNavaraRenderer,
|
||||
'ais-live-vessels': async () =>
|
||||
(await import('../layers/vessels/render/navara.js'))
|
||||
.createVesselsNavaraRenderer,
|
||||
};
|
||||
const missing = Object.keys(loaders).filter(
|
||||
(id) => !layerAvailableOn(id, 'navara'),
|
||||
|
||||
+6
-1
@@ -68,7 +68,12 @@ export function createQaProbe({ engine, dataManager, availability }) {
|
||||
);
|
||||
return row ? screenOf(row.position) : null;
|
||||
},
|
||||
selected: (layerId) => moduleOf(layerId)?.getTrackedInfo?.() ?? null,
|
||||
// The tracked object, or the selected one of a layer that selects
|
||||
// without tracking (vessels).
|
||||
selected: (layerId) => {
|
||||
const module = moduleOf(layerId);
|
||||
return module?.getTrackedInfo?.() ?? module?.getSelectedInfo?.() ?? null;
|
||||
},
|
||||
drawn: (layerId) => moduleOf(layerId)?.qaDrawn?.() ?? null,
|
||||
following() {
|
||||
const handle = engine.camera.following();
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
aircraftIconCells,
|
||||
drawAircraftIconAtlas,
|
||||
} from '../../../data/aircraftIcons.js';
|
||||
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
|
||||
import { createNavaraLayerView } from '../../shared/render/navaraView.js';
|
||||
|
||||
const BASE_SIZE_PX = 20;
|
||||
|
||||
@@ -252,130 +252,13 @@ export function createAircraftNavaraRenderer({
|
||||
};
|
||||
|
||||
// --- the view ------------------------------------------------------------
|
||||
let frameNumber = 0;
|
||||
removers.push(
|
||||
engine.frames.preUpdate.addEventListener(() => {
|
||||
frameNumber++;
|
||||
}),
|
||||
);
|
||||
const camera = {
|
||||
positionWC: geo.cartesian3.create(),
|
||||
positionCartographic: geo.cartographic.create(),
|
||||
directionWC: geo.cartesian3.create(),
|
||||
upWC: geo.cartesian3.create(),
|
||||
rightWC: geo.cartesian3.create(),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: { fovy: 1, aspectRatio: 1 },
|
||||
};
|
||||
let poseFrame = -1;
|
||||
const lastPosition = geo.cartesian3.create(Number.NaN, 0, 0);
|
||||
// The camera as of now: refreshed once per frame, and whenever it moved.
|
||||
const freshCamera = () => {
|
||||
const live = engine.camera.livePosition;
|
||||
if (
|
||||
poseFrame === frameNumber &&
|
||||
live.x === lastPosition.x &&
|
||||
live.y === lastPosition.y &&
|
||||
live.z === lastPosition.z
|
||||
)
|
||||
return camera;
|
||||
const pose = engine.camera.pose();
|
||||
geo.cartesian3.clone(pose.position, camera.positionWC);
|
||||
geo.cartographic.clone(pose.cartographic, camera.positionCartographic);
|
||||
geo.cartesian3.clone(pose.direction, camera.directionWC);
|
||||
geo.cartesian3.clone(pose.up, camera.upWC);
|
||||
geo.cartesian3.clone(pose.right, camera.rightWC);
|
||||
camera.heading = pose.heading;
|
||||
camera.pitch = pose.pitch;
|
||||
camera.roll = pose.roll;
|
||||
camera.frustum.fovy = pose.fovy;
|
||||
camera.frustum.aspectRatio = pose.aspectRatio ?? 1;
|
||||
geo.cartesian3.clone(live, lastPosition);
|
||||
poseFrame = frameNumber;
|
||||
return camera;
|
||||
};
|
||||
const cameraView = new Proxy(camera, {
|
||||
get: (target, key) => freshCamera()[key],
|
||||
const layerView = createNavaraLayerView(engine, {
|
||||
layerId,
|
||||
wrap: (run) => inFrameDo(run),
|
||||
isDestroyed: () => destroyed,
|
||||
});
|
||||
const scene = {
|
||||
get camera() {
|
||||
return cameraView;
|
||||
},
|
||||
canvas: {
|
||||
get clientHeight() {
|
||||
return engine.host.size().height;
|
||||
},
|
||||
},
|
||||
frameState: {
|
||||
get frameNumber() {
|
||||
return frameNumber;
|
||||
},
|
||||
},
|
||||
};
|
||||
// Shared helpers project through the engine registered for their scene.
|
||||
removers.push(registerViewerEngine(scene, engine));
|
||||
const horizon = {
|
||||
isPointVisible: (position) => engine.projector.horizonVisible(position),
|
||||
};
|
||||
const scratchDelta = geo.cartesian3.create();
|
||||
let frustum = null;
|
||||
const inFrustum = (position) => {
|
||||
const { origin, direction, right, up, tanX, tanY } = frustum;
|
||||
geo.cartesian3.subtract(position, origin, scratchDelta);
|
||||
const z = geo.cartesian3.dot(scratchDelta, direction);
|
||||
if (z <= -1) return false;
|
||||
const x = Math.abs(geo.cartesian3.dot(scratchDelta, right));
|
||||
const y = Math.abs(geo.cartesian3.dot(scratchDelta, up));
|
||||
return x <= z * tanX + 1 && y <= z * tanY + 1;
|
||||
};
|
||||
const view = {
|
||||
get camera() {
|
||||
return cameraView;
|
||||
},
|
||||
scene,
|
||||
surfaceOwner: engine,
|
||||
surfaceScene: engine,
|
||||
frameNumber: () => frameNumber,
|
||||
viewportHeightPx: () => engine.host.size().height,
|
||||
frustumTester() {
|
||||
const cam = freshCamera();
|
||||
const tanY = Math.tan(cam.frustum.fovy / 2);
|
||||
const { width, height } = engine.host.size();
|
||||
frustum = {
|
||||
origin: cam.positionWC,
|
||||
direction: cam.directionWC,
|
||||
right: cam.rightWC,
|
||||
up: cam.upWC,
|
||||
tanY,
|
||||
tanX: tanY * (height > 0 ? width / height : 1),
|
||||
};
|
||||
return inFrustum;
|
||||
},
|
||||
horizon: () => horizon,
|
||||
worldToWindow: (position, result) =>
|
||||
engine.projector.worldToWindow(position, result),
|
||||
requestRender: () => engine.render.request(layerId),
|
||||
on(event, listener) {
|
||||
const source =
|
||||
event === 'moveEnd'
|
||||
? engine.camera.moveEnd
|
||||
: event === 'preRender'
|
||||
? engine.frames.preRender
|
||||
: event === 'preUpdate'
|
||||
? engine.frames.preUpdate
|
||||
: null;
|
||||
if (!source) return null;
|
||||
return source.addEventListener((...args) =>
|
||||
inFrameDo(() => listener(...args)),
|
||||
);
|
||||
},
|
||||
isDestroyed: () => destroyed || engine.isDestroyed?.() === true,
|
||||
holdFrames() {},
|
||||
closeFrameLatch() {},
|
||||
releaseFrames() {},
|
||||
};
|
||||
removers.push(layerView.destroy);
|
||||
const { view, freshCamera } = layerView;
|
||||
|
||||
// --- fleet sprites ---------------------------------------------------------
|
||||
let atlas = null;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// The view a layer renderer on the Navara engine gives its logic (shared by
|
||||
// the aircraft and vessel renderers): `camera` camera-shaped and `scene`
|
||||
// scene-shaped adapters over engine.camera.pose() (refreshed once per frame,
|
||||
// and whenever the camera moved), with the engine registered for the scene
|
||||
// (engineFromViewer) so the shared helpers (iconOrientation, focus) project
|
||||
// through its projector; the frame number, frustum and horizon tests, the
|
||||
// window projection and the frame/camera events. No three, no Cesium.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ layerId: string, wrap?: (run: () => any) => any,
|
||||
* isDestroyed?: () => boolean }} options `wrap` runs each event listener
|
||||
* (the renderer's frame commit scope).
|
||||
* @returns {{ view: object, freshCamera: () => object, destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraLayerView(
|
||||
engine,
|
||||
{ layerId, wrap = (run) => run(), isDestroyed = () => false },
|
||||
) {
|
||||
const removers = [];
|
||||
let frameNumber = 0;
|
||||
removers.push(
|
||||
engine.frames.preUpdate.addEventListener(() => {
|
||||
frameNumber++;
|
||||
}),
|
||||
);
|
||||
const camera = {
|
||||
positionWC: geo.cartesian3.create(),
|
||||
positionCartographic: geo.cartographic.create(),
|
||||
directionWC: geo.cartesian3.create(),
|
||||
upWC: geo.cartesian3.create(),
|
||||
rightWC: geo.cartesian3.create(),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: { fovy: 1, aspectRatio: 1 },
|
||||
// The ellipsoid under a window point (the vessels' view-area request).
|
||||
pickEllipsoid: (screen) => engine.camera.pickEllipsoid(screen),
|
||||
};
|
||||
let poseFrame = -1;
|
||||
const lastPosition = geo.cartesian3.create(Number.NaN, 0, 0);
|
||||
// The camera as of now: refreshed once per frame, and whenever it moved.
|
||||
const freshCamera = () => {
|
||||
const live = engine.camera.livePosition;
|
||||
if (
|
||||
poseFrame === frameNumber &&
|
||||
live.x === lastPosition.x &&
|
||||
live.y === lastPosition.y &&
|
||||
live.z === lastPosition.z
|
||||
)
|
||||
return camera;
|
||||
const pose = engine.camera.pose();
|
||||
geo.cartesian3.clone(pose.position, camera.positionWC);
|
||||
geo.cartographic.clone(pose.cartographic, camera.positionCartographic);
|
||||
geo.cartesian3.clone(pose.direction, camera.directionWC);
|
||||
geo.cartesian3.clone(pose.up, camera.upWC);
|
||||
geo.cartesian3.clone(pose.right, camera.rightWC);
|
||||
camera.heading = pose.heading;
|
||||
camera.pitch = pose.pitch;
|
||||
camera.roll = pose.roll;
|
||||
camera.frustum.fovy = pose.fovy;
|
||||
camera.frustum.aspectRatio = pose.aspectRatio ?? 1;
|
||||
geo.cartesian3.clone(live, lastPosition);
|
||||
poseFrame = frameNumber;
|
||||
return camera;
|
||||
};
|
||||
const cameraView = new Proxy(camera, {
|
||||
get: (target, key) => freshCamera()[key],
|
||||
});
|
||||
const scene = {
|
||||
get camera() {
|
||||
return cameraView;
|
||||
},
|
||||
canvas: {
|
||||
get clientWidth() {
|
||||
return engine.host.size().width;
|
||||
},
|
||||
get clientHeight() {
|
||||
return engine.host.size().height;
|
||||
},
|
||||
},
|
||||
frameState: {
|
||||
get frameNumber() {
|
||||
return frameNumber;
|
||||
},
|
||||
},
|
||||
};
|
||||
// Shared helpers project through the engine registered for their scene.
|
||||
removers.push(registerViewerEngine(scene, engine));
|
||||
const horizon = {
|
||||
isPointVisible: (position) => engine.projector.horizonVisible(position),
|
||||
};
|
||||
const scratchDelta = geo.cartesian3.create();
|
||||
let frustum = null;
|
||||
const inFrustum = (position) => {
|
||||
const { origin, direction, right, up, tanX, tanY } = frustum;
|
||||
geo.cartesian3.subtract(position, origin, scratchDelta);
|
||||
const z = geo.cartesian3.dot(scratchDelta, direction);
|
||||
if (z <= -1) return false;
|
||||
const x = Math.abs(geo.cartesian3.dot(scratchDelta, right));
|
||||
const y = Math.abs(geo.cartesian3.dot(scratchDelta, up));
|
||||
return x <= z * tanX + 1 && y <= z * tanY + 1;
|
||||
};
|
||||
const view = {
|
||||
get camera() {
|
||||
return cameraView;
|
||||
},
|
||||
scene,
|
||||
surfaceOwner: engine,
|
||||
surfaceScene: engine,
|
||||
frameNumber: () => frameNumber,
|
||||
viewportHeightPx: () => engine.host.size().height,
|
||||
frustumTester() {
|
||||
const cam = freshCamera();
|
||||
const tanY = Math.tan(cam.frustum.fovy / 2);
|
||||
const { width, height } = engine.host.size();
|
||||
frustum = {
|
||||
origin: cam.positionWC,
|
||||
direction: cam.directionWC,
|
||||
right: cam.rightWC,
|
||||
up: cam.upWC,
|
||||
tanY,
|
||||
tanX: tanY * (height > 0 ? width / height : 1),
|
||||
};
|
||||
return inFrustum;
|
||||
},
|
||||
horizon: () => horizon,
|
||||
worldToWindow: (position, result) =>
|
||||
engine.projector.worldToWindow(position, result),
|
||||
requestRender: () => engine.render.request(layerId),
|
||||
on(event, listener) {
|
||||
const source =
|
||||
event === 'moveEnd'
|
||||
? engine.camera.moveEnd
|
||||
: event === 'preRender'
|
||||
? engine.frames.preRender
|
||||
: event === 'preUpdate'
|
||||
? engine.frames.preUpdate
|
||||
: null;
|
||||
if (!source) return null;
|
||||
return source.addEventListener((...args) =>
|
||||
wrap(() => listener(...args)),
|
||||
);
|
||||
},
|
||||
isDestroyed: () => isDestroyed() || engine.isDestroyed?.() === true,
|
||||
holdFrames() {},
|
||||
closeFrameLatch() {},
|
||||
releaseFrames() {},
|
||||
};
|
||||
|
||||
return {
|
||||
view,
|
||||
freshCamera,
|
||||
destroy() {
|
||||
for (const remove of removers.splice(0)) remove?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createCards } from './cards.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { vesselTypeCss } from '../../data/vesselLabels.js';
|
||||
/** Compose one vessel layer with application-owned scene services. */
|
||||
export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
const vesselState = createVesselState({ source, services });
|
||||
@@ -67,6 +68,40 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
cardScreenSeparated: parts.cards.cardScreenSeparated,
|
||||
});
|
||||
Object.defineProperty(layer, 'testing', { value: parts.testing });
|
||||
/**
|
||||
* What the layer draws, as plain data (the QA probe's drawn(id)): each
|
||||
* chevron as the renderer holds it, and the cards published last.
|
||||
*/
|
||||
layer.qaDrawn = () => {
|
||||
const { state } = vesselState;
|
||||
if (!state.feed.enabled) return null;
|
||||
const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null);
|
||||
return {
|
||||
chevrons: state.records.all
|
||||
.map((record) => [record, parts.rendering.getVisual(record).billboard])
|
||||
.filter(([, billboard]) => billboard)
|
||||
.map(([record, billboard]) => ({
|
||||
id: record.mmsi ?? null,
|
||||
position: plain(billboard.position),
|
||||
show: billboard.show === true,
|
||||
rotation: billboard.rotation,
|
||||
scale: billboard.scale,
|
||||
alpha: billboard.color?.alpha ?? 1,
|
||||
depthFree:
|
||||
billboard.disableDepthTestDistance === Number.POSITIVE_INFINITY,
|
||||
selected: record === state.selectedRecord,
|
||||
css:
|
||||
record === state.selectedRecord
|
||||
? '#ffffff'
|
||||
: vesselTypeCss(record.type),
|
||||
})),
|
||||
cards: (vesselState._publishedCards ?? []).map((entry) => ({
|
||||
id: entry.id,
|
||||
selected: entry.selected === true,
|
||||
position: plain(entry.position),
|
||||
})),
|
||||
};
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js';
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// The vessels layer's renderer on the Navara engine (phase 3 Task 13b),
|
||||
// with render/cesium.js's interface (./contract.js), built only on the
|
||||
// engine contract: the chevrons are one engine.movers sprite bank (always on
|
||||
// top, as Cesium's depth-test-free billboards) whose atlas holds every
|
||||
// chevron the layer draws (../icons.js, rasterised at 32 px as Cesium's
|
||||
// billboard texture); the trail is engine.lines; the camera reads go
|
||||
// through the shared Navara layer view (engine.camera.pose(), the
|
||||
// projector). No three, no Cesium. Registered for 'navara' by the Navara
|
||||
// mode after its import() (src/app/navaraMode.js).
|
||||
//
|
||||
// Handles keep Cesium's Billboard field vocabulary as setters that write
|
||||
// the bank; the writes of a frame are committed once, at the end of the
|
||||
// frame listener that made them (or in a microtask for writes outside a
|
||||
// frame).
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createMoverBank } from '../../aircraft/render/navara.js';
|
||||
import { createNavaraLayerView } from '../../shared/render/navaraView.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import { allVesselChevronIcons, VESSEL_ICON_PX } from '../icons.js';
|
||||
|
||||
const LAYER_ID = 'ais-live-vessels';
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
const ATLAS_PAD = 2;
|
||||
|
||||
/** The chevron atlas layout (pure): one 32 px cell per icon, in a row. */
|
||||
export function vesselAtlasLayout(icons = allVesselChevronIcons()) {
|
||||
const cell = VESSEL_ICON_PX + 2 * ATLAS_PAD;
|
||||
const width = icons.length * cell;
|
||||
const height = cell;
|
||||
const rects = new Map();
|
||||
const cells = {};
|
||||
icons.forEach((icon, column) => {
|
||||
const x = column * cell + ATLAS_PAD;
|
||||
const y = ATLAS_PAD;
|
||||
rects.set(icon, { x, y, size: VESSEL_ICON_PX });
|
||||
cells[icon] = [
|
||||
x / width,
|
||||
y / height,
|
||||
(x + VESSEL_ICON_PX) / width,
|
||||
(y + VESSEL_ICON_PX) / height,
|
||||
];
|
||||
});
|
||||
return { width, height, rects, cells };
|
||||
}
|
||||
|
||||
/** Draws the atlas (browser): a canvas and its cells, once the SVGs decode. */
|
||||
export async function drawVesselAtlas() {
|
||||
const layout = vesselAtlasLayout();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = layout.width;
|
||||
canvas.height = layout.height;
|
||||
const context = canvas.getContext('2d');
|
||||
await Promise.all(
|
||||
[...layout.rects].map(async ([uri, { x, y, size }]) => {
|
||||
const image = new Image();
|
||||
image.src = uri;
|
||||
await image.decode();
|
||||
context.drawImage(image, x, y, size, size);
|
||||
}),
|
||||
);
|
||||
return { canvas, cells: layout.cells };
|
||||
}
|
||||
|
||||
const cssToRgb = (css) => {
|
||||
const hex = String(css).replace('#', '');
|
||||
return [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
|
||||
};
|
||||
|
||||
/** Plain colour copy (what a handle's colour getter returns). */
|
||||
class StoredColor {
|
||||
constructor(color) {
|
||||
this.red = color?.red ?? 1;
|
||||
this.green = color?.green ?? 1;
|
||||
this.blue = color?.blue ?? 1;
|
||||
this.alpha = color?.alpha ?? 1;
|
||||
}
|
||||
|
||||
withAlpha(alpha) {
|
||||
return new StoredColor({ ...this, alpha });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ drawAtlas?: () => Promise<{ canvas: object, cells: object }> }} [options]
|
||||
* Test seam: the chevron atlas.
|
||||
* @returns {import('./contract.js').VesselRenderer}
|
||||
*/
|
||||
export function createVesselsNavaraRenderer(
|
||||
engine,
|
||||
{ drawAtlas = drawVesselAtlas } = {},
|
||||
) {
|
||||
let destroyed = false;
|
||||
|
||||
// --- commits: once per frame listener, or in a microtask -----------------
|
||||
let bank = null;
|
||||
let inFrame = 0;
|
||||
let microtask = false;
|
||||
let pendingCommit = false;
|
||||
const flush = () => {
|
||||
microtask = false;
|
||||
if (!pendingCommit) return;
|
||||
pendingCommit = false;
|
||||
bank?.commit();
|
||||
};
|
||||
const onCommit = () => {
|
||||
pendingCommit = true;
|
||||
if (inFrame === 0 && !microtask) {
|
||||
microtask = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const inFrameDo = (run) => {
|
||||
inFrame++;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
inFrame--;
|
||||
if (inFrame === 0) flush();
|
||||
}
|
||||
};
|
||||
|
||||
// --- the view --------------------------------------------------------------
|
||||
const layerView = createNavaraLayerView(engine, {
|
||||
layerId: LAYER_ID,
|
||||
wrap: inFrameDo,
|
||||
isDestroyed: () => destroyed,
|
||||
});
|
||||
const base = layerView.view;
|
||||
const view = {
|
||||
get camera() {
|
||||
return base.camera;
|
||||
},
|
||||
scene: base.scene,
|
||||
horizon: () => base.horizon(),
|
||||
worldToWindow: (position, result) => base.worldToWindow(position, result),
|
||||
canvasSize: () => engine.host.size(),
|
||||
requestRender: () => base.requestRender(),
|
||||
on(event, listener) {
|
||||
return event === 'preRender' || event === 'moveEnd'
|
||||
? base.on(event, listener)
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
// --- the chevrons ------------------------------------------------------------
|
||||
let atlas = null;
|
||||
let visible = true;
|
||||
let attached = false;
|
||||
const handles = new Set();
|
||||
const records = new Map(); // pickId → record
|
||||
let unkeyedSeq = 0;
|
||||
const scratchColor = [1, 1, 1, 1];
|
||||
const write = (handle, set, index) => {
|
||||
const color = handle.storedColor;
|
||||
scratchColor[0] = color.red;
|
||||
scratchColor[1] = color.green;
|
||||
scratchColor[2] = color.blue;
|
||||
scratchColor[3] = color.alpha;
|
||||
set.set(index, {
|
||||
position: handle.storedPosition,
|
||||
rotation: handle.storedRotation,
|
||||
scale: handle.storedScale,
|
||||
color: scratchColor,
|
||||
cell: atlas.cells[handle.storedImage]
|
||||
? handle.storedImage
|
||||
: Object.keys(atlas.cells)[0],
|
||||
show: handle.storedShow,
|
||||
pickId: handle.pickId,
|
||||
});
|
||||
};
|
||||
const ensureBank = () => {
|
||||
if (bank || !atlas || destroyed) return;
|
||||
bank = createMoverBank(engine, {
|
||||
options: (capacity, generation) => ({
|
||||
id: `${LAYER_ID}-chevrons-${generation}`,
|
||||
kind: 'sprite',
|
||||
capacity,
|
||||
order: SPRITE_LAYER_ORDER.indexOf('ais'),
|
||||
// Cesium's disableDepthTestDistance: Infinity.
|
||||
depthTest: 'always',
|
||||
pickable: true,
|
||||
sprite: { atlas, sizePx: VESSEL_ICON_PX },
|
||||
}),
|
||||
write,
|
||||
onCommit,
|
||||
});
|
||||
bank.setVisible(visible && attached);
|
||||
for (const handle of handles) bank.add(handle);
|
||||
};
|
||||
const atlasReady = Promise.resolve()
|
||||
.then(() => drawAtlas())
|
||||
.then((drawn) => {
|
||||
if (destroyed) return;
|
||||
atlas = drawn;
|
||||
if (attached) ensureBank();
|
||||
});
|
||||
|
||||
const update = (handle, fields) => {
|
||||
if (handle.gevBank) handle.gevBank.update(handle, fields);
|
||||
};
|
||||
const scratchRgba = [1, 1, 1, 1];
|
||||
const newHandle = (spec) => {
|
||||
const record = spec.id;
|
||||
const pickId =
|
||||
record?.mmsi != null && record.mmsi !== ''
|
||||
? String(record.mmsi)
|
||||
: `gev-vessel-unkeyed:${++unkeyedSeq}`;
|
||||
const handle = {
|
||||
id: record,
|
||||
pickId,
|
||||
gevBank: null,
|
||||
gevIndex: -1,
|
||||
storedPosition: geo.cartesian3.clone(spec.position),
|
||||
storedShow: spec.show !== false,
|
||||
storedRotation: spec.rotation ?? 0,
|
||||
storedScale: spec.scale ?? 1,
|
||||
storedImage: spec.image,
|
||||
storedColor: new StoredColor(),
|
||||
// Cesium reads these before the image loads as undefined (the logic
|
||||
// falls back to the 32 px artwork).
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
// The bank draws without a depth test (Cesium's billboards: Infinity).
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
get position() {
|
||||
return handle.storedPosition;
|
||||
},
|
||||
set position(value) {
|
||||
handle.storedPosition = geo.cartesian3.clone(value);
|
||||
update(handle, { position: handle.storedPosition });
|
||||
},
|
||||
get show() {
|
||||
return handle.storedShow;
|
||||
},
|
||||
set show(value) {
|
||||
const next = value !== false;
|
||||
if (next === handle.storedShow) return;
|
||||
handle.storedShow = next;
|
||||
update(handle, { show: next });
|
||||
},
|
||||
get rotation() {
|
||||
return handle.storedRotation;
|
||||
},
|
||||
set rotation(value) {
|
||||
handle.storedRotation = value;
|
||||
update(handle, { rotation: value });
|
||||
},
|
||||
get scale() {
|
||||
return handle.storedScale;
|
||||
},
|
||||
set scale(value) {
|
||||
handle.storedScale = value;
|
||||
update(handle, { scale: value });
|
||||
},
|
||||
get image() {
|
||||
return handle.storedImage;
|
||||
},
|
||||
set image(value) {
|
||||
handle.storedImage = value;
|
||||
if (atlas?.cells[value]) update(handle, { cell: value });
|
||||
},
|
||||
get color() {
|
||||
return handle.storedColor;
|
||||
},
|
||||
set color(value) {
|
||||
handle.storedColor = new StoredColor(value);
|
||||
scratchRgba[0] = handle.storedColor.red;
|
||||
scratchRgba[1] = handle.storedColor.green;
|
||||
scratchRgba[2] = handle.storedColor.blue;
|
||||
scratchRgba[3] = handle.storedColor.alpha;
|
||||
update(handle, { color: scratchRgba });
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
};
|
||||
|
||||
const fleet = {
|
||||
collection: null,
|
||||
attached: () => attached,
|
||||
attach({ show }) {
|
||||
if (attached || destroyed) return;
|
||||
attached = true;
|
||||
visible = show !== false;
|
||||
ensureBank();
|
||||
bank?.setVisible(visible);
|
||||
},
|
||||
setVisible(show) {
|
||||
visible = show !== false;
|
||||
bank?.setVisible(visible && attached);
|
||||
if (visible) view.requestRender();
|
||||
},
|
||||
add(spec) {
|
||||
const handle = newHandle(spec);
|
||||
handles.add(handle);
|
||||
records.set(handle.pickId, handle.id);
|
||||
bank?.add(handle);
|
||||
return handle;
|
||||
},
|
||||
remove(handle) {
|
||||
if (!handle || !handles.delete(handle)) return;
|
||||
records.delete(handle.pickId);
|
||||
handle.gevBank?.remove(handle);
|
||||
},
|
||||
detach() {
|
||||
attached = false;
|
||||
for (const handle of handles) {
|
||||
handle.gevBank = null;
|
||||
handle.gevIndex = -1;
|
||||
}
|
||||
handles.clear();
|
||||
records.clear();
|
||||
bank?.destroy();
|
||||
bank = null;
|
||||
},
|
||||
};
|
||||
|
||||
/** The record a pick is of: pick ids are the MMSI (unkeyed: their own). */
|
||||
const pickedRecord = (picked) => {
|
||||
const id = picked?.id;
|
||||
return id === undefined || id === null
|
||||
? null
|
||||
: (records.get(String(id)) ?? null);
|
||||
};
|
||||
|
||||
// Vessels never follow: a follow belongs to another layer.
|
||||
const follow = {
|
||||
onChanged: (listener) =>
|
||||
engine.camera.followChanged.addEventListener(listener),
|
||||
active: () => engine.camera.following() !== null,
|
||||
};
|
||||
|
||||
// --- the trail (engine.lines) ----------------------------------------------
|
||||
let lineSeq = 0;
|
||||
const trails = {
|
||||
create({ color, width = 2.5 }) {
|
||||
const [r, g, b] = cssToRgb(color);
|
||||
const id = `gev-trail:${LAYER_ID}-${++lineSeq}`;
|
||||
let line = null;
|
||||
const ensure = () =>
|
||||
(line ??= engine.lines.createLine({
|
||||
id,
|
||||
widthPx: width,
|
||||
color: [r, g, b, TRAIL_ALPHA],
|
||||
depthFailAlpha: TRAIL_OCCLUDED_ALPHA / TRAIL_ALPHA,
|
||||
arc: 'geodesic',
|
||||
pickId: id,
|
||||
}));
|
||||
return {
|
||||
setPositions(cartesians) {
|
||||
if (destroyed) return;
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
geo.cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
ensure().setPositions(positions.length >= 2 ? positions : []);
|
||||
},
|
||||
clear() {
|
||||
line?.setPositions([]);
|
||||
},
|
||||
destroy() {
|
||||
line?.destroy();
|
||||
line = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'navara',
|
||||
engine,
|
||||
view,
|
||||
fleet,
|
||||
pickedRecord,
|
||||
follow,
|
||||
trails,
|
||||
/** The atlas is drawn (tests, QA). */
|
||||
whenReady: () => atlasReady,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
flush();
|
||||
fleet.detach();
|
||||
destroyed = true;
|
||||
layerView.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -208,3 +208,81 @@ test('vessel logic draws and selects through a plain renderer on an engine, with
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessels on the Navara renderer: one always-on-top chevron bank, MMSI picks, the trail on engine.lines', async () => {
|
||||
const { createVesselsNavaraRenderer, vesselAtlasLayout } =
|
||||
await import('./render/navara.js');
|
||||
const engine = createFakeEngine();
|
||||
const layout = vesselAtlasLayout();
|
||||
const renderer = createVesselsNavaraRenderer(engine, {
|
||||
drawAtlas: async () => ({ canvas: { kind: 'atlas' }, cells: layout.cells }),
|
||||
});
|
||||
assert.deepEqual(missingVesselRendererMembers(renderer), []);
|
||||
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,
|
||||
}),
|
||||
getTrack: async () => ({
|
||||
records: [{ latitude: 40.5, longitude: -73.8 }],
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
...services(renderer),
|
||||
geoid: {
|
||||
ensureGeoidReady: () => new Promise(noop),
|
||||
geoidHeight: () => 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
getElementById: () => null,
|
||||
});
|
||||
try {
|
||||
layer.init(null, { engine });
|
||||
await layer.enable(null, { engine });
|
||||
await renderer.whenReady();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const sets = engine.movers.list();
|
||||
assert.equal(sets.length, 1);
|
||||
assert.equal(sets[0].count, 2);
|
||||
const set = engine.fake.moverSet(sets[0].id);
|
||||
assert.equal(set.options.depthTest, 'always');
|
||||
assert.equal(set.options.sprite.sizePx, 32);
|
||||
assert.deepEqual([...set.store.pickIds.slice(0, 2)].sort(), ['111', '222']);
|
||||
|
||||
// A click on its pick (the MMSI) selects it: the white chevron ×1.2.
|
||||
engine.fake.script('pick', () => ({ id: '111', owner: sets[0].id }));
|
||||
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||
assert.equal(layer.getSelectedInfo()?.mmsi, '111');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const index = [...set.store.pickIds].indexOf('111');
|
||||
assert.ok(Math.abs(set.store.scales[index] - 0.72) < 1e-6);
|
||||
// The trail: an engine line (the backfill fix + the live position).
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.ok(engine.fake.line('gev-trail:ais-live-vessels-1'));
|
||||
|
||||
// Another layer follows something: the vessel selection goes.
|
||||
const handle = engine.camera.follow({
|
||||
position: () => engine.camera.pose().position,
|
||||
viewFrom: { x: 0, y: -100, z: 100 },
|
||||
});
|
||||
assert.equal(layer.getSelectedInfo(), null);
|
||||
handle.stop();
|
||||
|
||||
layer.disable(null, { engine });
|
||||
layer.destroy(null, { engine });
|
||||
assert.equal(engine.movers.list().length, 0);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -413,6 +413,8 @@ export function createRendering({
|
||||
height,
|
||||
labelRowLimit(),
|
||||
);
|
||||
// The cards published last (QA: qaDrawn).
|
||||
vesselState._publishedCards = entries;
|
||||
vesselState._vesselOverlayHost.setEntries(
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
entries.map((entry) => {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState.shipIconCache = new Map();
|
||||
|
||||
/** The card entries published last (QA: the layer's qaDrawn). */
|
||||
|
||||
vesselState._publishedCards = [];
|
||||
|
||||
vesselState.state = {
|
||||
feed: createVesselFeed(),
|
||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||
|
||||
Reference in new issue
Block a user