mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor(geography): separate cable sources and bundled lookup modules
This commit is contained in:
1 parent
2213c42200
commit
3ce5d2e454
21 files changed
+2423
-1437
No files matched your search
@@ -0,0 +1,309 @@
|
||||
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))}...`;
|
||||
}
|
||||
Reference in new issue
Block a user