diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cfc18f7dd..918e3ea61 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1504,6 +1504,9 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1578,6 +1581,11 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/layers/flights/modelSpec.js", + "src/layers/flights/policy.js", + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1677,6 +1685,13 @@ "vessel-layer": { "exports": ["./layers/vessels"], "modules": [ + "src/data/fleetModels.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftModelLighting.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", "src/data/headingDegrees.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -2545,6 +2560,10 @@ "./standalone/catalog" ], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -3811,6 +3830,10 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/data/feedState.js", "src/loadingFeedback.js", "src/sources/adsbFields.js", @@ -5165,6 +5188,13 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/fleetModels.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", + "src/data/aircraftModelLighting.js", "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", @@ -5248,6 +5278,13 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/i18n/marker.js", + "src/data/fleetModels.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftClass.js", + "src/data/aircraftModelLighting.js", + "src/data/fleetTrailAnchors.js", + "src/data/modelEligibility.js", "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 71cefceed..c18a43d85 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -57,8 +57,13 @@ export function ingestAisStreamEnvelope(envelope) { message.ReportB?.Valid === false ? undefined : message.ReportB?.ShipType, + Dimension: + message.ReportB?.Valid === false + ? undefined + : message.ReportB?.Dimension, } : message; + const dimensions = aisDimensions(fields.Dimension); // Static reports may be partial (including Class B's separate A/B parts). // Keep known values in the cache as well as in the current live row. const staticData = { @@ -84,6 +89,8 @@ export function ingestAisStreamEnvelope(envelope) { staticCode(live?.imo) || staticCode(previous.imo) || '', + length: dimensions.length ?? live?.length ?? previous.length ?? null, + beam: dimensions.beam ?? live?.beam ?? previous.beam ?? null, }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); @@ -113,6 +120,8 @@ export function ingestAisStreamEnvelope(envelope) { imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), type: vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), + length: staticData.length ?? null, + beam: staticData.beam ?? null, speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), @@ -236,6 +245,25 @@ function mergeAisStaticIntoLiveVessel(mmsi, staticData) { if (staticData.type) existing.type = staticData.type; if (staticData.destination) existing.destination = staticData.destination; if (staticData.imo) existing.imo = staticData.imo; + if (staticData.length) existing.length = staticData.length; + if (staticData.beam) existing.beam = staticData.beam; +} + +/** + * A static report's hull size from its AIS dimensions: the antenna's + * distances to bow (A), stern (B), port (C) and starboard (D), in metres. + * Zero is AIS's "not available", so a side missing its two parts has none. + * @param {{A?: number, B?: number, C?: number, D?: number}|undefined} dimension + * @returns {{length: number|null, beam: number|null}} + */ +function aisDimensions(dimension) { + const part = (value) => { + const number = numberValue(value); + return number !== null && number > 0 ? number : 0; + }; + const length = part(dimension?.A) + part(dimension?.B); + const beam = part(dimension?.C) + part(dimension?.D); + return { length: length > 0 ? length : null, beam: beam > 0 ? beam : null }; } function vesselNameFromAis(metadata, message, staticData = {}) { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index c3d6c6f40..d89d60e67 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -109,6 +109,8 @@ export function createApplicationCatalog({ const vessels = createApplicationVessels({ source: sources.vessels, options: vesselOptions, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), }); const installations = createApplicationInstallations({ surface, @@ -167,7 +169,12 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic, surface }), createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), - createApplicationTransit({ surface, source: sources.transit }), + createApplicationTransit({ + surface, + source: sources.transit, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationBikeshare({ source: sources.bikeshare }), createApplicationDirections({ source: sources.directions }), createApplicationRecentImagery({ source: sources['recent-imagery'] }), @@ -200,7 +207,12 @@ export function createApplicationCatalog({ ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), - createApplicationRail({ surface, source: sources.rail }), + createApplicationRail({ + surface, + source: sources.rail, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationFirms({ surface, id: 'local-firms', diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index cae0bf44c..65edc5d63 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -10,9 +10,19 @@ import * as focus from '../../data/focusDeemphasis.js'; import * as worldFocus from '../../worldFocus.js'; import * as render from '../../renderGovernor.js'; import * as readouts from '../../hudReadouts.js'; +import { fleetModelServices } from './fleetModels.js'; -/** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationVessels({ source, options = {} }) { +/** + * Construct one layer using the application scene owners and a supplied + * source. `displayParams` reads the Flights layer's DISPLAY-rail 3D + * preference, under which nearby vessels are drawn as fleet models. + */ +export function createApplicationVessels({ + source, + options = {}, + displayParams = null, + resolveAsset, +}) { return createVesselLayer({ source, options, @@ -28,6 +38,10 @@ export function createApplicationVessels({ source, options = {} }) { worldFocus, render, readouts, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/fleetModels.js b/src/app/layers/fleetModels.js new file mode 100644 index 000000000..f6c5bcb7d --- /dev/null +++ b/src/app/layers/fleetModels.js @@ -0,0 +1,19 @@ +import { FLEET_MODEL_POLICY } from '../../layers/flights/policy.js'; + +/** + * The fleet-model service the Vessels, Transit and Rail layers draw their 3D + * models with: the Flights layer's DISPLAY-rail 3D preference + * (`{ models3d, models3dMode }`), the aircraft layers' icon-to-model rules, + * and the resolver the fleet set's files are fetched through. + * @param {{display?: (() => object|null)|null, + * resolveAsset?: (url: string) => string}} [options] + * @returns {object|null} Null without a display preference (no models). + */ +export function fleetModelServices({ + display = null, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +} = {}) { + if (!display) return null; + return Object.freeze({ display, policy: FLEET_MODEL_POLICY, resolveAsset }); +} diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js index 9b5a3bb0e..fb3a05c51 100644 --- a/src/app/layers/rail.js +++ b/src/app/layers/rail.js @@ -1,5 +1,6 @@ import { createRailLayer } from '../../layers/rail/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -13,10 +14,18 @@ import { /** * Construct the Rail layer on the application's scene owners and its shared * ground floor, the same floor Transit and the aircraft stand on. - * @param {{surface: object, source?: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby trains are drawn as fleet models. + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationRail({ surface, source }) { +export function createApplicationRail({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor) throw new TypeError('Rail needs the application surface services'); const { groundFloor } = surface; @@ -45,6 +54,10 @@ export function createApplicationRail({ surface, source }) { globalThis.document?.body?.classList?.contains('cockpit-mode') === true, }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 367fc425e..1b430a957 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -1,5 +1,6 @@ import { createTransitLayer } from '../../layers/transit/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -19,10 +20,19 @@ import { * own mesh floors. An earlier cut imported the legacy module-level singleton * instead and could not see either. * - * @param {{surface: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby vehicles are drawn as fleet models. + * + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationTransit({ surface, source }) { +export function createApplicationTransit({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor || !surface?.meshFloor) throw new TypeError('Transit needs the application surface services'); const { groundFloor, meshFloor } = surface; @@ -44,6 +54,10 @@ export function createApplicationTransit({ surface, source }) { allocateCorridorCells: groundFloor.allocateCorridorCells, }, mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 5ca3375f7..93569fc97 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -258,3 +258,61 @@ test('partial static data keeps newer live metadata ahead of the old static cach assert.equal(rowFor(mmsi).type, '80'); assert.equal(rowFor(mmsi).destination, 'HAMBURG'); }); + +// A static report's dimensions are the antenna's distances to bow (A), stern +// (B), port (C) and starboard (D): length A+B, beam C+D. Zero is AIS's "not +// available", so a vessel reporting none has no size, not a size of zero. +test('a static report gives a vessel its hull length and beam', () => { + ingestAisStreamEnvelope(positionReport('636019825', { Sog: 14 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019825, + Name: 'EVER GIVEN', + Type: 70, + Dimension: { A: 330, B: 70, C: 30, D: 29 }, + }, + }, + MetaData: { MMSI: '636019825' }, + }); + const row = rowFor('636019825'); + assert.equal(row.length, 400); + assert.equal(row.beam, 59); + + ingestAisStreamEnvelope(positionReport('636019826', { Sog: 3 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019826, + Type: 52, + Dimension: { A: 0, B: 0, C: 0, D: 0 }, + }, + }, + MetaData: { MMSI: '636019826' }, + }); + assert.equal(rowFor('636019826').length, null); + assert.equal(rowFor('636019826').beam, null); +}); + +test('a Class B report carries its dimensions in part B', () => { + ingestAisStreamEnvelope(positionReport('244000777', { Sog: 6 })); + ingestAisStreamEnvelope({ + MessageType: 'StaticDataReport', + Message: { + StaticDataReport: { + UserID: 244000777, + ReportB: { + Valid: true, + ShipType: 37, + Dimension: { A: 8, B: 4, C: 2, D: 2 }, + }, + }, + }, + MetaData: { MMSI: '244000777' }, + }); + const row = rowFor('244000777'); + assert.equal(row.length, 12); + assert.equal(row.beam, 4); +}); diff --git a/src/data/fleetModelInstances.test.mjs b/src/data/fleetModelInstances.test.mjs new file mode 100644 index 000000000..298ef56df --- /dev/null +++ b/src/data/fleetModelInstances.test.mjs @@ -0,0 +1,360 @@ +// The icon-to-model handoff the Vessels, Transit and Rail layers share +// (fleetModelInstances.js): models only under the DISPLAY 3D toggle and the +// altitude ceiling, at most `cap` of the nearest, each vehicle's icon the +// visual until its model is loaded, ready and placed, and a tinted model +// painted with its route colour. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import * as Cesium from 'cesium'; +import { + createFleetModel, + createFleetModelLayer, + fleetModelMatrix, + fleetTintShader, + FLEET_HEADING_OFFSET_DEG, +} from './fleetModelInstances.js'; +import { + loadFleetModelSet, + transitModelSpec, + vesselModelSpec, +} from './fleetModels.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +const realInfo = console.info; +console.info = () => {}; +const set = await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; + +const POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 2, + capAll: 4, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 80, +}); + +const CENTRE = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 0); + +function fakeViewer(height = 1_000) { + const primitives = []; + const position = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, height); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + const camera = { + positionWC: position, + positionCartographic: { height }, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + return { + camera, + scene: { + primitives: { + add(p) { + primitives.push(p); + return p; + }, + remove(p) { + const i = primitives.indexOf(p); + if (i >= 0) primitives.splice(i, 1); + return i >= 0; + }, + }, + }, + primitives, + isDestroyed: () => false, + }; +} + +function fakeModel(options) { + return { + ...options, + ready: false, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; +} + +/** A vehicle `metres` east of the camera's foot. */ +function itemAt(metres, spec, extra = {}) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(CENTRE); + const position = Cesium.Matrix4.multiplyByPoint( + enu, + new Cesium.Cartesian3(metres, 0, 0), + new Cesium.Cartesian3(), + ); + return { position, headingDeg: 90, spec, tint: null, pickId: null, ...extra }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('models load from the up-front set, never by URL', async () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 32 }); + let options = null; + await createFleetModel( + spec, + { scale: spec.scale }, + { + set, + resolveAsset: (url) => `/base${url}`, + load: async (o) => { + options = o; + return {}; + }, + }, + ); + assert.ok(options.gltf instanceof Uint8Array, 'the loaded GLB bytes'); + assert.equal(options.gltf, set.bytes.get(spec.model)); + assert.equal(options.basePath, `/base${spec.url}`); + assert.equal(options.url, undefined, 'no fetch by URL'); + assert.deepEqual({ ...options.environmentMapOptions }, { enabled: false }); + await assert.rejects( + createFleetModel({ model: 'missing' }, {}, { set, load: async () => ({}) }), + /not loaded/, + ); +}); + +test('the icon stays the visual until the model is ready; only the nearest are drawn', async () => { + const viewer = fakeViewer(); + const display = { models3d: true, models3dMode: 'proximity' }; + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => display, + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ spec, ...options }); + created.push(model); + return model; + }, + }); + const spec = transitModelSpec(set, { mode: 'tram' }); + const items = new Map([ + ['near', itemAt(100, spec, { pickId: 'near' })], + ['mid', itemAt(1_000, spec, { pickId: 'mid' })], + ['far', itemAt(4_000, spec, { pickId: 'far' })], + ]); + layer.frame(items, 0); + await flush(); + assert.equal(created.length, POLICY.cap, 'the cap holds'); + assert.deepEqual( + created.map((m) => m.id).sort(), + ['mid', 'near'], + 'the nearest are modelled', + ); + assert.equal(created[0].scale, spec.scale, 'drawn life size'); + assert.equal(created[0].minimumPixelSize, POLICY.minimumPixelSize); + + layer.frame(items, 100); + for (const item of items.values()) + assert.equal(item.modelOwnsVisual, false, 'not ready: the icon draws'); + assert.ok( + layer.settling > 0, + 'the layer asks for frames while models settle', + ); + + for (const model of created) model.ready = true; + layer.frame(items, 200); + assert.equal(items.get('near').modelOwnsVisual, true); + assert.equal(items.get('mid').modelOwnsVisual, true); + assert.equal(items.get('far').modelOwnsVisual, false); + assert.equal(layer.settling, 0); + + // A placed model stands on its item's point, nose along its heading. + const near = created.find((m) => m.id === 'near'); + const origin = Cesium.Matrix4.getTranslation( + near.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok( + Cesium.Cartesian3.distance(origin, items.get('near').position) < 1e-6, + ); + + // 3D off: every model goes and every icon comes back. + display.models3d = false; + layer.frame(items, 300); + assert.equal(layer.size, 0); + for (const item of items.values()) assert.equal(item.modelOwnsVisual, false); + assert.ok( + created.every((m) => m.destroyed), + 'released models are destroyed', + ); + layer.destroy(); + assert.equal(viewer.primitives.length, 0); +}); + +test('models stay off above the altitude ceiling', async () => { + const viewer = fakeViewer(POLICY.altitudeCeilingM + 1); + let calls = 0; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + calls += 1; + return fakeModel(options); + }, + }); + assert.equal(layer.active(), false); + layer.frame(new Map([['a', itemAt(0, transitModelSpec(set, {}))]]), 0); + await flush(); + assert.equal(calls, 0); + layer.destroy(); +}); + +test('a tinted model is painted with its route colour; an untinted one is not', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel(options); + created.push(model); + return model; + }, + }); + const red = Cesium.Color.RED; + const tram = transitModelSpec(set, { mode: 'tram' }); + const ship = vesselModelSpec(set, { type: '70', length: 180 }); + assert.equal(tram.tinted, true); + assert.equal(ship.tinted, false); + layer.frame( + new Map([ + ['tram', itemAt(10, tram, { pickId: 'tram', tint: red })], + ['ship', itemAt(20, ship, { pickId: 'ship', tint: red })], + ]), + 0, + ); + await flush(); + const byId = Object.fromEntries(created.map((m) => [m.id, m])); + assert.equal(byId.tram.customShader, fleetTintShader(red)); + assert.equal(byId.ship.customShader, undefined); + assert.equal( + fleetTintShader(red), + fleetTintShader(Cesium.Color.RED), + 'one shader per colour', + ); + layer.destroy(); +}); + +test('an elevated vehicle is raised by its type elevation', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ ...options, ready: true }); + model.ready = true; + created.push(model); + return model; + }, + }); + const monorail = transitModelSpec(set, { mode: 'monorail' }); + assert.equal(monorail.elevationM, set.types.transit.elevation.monorail); + const item = itemAt(10, monorail, { pickId: 'm' }); + const items = new Map([['m', item]]); + layer.frame(items, 0); + await flush(); + layer.frame(items, 100); + const origin = Cesium.Matrix4.getTranslation( + created[0].modelMatrix, + new Cesium.Cartesian3(), + ); + const lifted = Cesium.Cartographic.fromCartesian(origin).height; + const ground = Cesium.Cartographic.fromCartesian(item.position).height; + assert.ok(Math.abs(lifted - ground - monorail.elevationM) < 1e-3); + layer.destroy(); +}); + +test('a vessel stretched to its reported beam keeps its length scale on the model', () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 60 }); + assert.ok(spec.scaleXYZ[1] > spec.scaleXYZ[0], 'a wide hull for its length'); + const matrix = fleetModelMatrix( + CENTRE, + 0, + spec, + new Cesium.Matrix4(), + new Cesium.HeadingPitchRoll(), + ); + const scale = Cesium.Matrix4.getScale(matrix, new Cesium.Cartesian3()); + // Cesium's axis correction puts the beam on local X, the length on local Y. + assert.ok(Math.abs(scale.x - spec.scaleXYZ[1] / spec.scaleXYZ[0]) < 1e-9); + assert.ok(Math.abs(scale.y - 1) < 1e-9); + assert.equal(FLEET_HEADING_OFFSET_DEG, 180, 'fleet models are built nose -X'); +}); + +test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', () => { + for (const file of [ + 'src/layers/vessels/rendering.js', + 'src/layers/transit/rendering.js', + 'src/layers/rail/rendering.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /createFleetModelLayer\(/, + `${file}: draws fleet models`, + ); + assert.match( + source, + /loadFleetModelSet\(/, + `${file}: loads the set up front`, + ); + assert.match( + source, + /modelOwnsVisual/, + `${file}: hands its icon to the model`, + ); + } + for (const file of [ + 'src/app/layers/aisLiveVessels.js', + 'src/app/layers/transit.js', + 'src/app/layers/rail.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /fleet: fleetModelServices\(/, + `${file}: given the fleet service`, + ); + } + const catalog = fs.readFileSync( + new URL('../app/constructCatalog.js', import.meta.url), + 'utf8', + ); + assert.equal( + catalog.match(/displayParams: \(\) => flights\.getParams\(\)/g)?.length, + 4, + 'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle', + ); +}); diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index e44dc0ce3..bcf79fadc 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -95,6 +95,7 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.rendering.onPreRender, ); state._networkFade = -1; + parts.rendering.startModels(); parts.viewport.loadCatalog(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); @@ -127,6 +128,7 @@ export function createLifecycle({ state, services, parts, timetable }) { state._heldFeedId = null; state._feedStatus.clear(); dropAllTrains(); + parts.rendering.stopModels(); // Floor cells and track samples queued for trains now gone. parts.rendering.reset(); parts.routes.hideAll(); @@ -154,6 +156,7 @@ export function createLifecycle({ state, services, parts, timetable }) { /** Tear down the collection and release every shared registration. */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); parts.routes.destroy(); if (state._markers) { unregisterSpriteCollection('rail', state._markers); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index eff1ace98..15d2bb742 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -26,6 +26,22 @@ import { UNSELECTED_TRAIN_ALPHA, } from './policy.js'; import { placeOnPath } from './pathBinding.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; + +/** How often the trains near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; +/** Each category's colour, painted where a train model marks it. */ +const CATEGORY_TINTS = Object.fromEntries( + Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [ + category, + Cesium.Color.fromCssColorString(css), + ]), +); const SPRITE_PX = 64; /** Billboard tints; the setter copies, so sharing one instance is safe. */ @@ -507,6 +523,11 @@ export function createRendering({ state, services, parts }) { ); } + /** Whether a train's icon is drawn: shown, and not drawn by its model. */ + function markerShown(entry) { + return categoryShown(entry) && !entry.modelOwnsVisual; + } + /** * A train bound to its railway line is drawn on the line, at the arc its * playback has reached; any other train (or a bound train whose reports do @@ -536,7 +557,7 @@ export function createRendering({ state, services, parts }) { undefined, scratchPosition, ); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** While a train is selected, every other train is drawn see-through. */ @@ -554,7 +575,7 @@ export function createRendering({ state, services, parts }) { entry.marker.width = size; entry.marker.height = size; entry.marker.color = tintFor(entry); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** Re-tint every train after the selection appeared, moved or cleared. */ @@ -658,7 +679,7 @@ export function createRendering({ state, services, parts }) { function cullSelected() { const entry = state._trains.get(state._selectedKey); if (!entry?.marker) return; - const shown = categoryShown(entry) && facesCamera(entry.marker.position); + const shown = markerShown(entry) && facesCamera(entry.marker.position); if (entry.marker.show !== shown) entry.marker.show = shown; parts.routes.cullStops(facesCamera); } @@ -666,6 +687,121 @@ export function createRendering({ state, services, parts }) { function onPreRender() { advanceMoving(); if (state._enabled && state._selectedKey !== null) cullSelected(); + if (state._enabled) updateModels(); + } + + // Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as + // the project's procedural fleet models, chosen by category and mode, + // painted in their category colour where the model marks it, on the + // aircraft layers' icon-to-model rules (services.fleet). The fleet set + // loads once, up front, when the layer starts. + let fleetModels = null; + let modelItems = new Map(); + let modelCandidatesAtMs = -Infinity; + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; trains keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand a train's visual back to its icon. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) entry.marker.show = markerShown(entry); + } + + function modelItem(entry, set) { + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) + item = { entry, pickId: entry.key, modelOwnsVisual: false }; + const specKey = `${entry.category}|${entry.railMode}`; + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = transitModelSpec(set, { + railCategory: entry.category, + railMode: entry.railMode, + }); + item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + item.show = categoryShown(entry); + return item; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._trains.size === 0) { + if (modelItems.size) { + fleetModels.frame(new Map(), performance.now()); + for (const item of modelItems.values()) handBack(item.entry); + modelItems = new Map(); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + const next = new Map(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + const camera = state._viewer?.camera; + const policy = services.fleet.policy; + const keepM = + services.fleet.display?.()?.models3dMode === 'all' + ? policy.keepAllM + : policy.keepM; + const limitSq = keepM * keepM * 1.44; + if (camera?.positionWC) + for (const entry of state._trains.values()) { + if (!entry.marker || !categoryShown(entry)) continue; + if ( + Cesium.Cartesian3.distanceSquared( + camera.positionWC, + entry.marker.position, + ) > limitSq + ) + continue; + next.set(entry.key, modelItem(entry, set)); + } + } else { + for (const [key, item] of modelItems) + if (state._trains.get(key) === item.entry && item.entry.marker) + next.set(key, modelItem(item.entry, set)); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + modelItems = next; + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('rail-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems = new Map(); + modelCandidatesAtMs = -Infinity; + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); } function advanceMoving() { @@ -697,7 +833,7 @@ export function createRendering({ state, services, parts }) { function applyCategoryVisibility() { for (const entry of state._trains.values()) { if (!entry.marker) continue; - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); schedulePlayback(entry); } syncRenderHold(); @@ -720,5 +856,7 @@ export function createRendering({ state, services, parts }) { applyCategoryVisibility, categoryShown, reset, + startModels, + stopModels, }; } diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 90210063a..2b24f51de 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -195,6 +195,7 @@ export function createLifecycle({ state, services, parts }) { parts.rendering.onPreRender, ); } + parts.rendering.startModels(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); }, @@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) { destroyTrack(entry.track); } state._vehicles.clear(); + parts.rendering.stopModels(); parts.height.clear(); state._markers?.removeAll(); state._animatedMarkers?.removeAll(); @@ -282,6 +284,7 @@ export function createLifecycle({ state, services, parts }) { */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); if (state._markers) { unregisterSpriteCollection('transit', state._markers); viewer?.scene?.primitives?.remove(state._markers); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 664b1688d..7ba55c47f 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -27,6 +27,12 @@ import { TRANSIT_MODE_COLORS, vehicleNearView, } from './policy.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; export const MODE_CESIUM_COLORS = Object.fromEntries( Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [ @@ -100,7 +106,8 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; - entry.marker.show = !!known && vehicleInView(entry); + entry.marker.show = + !!known && vehicleInView(entry) && !entry.modelOwnsVisual; if (known) { entry.marker.position = state._scratchCartesian; entry.hasRendered = true; @@ -403,6 +410,7 @@ export function createRendering({ state, services, parts }) { const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); + updateModels(); syncRenderHold(); return; } @@ -450,9 +458,97 @@ export function createRendering({ state, services, parts }) { : 0; } if (state._heightDirty.size) governorRequestRender('transit-height-batch'); + updateModels(); syncRenderHold(); } + // Under the DISPLAY rail's 3D toggle, vehicles in view near the camera are + // drawn as the project's procedural fleet models, chosen by mode, painted + // in their mode colour where the model marks it, on the aircraft layers' + // icon-to-model rules (services.fleet). The fleet set loads once, up front, + // when the layer starts. + let fleetModels = null; + let modelItems = new Map(); + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vehicles keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand an entry's visual back to its icon. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) + entry.marker.show = + vehicleInView(entry) && + !entry.heightPending && + entry.surfaceReady !== false; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._vehicles.size === 0) { + if (modelItems.size) { + fleetModels.frame(new Map(), performance.now()); + for (const item of modelItems.values()) handBack(item.entry); + modelItems = new Map(); + } + return; + } + const set = fleetModelSet(); + const next = new Map(); + for (const entry of state._visible) { + if (!entry.marker || !entry.hasRendered) continue; + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) { + item = { entry, pickId: entry.key, modelOwnsVisual: false }; + } + if (item.mode !== entry.mode) { + item.mode = entry.mode; + item.spec = transitModelSpec(set, { mode: entry.mode }); + item.tint = + MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + item.show = !entry.heightPending && entry.surfaceReady !== false; + next.set(entry.key, item); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + modelItems = next; + fleetModels.frame(modelItems, performance.now()); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('transit-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems = new Map(); + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); + } + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); const sphere = new Cesium.BoundingSphere(undefined, 5); const cameraPosition = new Cesium.Cartesian3(); @@ -556,7 +652,10 @@ export function createRendering({ state, services, parts }) { } } entry.marker.show = - visible && !entry.heightPending && entry.surfaceReady !== false; + visible && + !entry.heightPending && + entry.surfaceReady !== false && + !entry.modelOwnsVisual; visibility.heightPending = !!entry.heightPending; visibility.surfaceReady = entry.surfaceReady ?? null; schedulePlayback(entry); @@ -621,5 +720,7 @@ export function createRendering({ state, services, parts }) { setStylePreset, onPreRender, syncRenderHold, + startModels, + stopModels, }; } diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 735f32daa..c291c8e35 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -213,6 +213,8 @@ export function createIngestion({ imo: record.imo, type: record.type, destination: record.destination, + length: record.lengthM ?? null, + beam: record.beamM ?? null, speed: record.speedMps == null ? null : record.speedMps / 0.514444, course: record.courseDeg, heading: record.headingDeg, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 4c457c198..fd7b6becd 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -248,6 +248,7 @@ export function createLifecycle({ if (state.preRenderRemover) { state.preRenderRemover(); } + components.rendering.destroyModels(); resetState(); }, }; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index 17296cc62..7b70a3105 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -25,6 +25,9 @@ export function normalizeVessel(row) { imo: String(row.imo || ''), type: String(row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres), null when unreported. + length: positiveNumber(row.length), + beam: positiveNumber(row.beam), speed: finiteNumber(row.speed), course: finiteNumber(row.course), heading: finiteNumber(row.heading), @@ -46,6 +49,11 @@ function stampFix(record, receivedAtMs, feedLatestEpoch) { record.fixFeedEpoch = feedLatestEpoch; } +function positiveNumber(value) { + const number = finiteNumber(value); + return number !== null && number > 0 ? number : null; +} + function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -147,6 +155,8 @@ export class VesselRecords { record.imo = next.imo; record.type = next.type; record.destination = next.destination; + record.length = next.length; + record.beam = next.beam; record.speed = next.speed; record.course = next.course; record.heading = next.heading; diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 7a6aa6739..a5c27d637 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -22,10 +22,19 @@ import { followsClock, } from './policy.js'; import { vesselPositionAt } from './motion.js'; +import { + fleetModelSet, + loadFleetModelSet, + vesselModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; /** How often vessels move on as the world clock plays (wall ms). */ const FOLLOW_POSITION_MS = 250; +/** How often the vessels near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; + export function createRendering({ vesselState, services, @@ -56,6 +65,12 @@ export function createRendering({ VESSEL_LIFT_M, ); visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM); + // A ship model's origin is its waterline: the sea surface, unlifted. + visual.waterline = Cesium.Cartesian3.fromDegrees( + lon, + lat, + heightM - VESSEL_LIFT_M, + ); visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0); visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( visual.position, @@ -101,6 +116,7 @@ export function createRendering({ } function ensureCollections(viewer) { + ensureModels(viewer); if (!viewer || state.billboardCollection) return; state.billboardCollection = new Cesium.BillboardCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT, @@ -203,6 +219,126 @@ export function createRendering({ return icon; } + // Under the DISPLAY rail's 3D toggle, vessels near the camera are drawn as + // the project's procedural fleet models at their reported length and beam, + // on the aircraft layers' icon-to-model rules (services.fleet). The fleet + // set loads once, up front, when the layer starts. + let fleetModels = null; + let modelItems = new Map(); + let modelCandidatesAtMs = -Infinity; + + function ensureModels(viewer) { + const fleet = services.fleet; + if (!viewer || fleetModels || !fleet?.policy) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vessels keep their icons */ + }); + fleetModels = createFleetModelLayer({ + viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** The model item for `record`, its spec rebuilt only when its type or size changes. */ + function modelItem(record, set) { + let item = modelItems.get(record.id); + const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`; + if (!item) { + item = { pickId: record, tint: null, modelOwnsVisual: false }; + modelItems.set(record.id, item); + } + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = vesselModelSpec(set, record); + } + const visual = getVisual(record); + item.record = record; + item.position = visual.waterline; + item.headingDeg = vesselCourseDeg(record); + item.show = record.id !== state.cockpitSubjectId; + return item; + } + + /** Gather the vessels within the models' keep radius of the camera. */ + function gatherModelItems(set, prefs) { + const camera = state.viewer?.camera; + const policy = services.fleet.policy; + const keepM = + prefs?.models3dMode === 'all' ? policy.keepAllM : policy.keepM; + const next = new Map(); + if (camera?.positionWC) { + const limitSq = keepM * keepM * 1.44; + for (const record of state.records.all) { + const visual = getVisual(record); + if (!visual.waterline) continue; + if ( + Cesium.Cartesian3.distanceSquared( + camera.positionWC, + visual.waterline, + ) > limitSq + ) + continue; + next.set(record.id, modelItem(record, set)); + } + } + for (const [id, item] of modelItems) + if (!next.has(id)) handBackVisual(item); + modelItems = next; + } + + /** Give a vessel's icon back when its model no longer draws it. */ + function handBackVisual(item) { + if (!item.ownedBillboard) return; + item.ownedBillboard = false; + const billboard = getVisual(item.record).billboard; + if (billboard) + billboard.show = + isVisible(getVisual(item.record).surfacePosition, makeOccluder()) && + item.record.id !== state.cockpitSubjectId; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active()) { + if (modelItems.size) { + fleetModels.frame(modelItems, performance.now()); + for (const item of modelItems.values()) handBackVisual(item); + modelItems = new Map(); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + gatherModelItems(set, services.fleet.display?.()); + } else { + for (const [id, item] of modelItems) { + if (state.records.byId.get(id) === item.record) + modelItem(item.record, set); + else modelItems.delete(id); + } + } + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const billboard = getVisual(item.record).billboard; + if (item.modelOwnsVisual) { + item.ownedBillboard = true; + if (billboard) billboard.show = false; + } else handBackVisual(item); + } + if (fleetModels.settling) governorRequestRender('ais-vessels-models'); + } + + function destroyModels() { + fleetModels?.destroy(); + fleetModels = null; + modelItems = new Map(); + modelCandidatesAtMs = -Infinity; + } + // Vessels move only when a poll lands, so the layer does not hold the // continuous render loop. Its passes run in preRender, which idle mode fires // only for rendered frames: a forced pass (new data, selection) requests its @@ -216,6 +352,7 @@ export function createRendering({ state.preRenderRemover = viewer.scene.preRender.addEventListener(() => { if (followsClock(vesselState)) followClockPositions(); updateVisibility(); + if (state.feed.enabled) updateModels(); }); } @@ -307,7 +444,9 @@ export function createRendering({ if (visual.billboard) { // The bridge camera sits on this sprite; showing it would fill the view. visual.billboard.show = - visible && record.id !== state.cockpitSubjectId; + visible && + record.id !== state.cockpitSubjectId && + !modelItems.get(record.id)?.ownedBillboard; if (visible && doRotations && scene) { const rot = screenProjectedRotation( scene, @@ -582,6 +721,12 @@ export function createRendering({ if (state.billboardCollection) { state.billboardCollection.show = show; } + fleetModels?.setVisible(show); + if (!show && fleetModels) { + fleetModels.clear(); + for (const item of modelItems.values()) item.ownedBillboard = false; + modelItems = new Map(); + } vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); } return { @@ -607,5 +752,7 @@ export function createRendering({ labelPriority, hasUsefulName, setVisible, + updateModels, + destroyModels, }; } diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js index 366ef2beb..3ea9c113f 100644 --- a/src/sources/live/vessels.js +++ b/src/sources/live/vessels.js @@ -10,6 +10,12 @@ function vesselMmsi(row) { return MMSI_PATTERN.test(text) ? text : ''; } +/** A positive measurement, else null. */ +function positive(value) { + const number = finite(value); + return number !== null && number > 0 ? number : null; +} + /** A row's stable id when its source names none: its MMSI or identifier. */ function defaultVesselId(row) { return String(row?.mmsi || row?.input_identifier || ''); @@ -39,6 +45,9 @@ export function normalizeVesselObservation(row, reference = null, id = null) { imo: String(row.imo || ''), type: String(row.type_specific || row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres) when the source reports them. + lengthM: positive(row.length), + beamM: positive(row.beam), speedMps: speedKts == null ? null : speedKts * 0.514444, courseDeg: finite(row.course), headingDeg: finite(row.heading),