Files
gods-eye-view/src/layers/submarineCables/overlay.js
T

310 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as Cesium from 'cesium';
import {
CABLE_REFERENCE_LABEL_WINNER_CAP,
CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
BASE_CABLE_COLOR,
BASE_LANDING_COLOR,
CABLE_OVERLAY_SOURCE_ID,
CABLE_OVERLAY_COLLISION_CAPACITY,
defaultSweepClock,
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
CABLE_SWEEP_MOTION_EPSILON_M,
STEM_TARGET_PX,
CABLE_STEM_TIP_EPSILON_SQ,
} from './policy.js';
/**
* Select the nearest visible cable references without exceeding the bounded
* cohort cap the shared host is offered.
* @param {object[]} records Records annotated with `visible` and `distanceM`.
* @param {number} [limit]
* @returns {object[]}
*/
export function selectCableReferenceLabelWinners(
records,
limit = CABLE_REFERENCE_LABEL_WINNER_CAP,
) {
const cap = Math.max(
0,
Math.min(CABLE_REFERENCE_LABEL_WINNER_CAP, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(records) || cap === 0) return [];
return records
.filter(
(record) =>
record?.visible === true &&
record.label &&
Number.isFinite(record.distanceM) &&
record.distanceM <= CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
)
.sort(
(a, b) =>
a.distanceM - b.distanceM ||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
)
.slice(0, cap);
}
/**
* Distance-derived arbiter priority. Quantized to 50 km buckets so a slowly
* moving camera does not reshuffle equal-rank labels on every sweep; nearer
* references still win collisions, matching the shipped nearest-first feel.
* @param {number} distanceM
* @returns {number}
*/
export function cableReferencePriority(distanceM) {
const distance = Number.isFinite(distanceM)
? Math.max(0, distanceM)
: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M;
return 1000 - Math.round(distance / 50000);
}
/**
* Build one shared-host ambient label for a cable or landing-point reference.
* The entry stays attached to the record's mutable stem-tip Cartesian and is
* created once per record; only `priority` is refreshed per sweep.
* `interactive: false` follows the infrastructure precedent — the depth-tested
* native point/stem/line remains the click surface, and a non-interactive
* label keeps the host's per-frame accessibility/hit sync allocation-free.
* @param {object} record Reference record ({ id, kind, label, tip }).
* @returns {object}
*/
export function createCableOverlayEntry(record) {
const kind = record?.kind === 'landing-point' ? 'landing-point' : 'cable';
return {
id: String(record?.id || ''),
position: record?.tip,
variant: 'label',
title: String(record?.label || ''),
accent: kind === 'cable' ? BASE_CABLE_COLOR : BASE_LANDING_COLOR,
priority: cableReferencePriority(record?.distanceM),
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
minDistance: 0,
maxDistance: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
distanceFadeStartRatio: 0.7,
// Former native scaleByDistance curve, unchanged: 1.0× at 250 km → 0.62×
// at 9,000 km.
distanceScale: {
near: 250000,
nearValue: 1,
far: 9000000,
farValue: 0.62,
},
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/**
* Bind the cable layer's host visibility and entry lifecycle to the shared
* world overlay (same contract as the infrastructure publisher). The layer
* supports re-init after destroy (legacy contract), so its teardown path is
* hide() — clear the published source and go invisible while staying
* reusable. There is deliberately no permanent-destroy method: a hidden
* publisher already drops late publishes until the next show().
* @param {object} [options]
* @param {string} [options.sourceId]
* @param {object} [options.host] Test seam for the three host lifecycle calls.
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void}}
*/
export function createCableOverlayPublisher({
sourceId = CABLE_OVERLAY_SOURCE_ID,
host,
} = {}) {
let visible = false;
let published = false;
const sourceOptions = {
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
moving: false,
};
return {
show() {
if (visible) return;
visible = true;
host.setVisible(sourceId, true);
},
publish(entries) {
if (!visible) return;
host.setEntries(sourceId, entries, sourceOptions);
published = entries.length > 0;
},
hide() {
if (published) host.clearSource(sourceId);
if (visible) host.setVisible(sourceId, false);
visible = false;
published = false;
},
};
}
/**
* Dirty gate for the reference sweep. TWO dirty conditions, no timer:
*
* 1. EVENT (primary): the sweep runs on the first frame after a camera
* `moveEnd`, layer enable, or load completion. The former 500 ms timer
* path re-sized stems while frames were flowing, and every re-size
* rebuilt the 2,629-instance batched translucent stem primitive
* (measured: 12 rebuilds in 6 s with the replacement primitive unready
* for 46/360 frames — the felt hitch). Stem lengths are therefore
* deliberately stale mid-drag/mid-flight and settle on release;
* reference visibility and label winners go stale the same way, and the
* shared host keeps reprojecting the published positions per frame.
* 2. MOTION FALLBACK: `moveEnd` covers drags, one-shot `setView`, and voice
* `flyTo`, but a TRACKED-entity follow camera or an orbit never emits it,
* so condition 1 alone starves those sessions forever (frozen horizon
* visibility, frozen stems, stale label winners for as long as tracking
* lasts). While frames render, the gate therefore samples the camera at
* most once per `CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS` and re-arms itself
* only when the camera has actually travelled past
* `CABLE_SWEEP_MOTION_EPSILON_M` since the last sweep.
*
* A PARKED camera still costs exactly zero sweeps: the probe compares against
* the last swept position (never the previous frame), so neither a frozen
* camera nor sub-epsilon numeric jitter ever accumulates into a sweep, and
* the per-frame cost of the fallback is one clock read plus one subtraction.
* Position is the only motion channel because it is the only camera input the
* sweep consumes — the horizon occluder, the reference distances, the stem
* sizing, and the winner ranks all derive from `positionWC` alone, so a
* heading-only rotation cannot change a single sweep output and deliberately
* does not spend one.
* @param {object} [options]
* @param {function():number} [options.now] Monotonic clock (test seam).
* @param {number} [options.probeIntervalMs]
* @param {number} [options.motionEpsilonM]
*/
export function createCableReferenceSweepGate({
now = defaultSweepClock,
probeIntervalMs = CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
motionEpsilonM = CABLE_SWEEP_MOTION_EPSILON_M,
} = {}) {
const motionEpsilonSq = motionEpsilonM ** 2;
// Preallocated: the probe clones into it, so a moving camera allocates
// nothing per frame.
const lastSweptPosition = new Cesium.Cartesian3();
let hasSweptPosition = false;
let dirty = true;
let lastProbeAt = -Infinity;
/** Arm the next probe window and adopt the camera position being swept. */
function accept(camera, time) {
lastProbeAt = time;
const position = camera?.positionWC;
hasSweptPosition = Boolean(position);
if (position) Cesium.Cartesian3.clone(position, lastSweptPosition);
return true;
}
return {
markDirty() {
dirty = true;
},
/**
* @param {Cesium.Camera} [camera] Live camera; omit to disable the motion
* fallback entirely (pure dirty-only gate).
* @returns {boolean}
*/
shouldRun(camera) {
if (dirty) {
dirty = false;
return accept(camera, now());
}
if (!camera) return false;
// Cheapest possible per-frame path: a clock read and a subtraction. The
// camera getter is only touched once the probe window has actually
// opened, and the window re-arms whether or not the probe sweeps.
const time = now();
if (time - lastProbeAt < probeIntervalMs) return false;
lastProbeAt = time;
const position = camera.positionWC;
if (!position) return false;
if (
hasSweptPosition &&
Cesium.Cartesian3.distanceSquared(position, lastSweptPosition) <=
motionEpsilonSq
) {
return false;
}
return accept(camera, time);
},
reset() {
dirty = true;
lastProbeAt = -Infinity;
hasSweptPosition = false;
},
};
}
/**
* Recompute one staticized stem on the sweep cadence. Replaces the former
* per-frame `CallbackProperty` pair: constant properties are redefined only
* when the camera-scaled tip moved beyond the settle epsilon, and the two
* preallocated position buffers alternate so each real change raises exactly
* one geometry notification (the localGeojson double-buffer pattern).
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
* @param {Cesium.Cartesian3} cameraPositionWC
* @param {number} canvasHeight CSS-pixel canvas height.
* @param {number} fov Camera frustum field of view (radians).
* @returns {boolean} True when the stem geometry was redefined.
*/
export function updateCableReferenceStem(
record,
cameraPositionWC,
canvasHeight,
fov,
) {
const distance = Cesium.Cartesian3.distance(cameraPositionWC, record.base);
const effectiveDistance = Math.max(distance, 5000);
const height = canvasHeight || 1080;
const fieldOfView = fov || Math.PI / 3;
const metersPerPixelFactor = (2 * Math.tan(fieldOfView / 2)) / height;
const tipHeight = Math.max(
700,
Math.min(85000, effectiveDistance * metersPerPixelFactor * STEM_TARGET_PX),
);
Cesium.Cartesian3.fromDegrees(
record.reference.lon,
record.reference.lat,
tipHeight,
Cesium.Ellipsoid.WGS84,
record.nextTip,
);
if (
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
CABLE_STEM_TIP_EPSILON_SQ
) {
return false;
}
Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
const stemPositions =
record.stemPositionBuffers[record.stemPositionBufferIndex];
stemPositions[0] = record.base;
stemPositions[1] = record.tip;
record.entity.position.setValue(record.tip);
record.entity.polyline.positions.setValue(stemPositions);
return true;
}
export function clampLabel(value, maxLength = 34) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
if (text.length <= maxLength) return text;
return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
}