mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
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))}...`;
|
||
}
|