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:
German Khokhlov committed 2026-10-17 14:06:11 +04:00
1 parent 620ada6df3
commit 69a78710cc
14 files changed
+850 -154

No files matched your search

+3
View File
@@ -365,6 +365,9 @@
"src/layers/vessels/render/cesium.js",
"src/layers/vessels/render/contract.js",
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/render/navara.js",
"src/layers/shared/render/navaraView.js",
"scripts/qa-vessels-engines.mjs",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
+3 -1
View File
@@ -2606,7 +2606,9 @@
"src/layers/aircraft/qaDrawn.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",
"src/layers/vessels/render/navara.js",
"src/layers/shared/render/navaraView.js"
],
"external": [
"hls.js",
+60 -18
View File
@@ -38,6 +38,16 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
// --engine cesium|navara (phase 3): frame, settle and read the renderer
// through window.__godsEyeView.engine (either engine) instead of the Cesium
// viewer; the URL gets ?engine=. Without it, as before.
const ENGINE = getOpt('--engine', null);
const PAGE_URL = (() => {
if (!ENGINE) return APP_URL;
const url = new URL(APP_URL);
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const TAG = getOpt('--tag', 'new');
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
const OUT_DIR = getOpt('--out', 'qa-shots');
@@ -203,6 +213,7 @@ async function main() {
console.log(`\nAIS Vessel Cards — visual proof harness`);
console.log(` App URL : ${APP_URL}`);
console.log(` Tag : ${TAG}`);
console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`);
console.log(` Data : ${DATA_MODE}`);
console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (SwiftShader)'}`);
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
@@ -259,13 +270,24 @@ async function main() {
});
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
if (ENGINE)
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 });
else
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
// Frame the port (kill the intro flight first) and enable the layer.
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows }) => {
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => {
const gev = window.__godsEyeView;
if (engineNeutral) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight?.();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(p.lon, p.lat, p.height),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
} else {
const v = gev.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
@@ -278,6 +300,7 @@ async function main() {
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}
await gev.dataManager.setEnabled('ais-live-vessels', true);
if (dataMode !== 'synthetic') return { mode: 'live', injected: 0 };
const seam = gev.dataManager.layers.get('ais-live-vessels')?.module?.__focusEvidence;
@@ -286,9 +309,10 @@ async function main() {
if (!result?.ok || result.count !== fixtureRows.length) {
throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`);
}
v.scene.requestRender?.();
if (engineNeutral) gev.engine.render.request('qa-vessel-cards');
else gev.viewer.scene.requestRender?.();
return { mode: 'synthetic', injected: result.count };
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows });
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) });
console.log(
` [DATA] ${dataEvidence.mode}${dataEvidence.injected ? ` (${dataEvidence.injected} injected)` : ''}; `
+ `artifact provenance=${DATA_PROVENANCE_SLUGS[DATA_MODE]}`,
@@ -296,21 +320,32 @@ async function main() {
// Wait for the photoreal tileset and at least one vessel refresh.
const settled = await page
.waitForFunction(() => {
.waitForFunction((engineNeutral) => {
const gev = window.__godsEyeView;
const t = gev.tileset;
const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
const count = ais?.stats?.count ?? 0;
return t?.tilesLoaded && count > 0;
}, { timeout: 90000, polling: 500 })
return tilesLoaded && count > 0;
}, { timeout: 90000, polling: 500 }, Boolean(ENGINE))
.then(() => true)
.catch(() => false);
// Re-assert the port framing: on slow (SwiftShader) runs the app's
// intro flight can start AFTER the first cancelFlight and land mid-wait,
// dragging the camera back to the boot city before the screenshot.
await page.evaluate((p) => {
const v = window.__godsEyeView.viewer;
await page.evaluate(async (p, engineNeutral) => {
const gev = window.__godsEyeView;
if (engineNeutral) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight?.();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(p.lon, p.lat, p.height),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
} else {
const v = gev.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = {
@@ -322,25 +357,32 @@ async function main() {
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}, port);
}
}, port, Boolean(ENGINE));
// A changed camera invalidates the previous settled result. Let the new
// view schedule its tiles before checking readiness again.
await new Promise((resolve) => setTimeout(resolve, 1500));
const resettled = await page
.waitForFunction(() => {
.waitForFunction((engineNeutral) => {
const gev = window.__godsEyeView;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
return gev.tileset?.tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 })
const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded;
return tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 }, Boolean(ENGINE))
.then(() => true)
.catch(() => false);
// Let two visibility/declutter cycles (800 ms) and host rendering settle.
await new Promise((r) => setTimeout(r, 2500));
const expectedFixtureIds = syntheticRows.map((row) => row.mmsi);
const overlayEvidence = await page.evaluate((fixtureIds) => {
const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => {
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
const canvas = window.__godsEyeView?.engine?.host?.canvas;
const gl = engineNeutral
? canvas?.getContext('webgl2') || canvas?.getContext('webgl')
: window.__godsEyeView?.viewer?.scene?.context?._gl;
const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info');
const seam = window.__godsEyeView?.dataManager?.layers
?.get('ais-live-vessels')?.module?.__focusEvidence;
@@ -358,7 +400,7 @@ async function main() {
renderer: String(gl.getParameter(debugInfo?.UNMASKED_RENDERER_WEBGL || gl.RENDERER) || 'unknown'),
} : { vendor: 'unavailable', renderer: 'unavailable' },
};
}, expectedFixtureIds);
}, expectedFixtureIds, Boolean(ENGINE));
await page.screenshot({ path: outPath });
const st = fs.statSync(outPath);
const hardwareRenderer = isHardwareRenderer(overlayEvidence.renderer);
+86 -7
View File
@@ -17,6 +17,12 @@
* Usage:
* node scripts/qa-vessel-datum.mjs
* node scripts/qa-vessel-datum.mjs --ports rotterdam --url http://localhost:4173
* node scripts/qa-vessel-datum.mjs --engine navara --url 'http://localhost:PORT/?qaFeed=vessels:fixture'
*
* --engine cesium|navara (phase 3): the engine-neutral path on either engine,
* through window.__godsEyeView.engine and the QA probe (layers.drawn: each
* chevron's drawn position and depth-test flag) instead of the Cesium
* viewer's primitives; the URL gets ?engine=. Without it, as before.
*/
import fs from 'node:fs';
import path from 'node:path';
@@ -31,6 +37,13 @@ const getOpt = (name, dflt) => {
const APP_URL = getOpt('--url', 'http://localhost:4173');
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
const OUT_DIR = getOpt('--out', 'qa-shots');
const ENGINE = getOpt('--engine', null);
const PAGE_URL = (() => {
if (!ENGINE) return APP_URL;
const url = new URL(APP_URL);
url.searchParams.set('engine', ENGINE);
return url.href;
})();
/**
* Camera framings match qa-vessel-cards.mjs; heightBand is the expected
@@ -132,9 +145,63 @@ function probeVessels({ portLat, portLon, nearDeg, sampleCap }) {
};
}
/**
* In-page probe on either engine (the QA probe): the chevrons the layer
* draws, their depth-test flag and the ellipsoidal heights of those near the
* port.
*/
async function probeVesselsEngine({ portLat, portLon, nearDeg, sampleCap }) {
const gev = window.__godsEyeView;
const drawn = gev?.layers?.drawn('ais-live-vessels');
if (!drawn) return { error: 'vessels layer not drawn' };
const geo = await import('/src/geo/index.js');
const chevrons = drawn.chevrons;
let depthFreeCount = 0;
let depthTestedCount = 0;
const nearHeights = [];
const step = Math.max(1, Math.floor(chevrons.length / sampleCap));
for (let i = 0; i < chevrons.length; i += step) {
const row = chevrons[i];
if (row.depthFree) depthFreeCount += 1;
else depthTestedCount += 1;
const carto = geo.cartographic.fromCartesian(row.position);
if (!carto) continue;
const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude);
if (Math.abs(lat - portLat) <= nearDeg && Math.abs(lon - portLon) <= nearDeg)
nearHeights.push(carto.height);
}
nearHeights.sort((a, b) => a - b);
const median = nearHeights.length ? nearHeights[Math.floor(nearHeights.length / 2)] : null;
return {
total: chevrons.length,
sampled: depthFreeCount + depthTestedCount,
depthFreeCount,
depthTestedCount,
nearCount: nearHeights.length,
medianHeight: median,
minHeight: nearHeights[0] ?? null,
maxHeight: nearHeights[nearHeights.length - 1] ?? null,
};
}
/** Frames a port through the engine (either engine). */
async function frameEngine(p, enable) {
const geo = await import('/src/geo/index.js');
const gev = window.__godsEyeView;
gev.engine.camera.cancelFlight?.();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(p.lon, p.lat, p.height),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
if (enable) await gev.dataManager.setEnabled('ais-live-vessels', true);
gev.engine.render.request('qa-vessel-datum');
}
async function main() {
console.log(`\nAIS Vessel Datum — assertion harness`);
console.log(` App URL : ${APP_URL}`);
console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`);
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
try {
@@ -179,11 +246,15 @@ async function main() {
await page.setViewport({ width: 1600, height: 900 });
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
if (ENGINE)
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 });
else
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
// Frame the port (kill the intro flight first) and enable the layer.
await page.evaluate(async (p) => {
if (ENGINE) await page.evaluate(frameEngine, port, true);
else await page.evaluate(async (p) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight?.();
@@ -208,7 +279,7 @@ async function main() {
let probe = null;
let settled = false;
while (Date.now() < deadline) {
probe = await page.evaluate(probeVessels, probeArgs);
probe = await page.evaluate(ENGINE ? probeVesselsEngine : probeVessels, probeArgs);
if (probe && !probe.error && probe.nearCount >= 3
&& probe.medianHeight !== null
&& probe.medianHeight >= port.heightBand[0]
@@ -245,7 +316,8 @@ async function main() {
// Visual proof — re-assert the framing (the app's intro flight can land
// mid-probe on slow runs), wait for the photoreal tileset, screenshot.
await page.evaluate((p) => {
if (ENGINE) await page.evaluate(frameEngine, port, false);
else await page.evaluate((p) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
@@ -260,10 +332,17 @@ async function main() {
});
}, port);
await page
.waitForFunction(() => window.__godsEyeView?.tileset?.tilesLoaded, { timeout: 60000, polling: 500 })
.waitForFunction(
(engineNeutral) =>
engineNeutral
? window.__godsEyeView?.engine?.surface?.tilesLoaded?.()
: window.__godsEyeView?.tileset?.tilesLoaded,
{ timeout: 60000, polling: 500 },
Boolean(ENGINE),
)
.catch(() => console.log(' (tileset settle timeout — screenshotting anyway)'));
await new Promise((r) => setTimeout(r, 2500));
const outPath = path.join(OUT_DIR, `vessel-datum-${key}.png`);
const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`);
await page.screenshot({ path: outPath });
console.log(` [SHOT] ${outPath}`);
await page.close();
+3 -3
View File
@@ -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);
});
+3
View File
@@ -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
View File
@@ -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();
+7 -124
View File
@@ -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;
+161
View File
@@ -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?.();
},
};
}
+35
View File
@@ -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';
+399
View File
@@ -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();
},
};
}
+78
View File
@@ -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;
}
});
+2
View File
@@ -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) => {
+4
View File
@@ -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() }),