mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Format extracted vessel components
This commit is contained in:
1 parent
2f4839e8f2
commit
a0b2d9f6aa
17 files changed
+2693
-2385
No files matched your search
+422
-353
@@ -1,403 +1,472 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { vesselTypeCss, vesselOverlayCohortLimit, VESSEL_OVERLAY_SOURCE_ID, applyVesselOverlayPolicy, VESSEL_CARD_FADE_DISTANCE_M } from '../../data/vesselLabels.js';
|
||||
import { cameraPoseSignature, screenProjectedRotation } from '../../data/iconOrientation.js';
|
||||
import { DEFAULT_RENDER_ROWS, DEFAULT_ACTIVE_LABELS, VISIBILITY_UPDATE_MS, FOCUS_UPDATE_MS, LABEL_GRID_PX, CARD_MIN_SEP_PX } from './policy.js';
|
||||
import {
|
||||
vesselTypeCss,
|
||||
vesselOverlayCohortLimit,
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
applyVesselOverlayPolicy,
|
||||
VESSEL_CARD_FADE_DISTANCE_M,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import {
|
||||
cameraPoseSignature,
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import {
|
||||
DEFAULT_RENDER_ROWS,
|
||||
DEFAULT_ACTIVE_LABELS,
|
||||
VISIBILITY_UPDATE_MS,
|
||||
FOCUS_UPDATE_MS,
|
||||
LABEL_GRID_PX,
|
||||
CARD_MIN_SEP_PX,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({ vesselState, services, parts: components, layer, options }) {
|
||||
const { state } = vesselState;
|
||||
const { registerSpriteCollection } = services.sprites;
|
||||
const { forgetSpriteFocus, focusNowMs, getFocusTarget, focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite } = services.focus;
|
||||
export function createRendering({
|
||||
vesselState,
|
||||
services,
|
||||
parts: components,
|
||||
layer,
|
||||
options,
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { registerSpriteCollection } = services.sprites;
|
||||
const {
|
||||
forgetSpriteFocus,
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
focusPassIsNeeded,
|
||||
advanceSpriteFocus,
|
||||
focusAlphaNeedsWrite,
|
||||
} = services.focus;
|
||||
|
||||
|
||||
|
||||
function renderRowLimit() {
|
||||
const configured = Number(options.maxRows);
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
return Math.max(500, Math.min(50000, Math.round(configured)));
|
||||
function renderRowLimit() {
|
||||
const configured = Number(options.maxRows);
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
return Math.max(500, Math.min(50000, Math.round(configured)));
|
||||
}
|
||||
return DEFAULT_RENDER_ROWS;
|
||||
}
|
||||
return DEFAULT_RENDER_ROWS;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function labelRowLimit() {
|
||||
const configured = Number(options.maxLabels);
|
||||
if (Number.isFinite(configured) && configured >= 0) {
|
||||
return Math.max(0, Math.min(renderRowLimit(), Math.round(configured)));
|
||||
function labelRowLimit() {
|
||||
const configured = Number(options.maxLabels);
|
||||
if (Number.isFinite(configured) && configured >= 0) {
|
||||
return Math.max(0, Math.min(renderRowLimit(), Math.round(configured)));
|
||||
}
|
||||
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
|
||||
}
|
||||
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ensureCollections(viewer) {
|
||||
if (!viewer || state.billboardCollection) return;
|
||||
state.billboardCollection = new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
state.billboardCollection.show = state.enabled;
|
||||
viewer.scene.primitives.add(state.billboardCollection);
|
||||
registerSpriteCollection('ais', state.billboardCollection);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create the billboard primitive for a freshly added vessel record. Map labels
|
||||
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
||||
* per-record label primitive exists anymore.
|
||||
* @param {Object} record - Normalized vessel record.
|
||||
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
|
||||
*/
|
||||
|
||||
function addRecordPrimitives(record, occluder) {
|
||||
const visible = state.enabled && isVisible(record.surfacePosition, occluder);
|
||||
record.billboard = state.billboardCollection.add({
|
||||
position: record.position,
|
||||
show: visible,
|
||||
image: shipIcon(record, false),
|
||||
scale: shipScale(record),
|
||||
// Screen-projected rotation lands on the next visibility/rotation pass.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
// Locked height-datum principle #2: contacts are ALWAYS visible —
|
||||
// depth-test-free sprites; the EllipsoidalOccluder handles the far side.
|
||||
// (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
|
||||
// the geoid still clips out behind local tide/mesh noise.)
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
id: record,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a record's billboard primitive from its collection.
|
||||
* @param {Object} record - Vessel record to tear down.
|
||||
*/
|
||||
|
||||
function removeRecordPrimitives(record) {
|
||||
if (!record) return;
|
||||
if (record.billboard && state.billboardCollection) {
|
||||
forgetSpriteFocus(record.billboard);
|
||||
state.billboardCollection.remove(record.billboard);
|
||||
function ensureCollections(viewer) {
|
||||
if (!viewer || state.billboardCollection) return;
|
||||
state.billboardCollection = new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
state.billboardCollection.show = state.enabled;
|
||||
viewer.scene.primitives.add(state.billboardCollection);
|
||||
registerSpriteCollection('ais', state.billboardCollection);
|
||||
}
|
||||
record.billboard = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the billboard primitive for a freshly added vessel record. Map labels
|
||||
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
||||
* per-record label primitive exists anymore.
|
||||
* @param {Object} record - Normalized vessel record.
|
||||
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
|
||||
*/
|
||||
|
||||
function addRecordPrimitives(record, occluder) {
|
||||
const visible =
|
||||
state.enabled && isVisible(record.surfacePosition, occluder);
|
||||
record.billboard = state.billboardCollection.add({
|
||||
position: record.position,
|
||||
show: visible,
|
||||
image: shipIcon(record, false),
|
||||
scale: shipScale(record),
|
||||
// Screen-projected rotation lands on the next visibility/rotation pass.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
// Locked height-datum principle #2: contacts are ALWAYS visible —
|
||||
// depth-test-free sprites; the EllipsoidalOccluder handles the far side.
|
||||
// (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
|
||||
// the geoid still clips out behind local tide/mesh noise.)
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
id: record,
|
||||
});
|
||||
}
|
||||
|
||||
function shipScale(record) {
|
||||
const speed = Number(record.speed || 0);
|
||||
if (speed >= 18) return 0.78;
|
||||
if (speed >= 8) return 0.68;
|
||||
return 0.6;
|
||||
}
|
||||
/**
|
||||
* Remove a record's billboard primitive from its collection.
|
||||
* @param {Object} record - Vessel record to tear down.
|
||||
*/
|
||||
|
||||
function removeRecordPrimitives(record) {
|
||||
if (!record) return;
|
||||
if (record.billboard && state.billboardCollection) {
|
||||
forgetSpriteFocus(record.billboard);
|
||||
state.billboardCollection.remove(record.billboard);
|
||||
}
|
||||
record.billboard = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best available real-world direction of travel for a vessel, degrees
|
||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||
* Screen rotation is computed from this by the shared projected-rotation
|
||||
* pass in updateVisibility — never directly from the compass value.
|
||||
* @param {Object} record - Vessel record.
|
||||
* @returns {number} Course in degrees (0 when unknown).
|
||||
*/
|
||||
function shipScale(record) {
|
||||
const speed = Number(record.speed || 0);
|
||||
if (speed >= 18) return 0.78;
|
||||
if (speed >= 8) return 0.68;
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
function vesselCourseDeg(record) {
|
||||
const direction = record.heading ?? record.course;
|
||||
return Number.isFinite(direction) ? direction : 0;
|
||||
}
|
||||
/**
|
||||
* Best available real-world direction of travel for a vessel, degrees
|
||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||
* Screen rotation is computed from this by the shared projected-rotation
|
||||
* pass in updateVisibility — never directly from the compass value.
|
||||
* @param {Object} record - Vessel record.
|
||||
* @returns {number} Course in degrees (0 when unknown).
|
||||
*/
|
||||
|
||||
function vesselCourseDeg(record) {
|
||||
const direction = record.heading ?? record.course;
|
||||
return Number.isFinite(direction) ? direction : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
||||
* The shape points north (up) so billboard rotation maps directly to heading.
|
||||
* One icon is generated per color+variant and reused across all billboards.
|
||||
* @param {Object} record - Vessel record (drives per-type tint).
|
||||
* @param {boolean} selected - True for the white/brighter selected variant.
|
||||
* @returns {string} SVG data URL.
|
||||
*/
|
||||
/**
|
||||
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
||||
* The shape points north (up) so billboard rotation maps directly to heading.
|
||||
* One icon is generated per color+variant and reused across all billboards.
|
||||
* @param {Object} record - Vessel record (drives per-type tint).
|
||||
* @param {boolean} selected - True for the white/brighter selected variant.
|
||||
* @returns {string} SVG data URL.
|
||||
*/
|
||||
|
||||
function shipIcon(record, selected) {
|
||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
||||
if (vesselState.shipIconCache.has(key)) return vesselState.shipIconCache.get(key);
|
||||
function shipIcon(record, selected) {
|
||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
||||
if (vesselState.shipIconCache.has(key))
|
||||
return vesselState.shipIconCache.get(key);
|
||||
|
||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||
const strokeWidth = selected ? 1.1 : 0.7;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||
const strokeWidth = selected ? 1.1 : 0.7;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<g transform="translate(16,16)">
|
||||
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
vesselState.shipIconCache.set(key, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => updateVisibility());
|
||||
}
|
||||
|
||||
|
||||
|
||||
function updateVisibility(force = false) {
|
||||
if (!state.enabled) return;
|
||||
const now = focusNowMs(performance.now());
|
||||
const focusTarget = getFocusTarget();
|
||||
const regularPass = force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
|
||||
const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount)
|
||||
&& (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||
if (!regularPass && !focusPass) return;
|
||||
if (regularPass) state.lastVisibilityUpdate = now;
|
||||
if (focusPass) state.lastFocusUpdate = now;
|
||||
if (!state.vesselRecords.length) {
|
||||
// No records — flush any lingering card entries (vanished-feed case).
|
||||
if (regularPass) updateClusteredLabels([]);
|
||||
if (focusPass) state.activeFocusCount = 0;
|
||||
return;
|
||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
vesselState.shipIconCache.set(key, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
const scene = state.viewer?.scene;
|
||||
const camera = state.viewer?.camera;
|
||||
if (regularPass) {
|
||||
// Candidate construction stays on the original 800 ms selector cadence.
|
||||
// The 80 ms focus-only pass below never allocates label candidates.
|
||||
const poseSig = camera ? cameraPoseSignature(camera) : '';
|
||||
const doRotations = force || poseSig !== vesselState._lastCamPoseSig;
|
||||
if (doRotations) vesselState._lastCamPoseSig = poseSig;
|
||||
const occluder = makeOccluder();
|
||||
const labelCandidates = [];
|
||||
for (const record of state.vesselRecords) {
|
||||
const visible = isVisible(record.surfacePosition, occluder);
|
||||
if (record.billboard) {
|
||||
record.billboard.show = visible;
|
||||
if (visible && doRotations && scene) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene, record.position, vesselCourseDeg(record), record.billboard.rotation
|
||||
);
|
||||
if (rot !== null && Math.abs(rot - record.billboard.rotation) > 0.002) {
|
||||
record.billboard.rotation = rot;
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
updateVisibility(),
|
||||
);
|
||||
}
|
||||
|
||||
function updateVisibility(force = false) {
|
||||
if (!state.enabled) return;
|
||||
const now = focusNowMs(performance.now());
|
||||
const focusTarget = getFocusTarget();
|
||||
const regularPass =
|
||||
force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
|
||||
const focusPass =
|
||||
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
|
||||
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||
if (!regularPass && !focusPass) return;
|
||||
if (regularPass) state.lastVisibilityUpdate = now;
|
||||
if (focusPass) state.lastFocusUpdate = now;
|
||||
if (!state.vesselRecords.length) {
|
||||
// No records — flush any lingering card entries (vanished-feed case).
|
||||
if (regularPass) updateClusteredLabels([]);
|
||||
if (focusPass) state.activeFocusCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = state.viewer?.scene;
|
||||
const camera = state.viewer?.camera;
|
||||
if (regularPass) {
|
||||
// Candidate construction stays on the original 800 ms selector cadence.
|
||||
// The 80 ms focus-only pass below never allocates label candidates.
|
||||
const poseSig = camera ? cameraPoseSignature(camera) : '';
|
||||
const doRotations = force || poseSig !== vesselState._lastCamPoseSig;
|
||||
if (doRotations) vesselState._lastCamPoseSig = poseSig;
|
||||
const occluder = makeOccluder();
|
||||
const labelCandidates = [];
|
||||
for (const record of state.vesselRecords) {
|
||||
const visible = isVisible(record.surfacePosition, occluder);
|
||||
if (record.billboard) {
|
||||
record.billboard.show = visible;
|
||||
if (visible && doRotations && scene) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene,
|
||||
record.position,
|
||||
vesselCourseDeg(record),
|
||||
record.billboard.rotation,
|
||||
);
|
||||
if (
|
||||
rot !== null &&
|
||||
Math.abs(rot - record.billboard.rotation) > 0.002
|
||||
) {
|
||||
record.billboard.rotation = rot;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (visible) labelCandidates.push(record);
|
||||
}
|
||||
if (visible) labelCandidates.push(record);
|
||||
updateClusteredLabels(labelCandidates);
|
||||
}
|
||||
updateClusteredLabels(labelCandidates);
|
||||
}
|
||||
if (focusPass && scene && camera) {
|
||||
const result = applyVesselFocusDeemphasis({
|
||||
records: state.vesselRecords,
|
||||
target: focusTarget,
|
||||
previousActiveCount: state.activeFocusCount,
|
||||
nowMs: now,
|
||||
screenPositionFor: (position) => (
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, vesselState._scratchFocusScreen)
|
||||
),
|
||||
cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
});
|
||||
state.activeFocusCount = result.activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
|
||||
* animation/deadband contract can be tested without constructing WebGL.
|
||||
* @param {object} input
|
||||
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
|
||||
*/
|
||||
|
||||
function applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
previousActiveCount = 0,
|
||||
nowMs,
|
||||
screenPositionFor,
|
||||
cameraDistanceFor,
|
||||
params,
|
||||
}) {
|
||||
if (!focusPassIsNeeded(target, previousActiveCount)) {
|
||||
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
|
||||
}
|
||||
let writes = 0;
|
||||
let transitioning = false;
|
||||
let activeCount = 0;
|
||||
for (const record of records || []) {
|
||||
const bb = record?.billboard;
|
||||
const position = bb?.position || record?.position;
|
||||
if (!bb || !position) continue;
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Hidden/far-side sprites still finish any pending release so the active
|
||||
// count remains truthful and they cannot reappear with stale dim alpha.
|
||||
screenPosition: bb.show === false ? null : screenPositionFor(position),
|
||||
cameraDistance: cameraDistanceFor(position),
|
||||
nowMs,
|
||||
target,
|
||||
params,
|
||||
// Vessel artwork is 32 px before billboard scale. Including the
|
||||
// ambient chevron's own rendered extent prevents edge-overlap misses.
|
||||
spriteHalfWidthPx: (bb.width || 32) * (bb.scale || 1) * 0.5,
|
||||
spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5,
|
||||
});
|
||||
transitioning ||= focus.transitioning;
|
||||
if (focus.active) activeCount += 1;
|
||||
if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) {
|
||||
// Narrow always-visible amendment: the ship chevron remains present at
|
||||
// the non-zero floor while it competes with the tracked target. Preserve
|
||||
// the billboard's existing base RGB, matching the other layer patterns,
|
||||
// rather than repainting every chevron from a hard-coded WHITE base.
|
||||
const baseColor = bb.color || Cesium.Color.WHITE;
|
||||
bb.color = baseColor.withAlpha(focus.factor);
|
||||
writes += 1;
|
||||
if (focusPass && scene && camera) {
|
||||
const result = applyVesselFocusDeemphasis({
|
||||
records: state.vesselRecords,
|
||||
target: focusTarget,
|
||||
previousActiveCount: state.activeFocusCount,
|
||||
nowMs: now,
|
||||
screenPositionFor: (position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
vesselState._scratchFocusScreen,
|
||||
),
|
||||
cameraDistanceFor: (position) =>
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
});
|
||||
state.activeFocusCount = result.activeCount;
|
||||
}
|
||||
}
|
||||
return { writes, transitioning, activeCount, ran: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
|
||||
* animation/deadband contract can be tested without constructing WebGL.
|
||||
* @param {object} input
|
||||
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
|
||||
*/
|
||||
|
||||
function applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
previousActiveCount = 0,
|
||||
nowMs,
|
||||
screenPositionFor,
|
||||
cameraDistanceFor,
|
||||
params,
|
||||
}) {
|
||||
if (!focusPassIsNeeded(target, previousActiveCount)) {
|
||||
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
|
||||
}
|
||||
let writes = 0;
|
||||
let transitioning = false;
|
||||
let activeCount = 0;
|
||||
for (const record of records || []) {
|
||||
const bb = record?.billboard;
|
||||
const position = bb?.position || record?.position;
|
||||
if (!bb || !position) continue;
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Hidden/far-side sprites still finish any pending release so the active
|
||||
// count remains truthful and they cannot reappear with stale dim alpha.
|
||||
screenPosition: bb.show === false ? null : screenPositionFor(position),
|
||||
cameraDistance: cameraDistanceFor(position),
|
||||
nowMs,
|
||||
target,
|
||||
params,
|
||||
// Vessel artwork is 32 px before billboard scale. Including the
|
||||
// ambient chevron's own rendered extent prevents edge-overlap misses.
|
||||
spriteHalfWidthPx: (bb.width || 32) * (bb.scale || 1) * 0.5,
|
||||
spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5,
|
||||
});
|
||||
transitioning ||= focus.transitioning;
|
||||
if (focus.active) activeCount += 1;
|
||||
if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) {
|
||||
// Narrow always-visible amendment: the ship chevron remains present at
|
||||
// the non-zero floor while it competes with the tracked target. Preserve
|
||||
// the billboard's existing base RGB, matching the other layer patterns,
|
||||
// rather than repainting every chevron from a hard-coded WHITE base.
|
||||
const baseColor = bb.color || Cesium.Color.WHITE;
|
||||
bb.color = baseColor.withAlpha(focus.factor);
|
||||
writes += 1;
|
||||
}
|
||||
}
|
||||
return { writes, transitioning, activeCount, ran: true };
|
||||
}
|
||||
|
||||
function makeOccluder() {
|
||||
const cameraPosition = state.viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPosition);
|
||||
}
|
||||
function makeOccluder() {
|
||||
const cameraPosition = state.viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
return new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
cameraPosition,
|
||||
);
|
||||
}
|
||||
|
||||
function isVisible(surfacePosition, occluder) {
|
||||
if (!surfacePosition || !occluder) return true;
|
||||
return occluder.isPointVisible(surfacePosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source selector for the shared world-overlay pipeline: the
|
||||
* grid declutter picks which vessels get cards — one winner per screen-space
|
||||
* grid cell, priority-ranked, capped — and publishes presentation entries to
|
||||
* the host, which owns projection, final placement, fade and paint. Runs on the
|
||||
* throttled visibility pass and forced refreshes, never per frame. The
|
||||
* selected vessel always gets its full-detail card, even when horizon-culled
|
||||
* from the ambient candidates; its protected entry bypasses ambient quotas.
|
||||
* @param {Array<Object>} records - Horizon-visible vessel records.
|
||||
*/
|
||||
|
||||
function isVisible(surfacePosition, occluder) {
|
||||
if (!surfacePosition || !occluder) return true;
|
||||
return occluder.isPointVisible(surfacePosition);
|
||||
}
|
||||
function updateClusteredLabels(records) {
|
||||
const viewer = state.viewer;
|
||||
const scene = viewer?.scene;
|
||||
const selected = state.selectedRecord;
|
||||
const entries = selected
|
||||
? [components.cards.buildSelectedVesselCard(selected)]
|
||||
: [];
|
||||
const maxLabels = labelRowLimit();
|
||||
|
||||
if (!scene || !records.length || maxLabels <= 0) {
|
||||
state.activeLabelCount = entries.length;
|
||||
publishVesselOverlayEntries(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source selector for the shared world-overlay pipeline: the
|
||||
* grid declutter picks which vessels get cards — one winner per screen-space
|
||||
* grid cell, priority-ranked, capped — and publishes presentation entries to
|
||||
* the host, which owns projection, final placement, fade and paint. Runs on the
|
||||
* throttled visibility pass and forced refreshes, never per frame. The
|
||||
* selected vessel always gets its full-detail card, even when horizon-culled
|
||||
* from the ambient candidates; its protected entry bypasses ambient quotas.
|
||||
* @param {Array<Object>} records - Horizon-visible vessel records.
|
||||
*/
|
||||
const cells = new Map();
|
||||
for (const record of records) {
|
||||
if (record === selected) continue;
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
record.position,
|
||||
);
|
||||
if (!screen) continue;
|
||||
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
||||
const candidate = {
|
||||
record,
|
||||
score: labelPriority(record, selected),
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
};
|
||||
const existing = cells.get(key);
|
||||
if (!existing || candidate.score > existing.score) {
|
||||
cells.set(key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function updateClusteredLabels(records) {
|
||||
const viewer = state.viewer;
|
||||
const scene = viewer?.scene;
|
||||
const selected = state.selectedRecord;
|
||||
const entries = selected ? [components.cards.buildSelectedVesselCard(selected)] : [];
|
||||
const maxLabels = labelRowLimit();
|
||||
|
||||
if (!scene || !records.length || maxLabels <= 0) {
|
||||
// Greedy min-separation pass over the priority-ranked cell winners: the
|
||||
// selected card's anchor seeds the accepted set so ambient cards keep clear.
|
||||
const accepted = [];
|
||||
if (selected) {
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
selected.billboard?.position || selected.position,
|
||||
);
|
||||
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
||||
}
|
||||
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
|
||||
for (const candidate of ranked) {
|
||||
if (entries.length >= maxLabels) break;
|
||||
if (
|
||||
!components.cards.cardScreenSeparated(
|
||||
accepted,
|
||||
candidate,
|
||||
CARD_MIN_SEP_PX,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
accepted.push({ x: candidate.x, y: candidate.y });
|
||||
entries.push(components.cards.buildVesselCard(candidate.record));
|
||||
}
|
||||
state.activeLabelCount = entries.length;
|
||||
publishVesselOverlayEntries(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = new Map();
|
||||
for (const record of records) {
|
||||
if (record === selected) continue;
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, record.position);
|
||||
if (!screen) continue;
|
||||
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
||||
const candidate = { record, score: labelPriority(record, selected), x: screen.x, y: screen.y };
|
||||
const existing = cells.get(key);
|
||||
if (!existing || candidate.score > existing.score) {
|
||||
cells.set(key, candidate);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Publish a complete, bounded source snapshot to the shared host. The source
|
||||
* selector remains authoritative for the 118 px grid and 150 px separation;
|
||||
* the host then composes this demand with sibling ambient-card sources.
|
||||
* @param {Object[]} entries Formatted vessel card entries.
|
||||
*/
|
||||
|
||||
// Greedy min-separation pass over the priority-ranked cell winners: the
|
||||
// selected card's anchor seeds the accepted set so ambient cards keep clear.
|
||||
const accepted = [];
|
||||
if (selected) {
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene, selected.billboard?.position || selected.position
|
||||
function publishVesselOverlayEntries(entries) {
|
||||
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
|
||||
const width = Number(canvas?.clientWidth) || 0;
|
||||
const height = Number(canvas?.clientHeight) || 0;
|
||||
const ambientLimit = vesselOverlayCohortLimit(
|
||||
width,
|
||||
height,
|
||||
labelRowLimit(),
|
||||
);
|
||||
vesselState._vesselOverlayHost.setEntries(
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
entries.map((entry) => {
|
||||
const card = applyVesselOverlayPolicy(
|
||||
entry,
|
||||
VESSEL_CARD_FADE_DISTANCE_M,
|
||||
);
|
||||
if (!card.interactive) return card;
|
||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
||||
? card.id.slice('vessel:'.length)
|
||||
: '';
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
|
||||
activate: () => {
|
||||
const record = state.vesselMap.get(mmsi);
|
||||
if (!record) return false;
|
||||
components.selection.selectAndFocusVessel(record);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
cohortLimit: Math.max(1, ambientLimit),
|
||||
collisionCapacity: ambientLimit,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
||||
}
|
||||
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
|
||||
for (const candidate of ranked) {
|
||||
if (entries.length >= maxLabels) break;
|
||||
if (!components.cards.cardScreenSeparated(accepted, candidate, CARD_MIN_SEP_PX)) continue;
|
||||
accepted.push({ x: candidate.x, y: candidate.y });
|
||||
entries.push(components.cards.buildVesselCard(candidate.record));
|
||||
|
||||
function labelPriority(record, selected) {
|
||||
if (record === selected) return 100000;
|
||||
let score = 0;
|
||||
if (hasUsefulName(record)) score += 1000;
|
||||
if (record.speed !== null)
|
||||
score += Math.min(400, Math.max(0, record.speed) * 20);
|
||||
if (record.heading !== null || record.course !== null) score += 80;
|
||||
if (record.type) score += 40;
|
||||
return score;
|
||||
}
|
||||
state.activeLabelCount = entries.length;
|
||||
publishVesselOverlayEntries(entries);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Publish a complete, bounded source snapshot to the shared host. The source
|
||||
* selector remains authoritative for the 118 px grid and 150 px separation;
|
||||
* the host then composes this demand with sibling ambient-card sources.
|
||||
* @param {Object[]} entries Formatted vessel card entries.
|
||||
*/
|
||||
|
||||
function publishVesselOverlayEntries(entries) {
|
||||
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
|
||||
const width = Number(canvas?.clientWidth) || 0;
|
||||
const height = Number(canvas?.clientHeight) || 0;
|
||||
const ambientLimit = vesselOverlayCohortLimit(width, height, labelRowLimit());
|
||||
vesselState._vesselOverlayHost.setEntries(
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
entries.map((entry) => {
|
||||
const card = applyVesselOverlayPolicy(entry, VESSEL_CARD_FADE_DISTANCE_M);
|
||||
if (!card.interactive) return card;
|
||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
||||
? card.id.slice('vessel:'.length)
|
||||
: '';
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
|
||||
activate: () => {
|
||||
const record = state.vesselMap.get(mmsi);
|
||||
if (!record) return false;
|
||||
components.selection.selectAndFocusVessel(record);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}),
|
||||
{
|
||||
cohortLimit: Math.max(1, ambientLimit),
|
||||
collisionCapacity: ambientLimit,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function labelPriority(record, selected) {
|
||||
if (record === selected) return 100000;
|
||||
let score = 0;
|
||||
if (hasUsefulName(record)) score += 1000;
|
||||
if (record.speed !== null) score += Math.min(400, Math.max(0, record.speed) * 20);
|
||||
if (record.heading !== null || record.course !== null) score += 80;
|
||||
if (record.type) score += 40;
|
||||
return score;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hasUsefulName(record) {
|
||||
const text = String(record.name || '').trim();
|
||||
return Boolean(text && text !== 'VESSEL' && !/^MMSI\s*\d+$/i.test(text) && text !== record.mmsi);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setVisible(show) {
|
||||
if (state.billboardCollection) {
|
||||
state.billboardCollection.show = show;
|
||||
function hasUsefulName(record) {
|
||||
const text = String(record.name || '').trim();
|
||||
return Boolean(
|
||||
text &&
|
||||
text !== 'VESSEL' &&
|
||||
!/^MMSI\s*\d+$/i.test(text) &&
|
||||
text !== record.mmsi,
|
||||
);
|
||||
}
|
||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
|
||||
}
|
||||
return { renderRowLimit, labelRowLimit, ensureCollections, addRecordPrimitives, removeRecordPrimitives, shipScale, vesselCourseDeg, shipIcon, installRuntime, updateVisibility, applyVesselFocusDeemphasis, makeOccluder, isVisible, updateClusteredLabels, publishVesselOverlayEntries, labelPriority, hasUsefulName, setVisible };
|
||||
|
||||
function setVisible(show) {
|
||||
if (state.billboardCollection) {
|
||||
state.billboardCollection.show = show;
|
||||
}
|
||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
|
||||
}
|
||||
return {
|
||||
renderRowLimit,
|
||||
labelRowLimit,
|
||||
ensureCollections,
|
||||
addRecordPrimitives,
|
||||
removeRecordPrimitives,
|
||||
shipScale,
|
||||
vesselCourseDeg,
|
||||
shipIcon,
|
||||
installRuntime,
|
||||
updateVisibility,
|
||||
applyVesselFocusDeemphasis,
|
||||
makeOccluder,
|
||||
isVisible,
|
||||
updateClusteredLabels,
|
||||
publishVesselOverlayEntries,
|
||||
labelPriority,
|
||||
hasUsefulName,
|
||||
setVisible,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user