Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 f04308e45d docs(changelog): add the #1706 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:05:55 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 9582f2f28b perf(alpr): read anchors from the terrain globe and sample outside the paint
The overlay took up to six scene.sampleHeight depth renders inside one
paint, and kept the answers on entities, so every refresh and every
re-enable sampled the same cameras again. On a terrain globe the anchor now
comes from globe.getHeight; answers are kept per camera id until the map
source changes; and what sampling remains runs after the paint, one per
frame.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:03:18 +01:00
19 changed files with 1579 additions and 1440 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- ALPR camera badges no longer take depth samples while the overlay paints, or
again each time the layer is switched back on. On a terrain globe their
height comes from the loaded terrain and is remembered per camera; some
badges move a few metres onto the drawn surface (mazy1998, #1706).
@@ -1,3 +0,0 @@
- Shorten the freeze when Submarine cables is switched on from about 1.4 s to
about 0.2 s. Landing markers are now shown a few dozen per frame instead of
all at once (mazy1998, #1707).
@@ -1,4 +0,0 @@
- Submarine cables draw from a few batched primitives instead of thousands of
entities. Switching the layer off and back on, and zooming with it on, no
longer stall the page, and it holds about 900 MB less memory. Double-click
no longer starts Cesium's entity tracking on a cable (mazy1998, #1722).
+17 -35
View File
@@ -1513,10 +1513,14 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
badge whose cell has not resolved clamps until it does, then moves in place.
Overlay anchors start on the same floor and write marker properties only on
change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (two depth samples per paint,
accepted only within -30/+40 m of the floor cell), so badges do not float or
sink on slopes; wedge corners follow their own floor cells instead of staying
level with the apex. Unknown bearings never acquire a wedge. The selected
replaced once by the rendered surface under it (accepted only within -30/+40 m
of the floor cell), so badges do not float or sink on slopes; wedge corners
follow their own floor cells instead of staying level with the apex. The
surface is read outside the overlay paint, one camera per frame: a terrain
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
until the map source changes, so a refresh or turning the layer off and on
samples nothing again. Unknown bearings never acquire a wedge. The selected
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
of 359.5° or more reads `0°` rather than `360°`. The row reads
`N nearby · M on screen`; when every loaded camera is outside the view it adds
@@ -1541,14 +1545,12 @@ query. Movement within an accepted or pending query reuses it. Markers retain
their Cesium identity while their geometry is unchanged; refreshes preserve
selection without replaying a click event.
Data Centers and Dams release their built Cesium data sources
Data Centers, Dams and Submarine Cables release their built Cesium data sources
and record references when disabled. Parsed datasets remain cached for the layer
lifetime, so re-enable rebuilds entities without downloading or parsing again;
this can take longer than simply revealing hidden entities. Destruction clears
the parsed cache as well. A disable during loading cannot leave a completed
build hidden in the scene. Submarine Cables draws with its own primitives
instead of entities and only hides them when disabled (see "Bundled geography
and submarine cable components").
build hidden in the scene.
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
frame/media URLs, camera records, ground placement, geometry queues, playback,
@@ -8149,37 +8151,17 @@ an error. Without feeds it keeps the WebUSB statuses. See
Submarine cables use separate source, geometry, rendering, interaction and
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
collections from a source with `fetch(signal)` and a display label. The default
source loads the same bundled TeleGeography files. Load ownership prevents
cancelled work from adding anything to the scene after teardown: the fetch is
the load's only await, and everything is built in the turn after it.
source loads the same bundled TeleGeography files. Disabling still removes all
three Cesium data sources; enabling rebuilds from the accepted parsed cache,
and destroying clears it. Load ownership prevents cancelled work from adding
entities after teardown.
The layer draws with four Cesium primitives of its own and creates no entity
or data source. Every line is one instance of a single
`GroundPolylinePrimitive` (per-line colour and show flag, rhumb lines,
tessellated in a worker; a map-stack change re-classifies it in place). Every
point is a billboard in one `BillboardCollection`. Reference tips are one
`PointPrimitiveCollection`, and the stems of the references in view are one
static polyline `Primitive`: a horizon sweep that moved a tip or changed which
references are in view builds a replacement in a worker, and it replaces the
shown stems once it is ready. Disabling hides the lines, markers and tips and
keeps them, so enabling again neither fetches nor builds them (only the stems
are redrawn, for the new view); destroying removes everything. These are
the same primitives Cesium built from the layer's entities, so the layer looks
the same. Polygon geometry in a custom source is not drawn, and GeoJSON
simplestyle properties (`marker-color`, `stroke` and the like) are not read.
Every line and marker from multipart cable or landing-point geometry stays tied
to its source feature. Each part gets its own feature's styling, click
Every Cesium entity from multipart cable or landing-point geometry stays tied
to its source feature, including generated IDs and IDs that collide with
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
destination and horizon-culling reference, and each source feature has one
reference stem. The source collections are not modified.
Landing markers are ground-clamped billboards. A marker starts hidden and
unclamped; the horizon sweep clamps and shows the visible ones 48 per rendered
frame (`CABLE_LANDING_REVEALS_PER_FRAME`), so the terrain height reads are
never one task. A marker is clamped once: Cesium then keeps it on the surface
as terrain loads, and hiding or showing it again reads nothing; hiding is
immediate.
Natural Earth regions and neighborhood polygon lookup are package exports.
Their existing lazy loaders, retry behavior, bundled datasets and attribution
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
+1
View File
@@ -24,6 +24,7 @@ export {
createCableOverlayEntry,
cableClassificationTypeForStack,
cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
createCableReferenceSweepGate,
updateCableReferenceStem,
} from '../layers/submarineCables/index.js';
File diff suppressed because it is too large. Load diff
+139 -57
View File
@@ -4,10 +4,10 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
REMEMBERED_ANCHORS,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
SURFACE_SAMPLES_PER_FRAME,
} from './policy.js';
import {
MARKER_IMAGE,
@@ -19,11 +19,17 @@ import {
validAlprGroundHeight,
} from './visuals.js';
const nextAnimationFrame = (step) =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(step)
: setTimeout(step, 16);
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
requestFrame = nextAnimationFrame,
}) {
let lane,
removeMapListener,
@@ -33,7 +39,12 @@ export function createAlprOverlay({
hits = [],
images = [],
destroyed = false,
lastCamera = null;
lastCamera = null,
sampling = false;
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
// and by turning the layer off and on; keeping the answers on them meant
// every rebuild sampled the same cameras again.
const surfaceAnswers = new Map();
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
@@ -92,38 +103,72 @@ export function createAlprOverlay({
entity.gevAlprDisplayPosition = null;
nativeVisible(entity, true);
}
surfaceAnswers.clear();
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
function rememberSurface(record, height, checked) {
surfaceAnswers.delete(record.id);
surfaceAnswers.set(record.id, {
latitude: record.latitude,
longitude: record.longitude,
height,
checked,
});
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
}
/**
* Rendered surface height under a camera. A terrain globe answers from its
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
*/
function surfaceHeight(record, entity) {
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
if (scene.globe?.show) return scene.globe.getHeight?.(location);
if (!scene.sampleHeightSupported) return undefined;
try {
return scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
return undefined;
}
}
/** The anchor a paint can have without sampling, or null while it waits for one. */
function anchorFor(record, entity) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const answer = surfaceAnswers.get(record.id);
if (
answer?.latitude === record.latitude &&
answer.longitude === record.longitude
) {
setAnchor(record, entity, answer.height);
if (answer.checked)
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
return entity.gevAlprCanvasPosition;
}
// A floor-placed marker anchors where its native badge sits. A marker
// still waiting for its floor reads the terrain globe, or on photoreal
// tiles waits for sampleAnchors().
let height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
else if (scene.globe.show)
height = scene.globe.getHeight?.(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
@@ -158,14 +203,19 @@ export function createAlprOverlay({
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
* Sample the rendered surface for the overlay cameras, outside the paint.
*
* A camera with no floor yet gets its anchor from a sample. An anchored one
* is checked against the surface once: floors come from ~111 m cells, so on
* a slope a badge could float or sink by tens of metres. Checks run only
* while the camera is still and tiles have settled, and samples far outside
* the floor prior (a roof or a streaming tile) are rejected. Each frame
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
* cohort; sampling inside paint() put up to six depth renders in one frame.
*/
function refineAnchors(budget) {
function sampleAnchors() {
sampling = false;
if (destroyed || !state.enabled) return;
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
@@ -188,27 +238,41 @@ export function createAlprOverlay({
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
if (!scene.sampleHeightSupported) return false;
let pending = false;
const canSample = scene.globe?.show || scene.sampleHeightSupported;
let samples = SURFACE_SAMPLES_PER_FRAME,
pending = false,
progressed = false,
moved = false;
// Cameras with no anchor come first: they are not on the overlay yet.
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity || entity.gevAlprCanvasPosition) continue;
// The floor may have resolved since the paint that found none.
if (anchorFor(record, entity)) moved = true;
else if (!canSample || samples <= 0) pending = true;
else {
samples -= 1;
const sample = surfaceHeight(record, entity);
if (validAlprGroundHeight(sample)) {
setAnchor(record, entity, sample);
rememberSurface(record, sample, false);
moved = progressed = true;
} else pending = true;
}
}
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
continue;
if (!still || !surfaceSettled(scene) || samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
samples -= 1;
progressed = true;
const sample = surfaceHeight(record, entity);
let height = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
@@ -218,21 +282,43 @@ export function createAlprOverlay({
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
height = sample + MARKER_FLOOR_LIFT_M;
setAnchor(record, entity, height);
moved = true;
}
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
// A surface that did not answer is asked again after a rebuild.
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
return pending;
if (moved) requestPaint();
if (!pending || Date.now() >= retryUntil) return;
// Take the next ration on the next frame; when nothing could be sampled
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
if (progressed) scheduleSampling();
else if (!retryTimer)
retryTimer = setTimeout(() => {
retryTimer = null;
scheduleSampling();
}, 250);
}
function scheduleSampling() {
if (sampling || destroyed) return;
sampling = true;
requestFrame(sampleAnchors);
}
function paint({ ctx, width, height, keyhole, occluder }) {
hits = [];
if (!state.enabled || destroyed) return;
let unresolved = false;
let waiting = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
const canSample =
state.viewer.scene.globe?.show ||
state.viewer.scene.sampleHeightSupported;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -246,12 +332,14 @@ export function createAlprOverlay({
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity, budget);
const anchor = anchorFor(record, entity);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
waiting = true;
continue;
}
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
waiting = true;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
@@ -335,15 +423,9 @@ export function createAlprOverlay({
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
// Anchors still to find or to check are sampled after this paint, a few
// per frame, never inside it.
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
}
return {
+135 -19
View File
@@ -45,8 +45,10 @@ function harness(t, count = 2, { floor = null } = {}) {
active,
removed = 0,
paints = 0;
const frames = [];
const overlay = createAlprOverlay({
state,
requestFrame: (step) => frames.push(step),
services: {
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
@@ -106,6 +108,12 @@ function harness(t, count = 2, { floor = null } = {}) {
entities,
draw,
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
/** Run the sampling step queued for the next animation frame, if any. */
frame() {
const step = frames.shift();
step?.();
return Boolean(step);
},
mapChanged() {
mapListener({ detail: { status: 'ready' } });
},
@@ -243,41 +251,149 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
const h = harness(t, 2, { floor: 150 });
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
function photoreal(h, heightAt = () => 172) {
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.globe.show = false;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
const sampled = [];
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
sampled.push(carto);
return heightAt(Cesium.Math.toDegrees(carto.latitude));
};
return sampled;
}
const anchorHeight = (h, id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
function settle(t, h) {
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
}
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 2, { floor: 150 });
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
h.overlay.sync(h.records);
h.paint();
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
'floor anchor first',
);
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
assert.equal(
samples,
sampled.length,
0,
'no depth samples while the camera may still be moving',
);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
settle(t, h);
assert.ok(
Math.abs(height('camera:0') - 173.5) < 0.01,
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(height('camera:1') - 151.5) < 0.01,
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
const taken = samples;
assert.equal(sampled.length, 2);
h.paint();
assert.equal(samples, taken, 'each anchor is checked once');
settle(t, h);
assert.equal(sampled.length, 2, 'each anchor is checked once');
});
test('a paint never samples the surface; frames take one sample each', (t) => {
// Sampling inside paint() put up to six depth renders into one frame, and
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 8);
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
h.paint();
assert.equal(sampled.length, 0, 'no sample inside a paint');
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
for (let taken = 1; taken <= 8; taken++) {
assert.equal(h.frame(), true);
assert.equal(sampled.length, taken, 'one sample per frame');
}
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
h.paint();
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
});
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 3, { floor: 150 });
const sampled = photoreal(h);
const scene = h.state.viewer.scene;
scene.globe.show = true;
scene.globe.tilesLoaded = true;
let reads = 0;
scene.globe.getHeight = () => {
reads++;
return 172;
};
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 0);
assert.equal(reads, 3);
// The same anchor the depth sample gave: the surface plus the marker lift.
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
});
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 4, { floor: 150 });
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 4);
const before = h.records.map((record) => anchorHeight(h, record.id));
// A refresh or a toggle removes the entities and builds new ones.
const rebuild = () => {
h.overlay.clear();
h.entities.removeAll();
for (const record of h.records)
h.entities.add({
id: record.id,
billboard: { image: 'fallback', show: true },
polyline: { show: true },
polygon: { show: true },
});
h.overlay.sync(h.records);
h.paint();
settle(t, h);
};
rebuild();
h.state.enabled = false;
h.overlay.clear();
h.state.enabled = true;
rebuild();
assert.equal(sampled.length, 4, 'no camera is sampled again');
assert.deepEqual(
h.records.map((record) => anchorHeight(h, record.id)),
before,
);
// A camera that moved is a different question.
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
rebuild();
assert.equal(sampled.length, 5);
h.mapChanged();
h.paint();
settle(t, h);
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
});
+5 -4
View File
@@ -66,14 +66,15 @@ export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
* depth render of several milliseconds, so they are spread over frames). */
export const SURFACE_SAMPLES_PER_FRAME = 1;
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
export const REMEMBERED_ANCHORS = 2048;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
-55
View File
@@ -77,58 +77,3 @@ export function normalizeFeatures(json, kind) {
};
});
}
function partPosition(value) {
if (!Array.isArray(value)) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
const height = Number(value[2]);
return Number.isFinite(height) ? [lon, lat, height] : [lon, lat];
}
function collectParts(geometry, out) {
const coordinates = geometry?.coordinates;
switch (geometry?.type) {
case 'Point': {
const point = partPosition(coordinates);
if (point) out.points.push(point);
break;
}
case 'MultiPoint':
for (const value of coordinates || []) {
const point = partPosition(value);
if (point) out.points.push(point);
}
break;
case 'LineString': {
const line = (coordinates || []).map(partPosition).filter(Boolean);
if (line.length >= 2) out.lines.push(line);
break;
}
case 'MultiLineString':
for (const part of coordinates || []) {
const line = (part || []).map(partPosition).filter(Boolean);
if (line.length >= 2) out.lines.push(line);
}
break;
case 'GeometryCollection':
for (const child of geometry.geometries || []) collectParts(child, out);
break;
default:
break;
}
}
/**
* The parts of a feature the layer draws: every line of a LineString or
* MultiLineString and every point of a Point or MultiPoint, also inside a
* GeometryCollection. Each is [lon, lat] or [lon, lat, height] in degrees
* and metres. Polygons are not drawn.
* @returns {{lines:number[][][],points:number[][]}}
*/
export function featureParts(feature) {
const out = { lines: [], points: [] };
collectParts(feature?.geometry, out);
return out;
}
+1 -2
View File
@@ -10,7 +10,6 @@ export function createSubmarineCableLayer({
screenSpaceEventHandlerFactory,
mapStackEventTarget = null,
sweepClock = defaultSweepClock,
createMarkerImage,
}) {
if (!source?.fetch || !source.label)
throw new TypeError(
@@ -24,7 +23,6 @@ export function createSubmarineCableLayer({
source,
screenSpaceEventHandlerFactory,
mapStackEventTarget,
createMarkerImage,
};
parts.rendering = createRendering(context);
parts.ingestion = createIngestion(context);
@@ -42,6 +40,7 @@ export {
export {
cableClassificationTypeForStack,
cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
} from './surface.js';
export {
CABLE_REFERENCE_LABEL_WINNER_CAP,
+174 -93
View File
@@ -1,82 +1,27 @@
import { msg, t } from '../../i18n/index.js';
import {
featureLabel,
featureParts,
featureReference,
normalizeFeatures,
} from './geometry.js';
import * as Cesium from 'cesium';
const toCartesian = ([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, height ?? 0);
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
function indexedCollection(features) {
return {
type: 'FeatureCollection',
features: features.map((feature, index) => ({
...feature,
properties: {
...feature.properties,
[SOURCE_FEATURE_INDEX]: index,
},
})),
};
}
export function createIngestion({ state, parts, source }) {
/**
* Turn the two feature collections into what rendering draws. Every part
* of a multipart feature is its own line or marker with its own pick
* record, all sharing the feature's reference; a feature gets one
* reference stem however many parts it has.
*/
function describeScene(cableFeatures, landingFeatures) {
const lines = [];
const markers = [];
const references = [];
const add = (features, kind, style) => {
for (const feature of features) {
const reference = featureReference(feature);
if (!reference) continue;
const label = featureLabel(feature);
const base = Cesium.Cartesian3.fromDegrees(
reference.lon,
reference.lat,
0,
);
const drawn = featureParts(feature);
const pick = () =>
parts.interaction.registerPick({ kind, reference, label });
for (const line of drawn.lines)
lines.push({
info: pick(),
base,
positions: line.map(toCartesian),
color: style.lineColor(feature),
width: style.lineWidth,
});
for (const point of drawn.points)
markers.push({
info: pick(),
base,
position: toCartesian(point),
hasHeight: point.length > 2,
color: style.color,
});
references.push({
reference,
label,
kind,
color: style.color,
feature,
});
}
};
add(cableFeatures, 'cable', {
color: state.cableColor,
lineWidth: 2.5,
lineColor: (feature) => {
const css = feature?.properties?.color;
const color = css && Cesium.Color.fromCssColorString(String(css));
return (color || state.cableColor).withAlpha(0.92);
},
});
const landingLine = state.landingColor.withAlpha(0.9);
add(landingFeatures, 'landing-point', {
color: state.landingColor,
lineWidth: 2,
lineColor: () => landingLine,
});
return { lines, markers, references };
}
async function load(viewer) {
if (state._loading || state._loaded) return;
@@ -85,10 +30,10 @@ export function createIngestion({ state, parts, source }) {
state._loadingLabel = t('loading...');
// Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this
// one is still settling. The stale load must bail after its await and
// one is still settling. The stale load must bail after every await and
// must never touch shared lifecycle state or the viewer it no longer
// owns — clearing `_loading`/`_abort` for the successor is how duplicate
// or post-destroy primitives would get added.
// or post-destroy data sources got added.
const generation = ++state._loadGeneration;
const abort = new AbortController();
state._abort = abort;
@@ -96,34 +41,170 @@ export function createIngestion({ state, parts, source }) {
generation === state._loadGeneration && !abort.signal.aborted;
try {
const { cables: cableJson, landingPoints: landingJson } =
await source.fetch(abort.signal);
// The fetch is the only await: everything after it runs in this one
// turn, so no disable or destroy can land between the ownership check
// and the primitives reaching the scene.
if (!owns()) return;
if (
!Array.isArray(cableJson?.features) ||
!Array.isArray(landingJson?.features)
) {
throw new TypeError(
'A cable source must return cables and landingPoints GeoJSON collections',
);
let cableJson = state._cachedCableJson;
let landingJson = state._cachedLandingJson;
if (!cableJson || !landingJson) {
({ cables: cableJson, landingPoints: landingJson } = await source.fetch(
abort.signal,
));
if (!owns()) return;
if (
!Array.isArray(cableJson?.features) ||
!Array.isArray(landingJson?.features)
) {
throw new TypeError(
'A cable source must return cables and landingPoints GeoJSON collections',
);
}
state._cachedCableJson = cableJson;
state._cachedLandingJson = landingJson;
}
const cableFeatures = normalizeFeatures(cableJson, 'cable');
const landingFeatures = normalizeFeatures(landingJson, 'landing');
try {
parts.rendering.buildScene(
viewer,
describeScene(cableFeatures, landingFeatures),
);
} catch (error) {
// Leave nothing half built in the scene.
parts.rendering.destroyScene(viewer);
throw error;
const cableDataSource = await Cesium.GeoJsonDataSource.load(
indexedCollection(cableFeatures),
{
clampToGround: true,
stroke: state.cableColor.withAlpha(0.95),
fill: state.cableColor.withAlpha(0.18),
strokeWidth: 2,
markerColor: state.cableColor,
markerSize: 6,
},
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
indexedCollection(landingFeatures),
{
clampToGround: true,
stroke: state.landingColor.withAlpha(0.9),
fill: state.landingColor.withAlpha(0.35),
strokeWidth: 2,
markerColor: state.landingColor,
markerSize: 6,
},
);
if (!owns()) return;
cableDataSource.name = `${source.label} Submarine Cables`;
landingDataSource.name = `${source.label} Landing Points`;
const referenceDataSource = new Cesium.CustomDataSource(
`${source.label} Cable References`,
);
const addedSources = [
cableDataSource,
landingDataSource,
referenceDataSource,
];
// dataSources.add() is itself an await point: Cesium mutates the
// collection on a DEFERRED tick, so a disable/destroy racing this
// window finds nothing to remove and the adds still materialize
// afterwards. Await every add to settlement (allSettled so a partial
// failure cannot leave an unawaited add materializing later), then
// re-check ownership. Pinned teardown mechanism: disable/destroy never
// wait for in-flight adds — the stale generation's own post-await
// check here guarantees cleanup by removing exactly the sources THIS
// generation added.
const addResults = await Promise.allSettled([
viewer.dataSources.add(cableDataSource),
viewer.dataSources.add(landingDataSource),
viewer.dataSources.add(referenceDataSource),
]);
const rejectedAdd = addResults.find(
(result) => result.status === 'rejected',
);
if (!owns() || rejectedAdd) {
// Compensate: every add has settled by now, so removal is effective
// (a remove inside the deferred window would have been a no-op).
// Remove EVERY generation-local source regardless of settle status:
// Cesium's add() pushes into the collection BEFORE raising
// dataSourceAdded, so a REJECTED add may still have landed its
// mutation. remove() of a never-added source is a harmless no-op.
for (const source of addedSources) {
try {
viewer.dataSources.remove(source, true);
} catch {
/* collection gone */
}
}
if (owns() && rejectedAdd) throw rejectedAdd.reason;
return;
}
// Await-free commit: the viewer accepted all three sources and this
// load still owns the lifecycle.
state._cableDataSource = cableDataSource;
state._landingDataSource = landingDataSource;
state._referenceDataSource = referenceDataSource;
const cableEntities = state._cableDataSource.entities.values;
const landingEntities = state._landingDataSource.entities.values;
state._pickByEntity = new WeakMap();
state._referenceRecords = [];
state._surfaceRecords = [];
// Cesium expands multipart geometry into several entities and suffixes
// colliding IDs. Its copied properties retain this source index even
// when a provider has no ID, so every segment keeps the right owner.
const cableReferences = new Set();
cableEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = cableFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
parts.rendering.styleCableEntity(entity, feature);
parts.interaction.registerPickEntity(entity, {
kind: 'cable',
reference,
label: featureLabel(feature),
});
state._surfaceRecords.push({
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (cableReferences.has(index)) return;
cableReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'cable',
color: state.cableColor,
feature,
});
});
const landingReferences = new Set();
landingEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = landingFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
parts.rendering.styleLandingEntity(entity, feature);
parts.interaction.registerPickEntity(entity, {
kind: 'landing-point',
reference,
label: featureLabel(feature),
});
state._surfaceRecords.push({
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (landingReferences.has(index)) return;
landingReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'landing-point',
color: state.landingColor,
feature,
});
});
state._count = cableFeatures.length + landingFeatures.length;
state._loaded = true;
state._lastUpdate = Date.now();
+30 -7
View File
@@ -2,10 +2,9 @@ import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
/** Every drawn part carries its own record as the Cesium pick id. */
function registerPick(info) {
state._pickRecords.add(info);
return info;
function registerPickEntity(entity, info) {
entity.__gevTeleGeography = info;
state._pickByEntity.set(entity, info);
}
function beginInteraction(viewer) {
@@ -23,8 +22,32 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
}
function resolvePickRecord(picked) {
const info = picked?.id;
return info && state._pickRecords.has(info) ? info : null;
if (!picked) return null;
const primitive = picked.primitive;
if (primitive) {
const primitiveInfo = state._pickByEntity.get(primitive);
if (primitiveInfo) return primitiveInfo;
if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography;
if (
primitive.id &&
typeof primitive.id === 'object' &&
primitive.id.reference
) {
return primitive.id;
}
}
const entity = picked.id;
if (entity) {
const entityInfo = state._pickByEntity.get(entity);
if (entityInfo) return entityInfo;
if (entity.__gevTeleGeography) return entity.__gevTeleGeography;
if (entity.id && typeof entity.id === 'object' && entity.id.reference) {
return entity.id;
}
}
return null;
}
function flyToReference(viewer, reference) {
@@ -47,7 +70,7 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
});
}
return {
registerPick,
registerPickEntity,
beginInteraction,
resolvePickRecord,
flyToReference,
+23 -17
View File
@@ -38,13 +38,12 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
// never emit moveEnd — reach the motion fallback. No render is
// requested for a fallback sweep: it only fires while the camera is
// already moving, so frames are flowing by construction.
if (!state._enabled || !state._loaded) return;
// Markers left over from the last sweep, a few per frame; line
// shows and rebuilt stems waiting for their geometry.
parts.rendering.revealLandingMarkers();
parts.rendering.syncLineShow();
parts.rendering.settleStems();
if (!state._referenceSweepGate.shouldRun(viewer.camera)) return;
if (
!state._enabled ||
!state._loaded ||
!state._referenceSweepGate.shouldRun(viewer.camera)
)
return;
parts.rendering.updateReferenceVisibility();
},
);
@@ -81,15 +80,10 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
state._loading = false;
state._loadingLabel = '';
}
// Hide what is built and keep it: a hidden primitive is skipped by
// the scene, so it costs nothing per frame, and the next enable shows
// it again without a rebuild. Only the stems go: they are sized for
// the view and the next sweep redraws them in a worker. An in-flight load's own post-await
// ownership check stops that stale generation from building anything.
state._pendingLandingReveals.length = 0;
parts.rendering.dropStems();
parts.rendering.updateVisibility();
state._viewer?.scene?.requestRender?.();
// Free the entities rather than hide them; enable() rebuilds from the
// cached JSON. An in-flight load's own post-await ownership check
// removes whatever that stale generation adds later.
parts.rendering.releaseDataSources(state._viewer);
},
update(viewer) {
@@ -98,7 +92,9 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
destroy(viewer) {
if (state._abort) state._abort.abort();
parts.rendering.destroyScene(viewer || state._viewer);
parts.rendering.releaseDataSources(viewer || state._viewer);
state._cachedCableJson = null;
state._cachedLandingJson = null;
// hide() clears every published host entry and goes invisible while
// keeping the publisher reusable — the legacy layer supported re-init
// after destroy, and hidden publishers already drop late publishes.
@@ -132,6 +128,16 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
state._error = null;
state._lastUpdate = null;
state._loadingLabel = '';
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
state._referenceRecords = [];
state._surfaceRecords = [];
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
state._markerBlendDone = false;
state._markerBlendInvariantWarned = false;
parts.rendering.resetPublishSignature();
state._referenceSweepGate.reset();
},
+15 -8
View File
@@ -41,7 +41,7 @@ export function selectCableReferenceLabelWinners(
.sort(
(a, b) =>
a.distanceM - b.distanceM ||
String(a.id || '').localeCompare(String(b.id || '')),
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
)
.slice(0, cap);
}
@@ -248,15 +248,16 @@ export function createCableReferenceSweepGate({
}
/**
* Recompute one stem tip on the sweep cadence. The tip is moved in place, so
* the label entry keeps pointing at it, and only when the camera-scaled tip
* moved beyond the settle epsilon; the tip point follows with one buffer
* write, and the caller redraws the stems when any tip moved.
* @param {object} record Reference record with point/base/tip/nextTip.
* 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 tip moved.
* @returns {boolean} True when the stem geometry was redefined.
*/
export function updateCableReferenceStem(
@@ -288,7 +289,13 @@ export function updateCableReferenceStem(
return false;
}
Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.point.position = 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;
}
+15 -16
View File
@@ -1,3 +1,5 @@
import * as Cesium from 'cesium';
export const BASE_CABLE_COLOR = '#39d5ff';
export const BASE_LANDING_COLOR = '#8fffd2';
@@ -64,22 +66,6 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
depthTested: false,
});
/**
* Landing markers clamped to the ground per rendered frame. A marker is put
* on the surface the first time it comes into view, and that reads the
* terrain height under it. Doing it for all 1,917 in the frame after the
* layer was switched on was a single task (measured 1.2 s on a loaded
* globe); a few dozen per frame keeps each frame short and the markers still
* fill in within a second. A marker is clamped once: after that Cesium moves
* it as terrain loads, and hiding or showing it reads nothing.
*/
export const CABLE_LANDING_REVEALS_PER_FRAME = 48;
/** Pixel size of the marker drawn for point geometry (GeoJSON's small pin). */
export const CABLE_MARKER_SIZE = 6;
/**
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
@@ -95,6 +81,19 @@ export const CABLE_GLOBE_STACK_IDS = Object.freeze(
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
);
/** EntityCluster's private marker collections and their required types. */
export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
Object.freeze({
key: '_billboardCollection',
type: Cesium.BillboardCollection,
}),
Object.freeze({
key: '_pointCollection',
type: Cesium.PointPrimitiveCollection,
}),
]);
/**
* Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
* the module's `export default createTeleGeographySubmarineCableLayer()`
+166 -382
View File
@@ -1,8 +1,8 @@
import * as Cesium from 'cesium';
import {
CABLE_LANDING_REVEALS_PER_FRAME,
CABLE_MARKER_SIZE,
} from './policy.js';
probeTranslucentMarkerBlend,
commitTranslucentMarkerBlend,
} from './surface.js';
import {
clampLabel,
createCableOverlayEntry,
@@ -11,13 +11,7 @@ import {
cableReferencePriority,
} from './overlay.js';
const defaultMarkerImage = (color) =>
new Cesium.PinBuilder().fromColor(color, CABLE_MARKER_SIZE);
export function createRendering({
state,
createMarkerImage = defaultMarkerImage,
}) {
export function createRendering({ state }) {
function resetPublishSignature() {
state._lastPublishedIds.length = 0;
state._lastPublishedPriorities.length = 0;
@@ -25,131 +19,129 @@ export function createRendering({
}
/**
* Build everything the layer draws, in one synchronous pass with no entity
* per feature. The layer used to load two GeoJsonDataSources and a
* CustomDataSource: 6,459 entities whose creation held about 1 GB of
* short-lived heap, whose visualizers ran 100–250 ms tasks whenever shows
* or stems changed, and whose removal on disable was a 150 ms task. These
* are the primitives Cesium built from those entities, made directly:
*
* - lines: one GroundPolylinePrimitive, a colour and a show flag per line
* (Cesium's own batch for clamped colour polylines), tessellated in a
* worker;
* - point markers: one BillboardCollection, each billboard hidden and
* unclamped until revealLandingMarkers puts it on the ground;
* - references: one PointPrimitiveCollection for the tips, whose positions
* and shows are plain buffer writes, and one Primitive of polylines for
* the stems (see rebuildStems).
*
* `lines`, `markers` and `references` come from ingestion; each line and
* marker carries the pick record Cesium returns as the pick id.
* Remove the three data sources from the viewer and forget the built
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
* DataSourceDisplay still walks every visualizer of a hidden source on
* every frame, and the thousands of hidden polylines/billboards keep
* costing CPU and GC pressure long after the user toggled the layer off —
* that is the "everything gets sluggish after I turn layers off" report.
* The fetched JSON stays cached, so the next enable rebuilds without a
* network round trip or a re-parse.
*/
function buildScene(viewer, { lines, markers, references }) {
const scene = viewer.scene;
state._lineRecords = [];
state._markerRecords = [];
function releaseDataSources(viewer) {
const sources = [
state._cableDataSource,
state._landingDataSource,
state._referenceDataSource,
];
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
state._referenceRecords = [];
state._pendingLandingReveals.length = 0;
if (lines.length) {
// The entity display gave every line this attribute too. It is a float
// attribute, and with one present Cesium keeps the per-line width as a
// float; without it the 2.5 px width is stored as a byte and draws 2 px.
const always =
Cesium.DistanceDisplayConditionGeometryInstanceAttribute.fromDistanceDisplayCondition(
new Cesium.DistanceDisplayCondition(),
);
const instances = lines.map(
(line) =>
new Cesium.GeometryInstance({
id: line.info,
geometry: new Cesium.GroundPolylineGeometry({
positions: line.positions,
width: line.width,
// GeoJSON lines are rhumb lines (GeoJsonDataSource's choice).
arcType: Cesium.ArcType.RHUMB,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
line.color,
),
show: new Cesium.ShowGeometryInstanceAttribute(true),
distanceDisplayCondition: always,
},
}),
);
state._cablePrimitive = scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance(),
classificationType: state._classificationType,
asynchronous: true,
show: state._enabled,
}),
);
for (const line of lines)
state._lineRecords.push({
info: line.info,
base: line.base,
visible: true,
shown: true,
attributes: null,
});
}
if (markers.length) {
// Every marker is translucent, so one blend pass, not Cesium's two.
const billboards = scene.primitives.add(
new Cesium.BillboardCollection({
scene,
blendOption: Cesium.BlendOption.TRANSLUCENT,
show: state._enabled,
}),
);
state._markerBillboards = billboards;
const images = new Map();
for (const marker of markers) {
const key = marker.color.toCssHexString();
let image = images.get(key);
if (!image) {
image = {
id: `gev-cable-marker-${key}`,
source: createMarkerImage(marker.color),
};
images.set(key, image);
}
const billboard = billboards.add({
id: marker.info,
position: marker.position,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
show: false,
});
billboard.setImage(image.id, image.source);
state._markerRecords.push({
info: marker.info,
marker: billboard,
base: marker.base,
// A position with its own height is drawn there, as before.
clamped: marker.hasHeight,
visible: false,
});
state._surfaceRecords = [];
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
// The blend pass targets the collections being dropped; the rebuilt
// sources need their own pass.
state._markerBlendDone = false;
state._loaded = false;
state._referenceSweepGate.reset();
for (const source of sources) {
if (!source) continue;
try {
viewer?.dataSources?.remove(source, true);
} catch {
/* collection gone */
}
}
}
if (references.length) {
state._referencePoints = scene.primitives.add(
new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
show: state._enabled,
}),
);
for (const reference of references) addReferenceStem(reference);
function updateVisibility() {
if (state._cableDataSource) state._cableDataSource.show = state._enabled;
if (state._landingDataSource)
state._landingDataSource.show = state._enabled;
if (state._referenceDataSource)
state._referenceDataSource.show = state._enabled;
}
function styleCableEntity(entity, feature) {
if (!entity?.polyline) return;
const color = feature?.properties?.color
? Cesium.Color.fromCssColorString(String(feature.properties.color))
: state.cableColor;
entity.polyline.material = color.withAlpha(0.92);
entity.polyline.width = 2.5;
entity.polyline.clampToGround = true;
entity.polyline.classificationType = state._classificationType;
entity.show = true;
}
/**
* Apply the single translucent blend pass to the landing-billboard and
* reference-point collections. Landing entities carry only billboards and
* reference entities only points, so completion is one applied collection
* per source; a failed shape probe disables the optimization permanently
* for this session and says so loudly in dev builds.
* Both sources are probed BEFORE either is committed, so the helper's
* "defaults untouched on shape drift" fallback holds across the pair too —
* a reference-source failure can never leave the landing source already
* forced to TRANSLUCENT.
*/
function applyMarkerBlendOnce() {
const landing = probeTranslucentMarkerBlend(state._landingDataSource);
const reference = probeTranslucentMarkerBlend(state._referenceDataSource);
if (landing.invariantFailed || reference.invariantFailed) {
state._markerBlendDone = true;
if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) {
state._markerBlendInvariantWarned = true;
console.error(
'[Data:telegeography-submarine-cables] EntityCluster marker-collection ' +
'shape changed — single-pass translucent blend skipped; markers fall ' +
"back to Cesium's default two-pass blend.",
);
}
return;
}
state._lineShowDirty = false;
const landingBlend = commitTranslucentMarkerBlend(landing);
const referenceBlend = commitTranslucentMarkerBlend(reference);
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
state._markerBlendDone = true;
}
/**
* Re-classify every cable ground line for the active surface. One batched
* ground-primitive rebuild per stack switch — never per frame.
* @param {Cesium.ClassificationType} next
*/
function applyCableClassification(next) {
if (next === undefined || next === state._classificationType) return;
state._classificationType = next;
if (!state._cableDataSource) return;
const entities = state._cableDataSource.entities.values;
for (let i = 0; i < entities.length; i++) {
const polyline = entities[i].polyline;
if (polyline) polyline.classificationType = next;
}
state._viewer?.scene?.requestRender?.();
}
function styleLandingEntity(entity, feature) {
if (!entity?.point) return;
entity.point.color = state.landingColor.withAlpha(0.92);
entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
entity.point.outlineColor = state.cableOutline;
entity.point.outlineWidth = 1;
entity.point.disableDepthTestDistance = 0;
entity.show = true;
}
function addReferenceStem({ reference, label, kind, color, feature }) {
if (!state._referenceDataSource || !reference) return;
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
const tip = Cesium.Cartesian3.fromDegrees(
reference.lon,
@@ -162,31 +154,42 @@ export function createRendering({
label,
featureId: feature?.id || feature?.properties?.id || null,
};
state._pickRecords.add(info);
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
// The tip is moved in place on the sweep cadence (overlay.js), never by a
// per-frame callback.
const point = state._referencePoints.add({
id: info,
position: tip,
pixelSize: kind === 'cable' ? 7 : 8,
color: pointColor,
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
outlineWidth: 1,
disableDepthTestDistance: 0,
show: false,
});
const record = {
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68);
// Constant properties on the sweep cadence, never per-frame callbacks:
// the sweep redefines them through the alternating buffers below.
const stemPositionBuffers = [
[base, tip],
[base, tip],
];
const entity = state._referenceDataSource.entities.add({
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
info,
point,
position: tip,
polyline: {
positions: stemPositionBuffers[0],
width: kind === 'cable' ? 2 : 2.4,
material: new Cesium.ColorMaterialProperty(stemColor),
},
point: {
pixelSize: kind === 'cable' ? 7 : 8,
color: pointColor,
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
outlineWidth: 1,
disableDepthTestDistance: 0,
},
});
entity.__gevTeleGeography = info;
state._pickByEntity.set(entity, info);
const record = {
id: entity.id,
entity,
base,
tip,
nextTip: Cesium.Cartesian3.clone(tip),
stemColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
color.withAlpha(kind === 'cable' ? 0.58 : 0.68),
),
stemWidth: kind === 'cable' ? 2 : 2.4,
stemPositionBuffers,
stemPositionBufferIndex: 0,
reference,
kind,
label: clampLabel(label),
@@ -198,212 +201,6 @@ export function createRendering({
state._referenceRecords.push(record);
}
/**
* Draw the stems of the references now in view, base to tip. Stems are one
* static polyline primitive, as the entity display batched them: nothing
* runs for it per frame, and a stem's length cannot change without new
* geometry. So a sweep that moved a tip or changed which references are
* in view builds a replacement in a worker, and settleStems swaps it in
* once it is ready; the previous stems stay until then. A sweep that
* changed nothing builds nothing.
*/
function rebuildStems() {
const primitives = state._viewer?.scene?.primitives;
if (!primitives) return;
if (state._stemPrimitivePending) {
// Superseded before it was ever shown.
primitives.remove(state._stemPrimitivePending);
state._stemPrimitivePending = null;
}
const instances = [];
for (const record of state._referenceRecords) {
if (!record.visible) continue;
instances.push(
new Cesium.GeometryInstance({
id: record.info,
geometry: new Cesium.PolylineGeometry({
positions: [record.base, record.tip],
width: record.stemWidth,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: { color: record.stemColor },
}),
);
}
state._stemsDirty = false;
if (!instances.length) {
if (state._stemPrimitive) primitives.remove(state._stemPrimitive);
state._stemPrimitive = null;
return;
}
state._stemPrimitivePending = primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
// Hidden while it builds; settleStems shows it.
show: false,
}),
);
state._viewer.scene.requestRender?.();
}
/** Swap in rebuilt stems once their geometry is ready; called per frame. */
function settleStems() {
const pending = state._stemPrimitivePending;
if (!pending) return;
const scene = state._viewer?.scene;
if (!pending.ready) {
// Keep frames coming until the geometry is in.
scene?.requestRender?.();
return;
}
pending.show = state._enabled;
if (state._stemPrimitive) scene?.primitives?.remove(state._stemPrimitive);
state._stemPrimitive = pending;
state._stemPrimitivePending = null;
scene?.requestRender?.();
}
/** Remove and destroy everything buildScene made. Only destroy() does this;
* a disable hides the primitives and keeps them for the next enable. */
function destroyScene(viewer) {
const scene = viewer?.scene;
const owned = [
[scene?.groundPrimitives, state._cablePrimitive],
[scene?.primitives, state._markerBillboards],
[scene?.primitives, state._stemPrimitive],
[scene?.primitives, state._stemPrimitivePending],
[scene?.primitives, state._referencePoints],
];
state._cablePrimitive = null;
state._markerBillboards = null;
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = false;
state._referencePoints = null;
state._referenceRecords = [];
state._lineRecords = [];
state._markerRecords = [];
state._lineShowDirty = false;
state._pendingLandingReveals.length = 0;
state._pickRecords = new WeakSet();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
state._loaded = false;
state._referenceSweepGate.reset();
for (const [collection, primitive] of owned) {
if (!primitive) continue;
try {
// A collection destroys what it removes; one that is already gone
// has destroyed it too.
if (!collection?.remove?.(primitive) && !primitive.isDestroyed())
primitive.destroy();
} catch {
/* collection gone */
}
}
}
/**
* Drop the stems, shown and building, so the next sweep draws them afresh.
* A disable does this: stems kept from the view the layer was switched off
* in would show at their old lengths until the rebuild for the new view
* was ready.
*/
function dropStems() {
const primitives = state._viewer?.scene?.primitives;
for (const primitive of [state._stemPrimitive, state._stemPrimitivePending])
if (primitive) primitives?.remove(primitive);
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = true;
}
function updateVisibility() {
for (const primitive of [
state._cablePrimitive,
state._markerBillboards,
state._stemPrimitive,
state._referencePoints,
])
if (primitive) primitive.show = state._enabled;
}
/**
* Re-classify the cable ground lines for the active surface. The primitive
* reads its classification as it queues commands, so this is one
* assignment per stack switch and no rebuild.
* @param {Cesium.ClassificationType} next
*/
function applyCableClassification(next) {
if (next === undefined || next === state._classificationType) return;
state._classificationType = next;
if (!state._cablePrimitive) return;
state._cablePrimitive.classificationType = next;
state._viewer?.scene?.requestRender?.();
}
/**
* Write the line show states the last sweep worked out. The primitive
* builds its geometry in a worker and has no per-line attributes until it
* is ready, so the states wait in the records until then.
*/
function syncLineShow() {
if (!state._lineShowDirty) return;
const primitive = state._cablePrimitive;
if (!primitive?.ready) {
// Keep frames coming until the geometry is in.
if (primitive) state._viewer?.scene?.requestRender?.();
return;
}
for (const record of state._lineRecords) {
if (record.shown === record.visible) continue;
record.attributes ??= primitive.getGeometryInstanceAttributes(
record.info,
);
if (!record.attributes) continue;
record.attributes.show = Cesium.ShowGeometryInstanceAttribute.toValue(
record.visible,
record.attributes.show,
);
record.shown = record.visible;
}
state._lineShowDirty = false;
}
/**
* Put the next few markers the last sweep found visible on the ground and
* show them, and ask for another frame while any are left. Called from the
* sweep and then once per rendered frame, so a parked camera costs nothing
* once the list is empty. Clamping is what reads the terrain height, once
* per marker; Cesium then keeps the marker on the surface as terrain loads,
* within its own per-frame time budget.
*/
function revealLandingMarkers() {
const pending = state._pendingLandingReveals;
if (!pending.length) return;
let budget = CABLE_LANDING_REVEALS_PER_FRAME;
while (pending.length && budget > 0) {
const record = pending.pop();
if (!record.visible || record.marker.show) continue;
if (!record.clamped) {
record.marker.heightReference = Cesium.HeightReference.CLAMP_TO_GROUND;
record.clamped = true;
budget--;
}
record.marker.show = true;
}
if (pending.length) state._viewer?.scene?.requestRender?.();
}
function updateReferenceVisibility() {
if (!state._enabled || !state._viewer?.camera) return;
const cameraPos = state._viewer.camera.positionWC;
@@ -419,35 +216,20 @@ export function createRendering({
const visible = occluder.isPointVisible(record.base);
record.visible = visible;
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
if (record.point.show !== visible) {
record.point.show = visible;
state._stemsDirty = true;
if (record.entity.show !== visible) {
record.entity.show = visible;
}
// Hidden stems keep their last tip; this same sweep refreshes them in
// the pass where they turn visible again.
if (
visible &&
updateCableReferenceStem(record, cameraPos, canvasHeight, fov)
)
state._stemsDirty = true;
// Hidden stems keep their last geometry; this same sweep refreshes them
// in the pass where they turn visible again.
if (visible)
updateCableReferenceStem(record, cameraPos, canvasHeight, fov);
}
if (state._stemsDirty) rebuildStems();
for (const record of state._lineRecords) {
record.visible = occluder.isPointVisible(record.base);
if (record.visible !== record.shown) state._lineShowDirty = true;
}
syncLineShow();
state._pendingLandingReveals.length = 0;
for (const record of state._markerRecords) {
for (const record of state._surfaceRecords) {
const visible = occluder.isPointVisible(record.base);
record.visible = visible;
if (record.marker.show === visible) continue;
// A marker not yet on the ground waits its turn; one that already is
// shows at once, and hiding is immediate.
if (visible && !record.clamped) state._pendingLandingReveals.push(record);
else record.marker.show = visible;
if (record.entity.show !== visible) {
record.entity.show = visible;
}
}
revealLandingMarkers();
const winners = selectCableReferenceLabelWinners(state._referenceRecords);
let changed = winners.length !== state._lastPublishedCount;
for (let i = 0; i < winners.length; i++) {
@@ -465,6 +247,9 @@ export function createRendering({
}
state._publishScratch.length = winners.length;
state._referenceLabelCount = winners.length;
// The marker collections are created lazily by the visualizers, so the
// single-pass blend is applied on the sweep cadence until both landed.
if (!state._markerBlendDone) applyMarkerBlendOnce();
if (!changed) return;
state._overlayPublisher.publish(state._publishScratch);
state._lastPublishedCount = winners.length;
@@ -477,14 +262,13 @@ export function createRendering({
}
return {
resetPublishSignature,
buildScene,
destroyScene,
releaseDataSources,
updateVisibility,
styleCableEntity,
applyMarkerBlendOnce,
applyCableClassification,
syncLineShow,
settleStems,
dropStems,
revealLandingMarkers,
styleLandingEntity,
addReferenceStem,
updateReferenceVisibility,
};
}
+21 -37
View File
@@ -30,50 +30,26 @@ export function createState({ sweepClock, overlayHost }) {
state._loadGeneration = 0;
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
/**
* What the layer draws, built once per load and kept across disable/enable
* (a disable only hides them, which costs nothing per frame):
* one ground polyline primitive for every line, one billboard collection
* for every point marker, a point collection for the reference tips and
* one polyline primitive for the reference stems.
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
* the three data sources (see releaseDataSources) instead of hiding them,
* so a re-enable has to rebuild the entities; the network/parse half of
* that rebuild is the expensive part users would notice, so it is cached.
*/
state._cablePrimitive = null;
state._cachedCableJson = null;
state._markerBillboards = null;
state._referencePoints = null;
/** The stems now drawn, and their replacement while it builds. */
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = false;
state._cachedLandingJson = null;
state._referenceRecords = [];
/** One record per drawn line: its pick record, horizon base and show state. */
state._lineRecords = [];
/** Line show states still to be written once the primitive is ready. */
state._lineShowDirty = false;
/** One record per point marker: its billboard and horizon base. */
state._markerRecords = [];
/** Markers the last sweep found visible but not on the ground yet; drained
* a few per frame (CABLE_LANDING_REVEALS_PER_FRAME). */
state._pendingLandingReveals = [];
/** The pick records handed to Cesium as ids, so a pick is known to be ours. */
state._pickRecords = new WeakSet();
state._surfaceRecords = [];
state._clickHandler = null;
@@ -87,6 +63,14 @@ export function createState({ sweepClock, overlayHost }) {
state._classificationType = Cesium.ClassificationType.BOTH;
/** True once both marker collections blend translucent (or the shape probe failed). */
state._markerBlendDone = false;
state._markerBlendInvariantWarned = false;
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._referenceSweepGate = createCableReferenceSweepGate({
+89 -2
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium';
import { CABLE_GLOBE_STACK_IDS } from './policy.js';
import {
CABLE_GLOBE_STACK_IDS,
MARKER_COLLECTION_EXPECTATIONS,
} from './policy.js';
/**
* Ground-line classification for one map stack. The photoreal stack renders
@@ -7,7 +10,7 @@ import { CABLE_GLOBE_STACK_IDS } from './policy.js';
* need the 3D-tile classification pass there; every other known stack (the
* bing stacks and osm) renders imagery on the shown globe, so only the
* terrain pass applies.
* Classifying against just the active surface halves the
* Classifying against just the active surface halves the batched
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
* for an unknown stack — it renders on every surface, exactly the shipped
* pre-optimization behavior.
@@ -37,3 +40,87 @@ export function cableClassificationTypeForScene(scene) {
? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.TERRAIN;
}
/**
* Validate one data source's marker collections WITHOUT mutating anything.
* Splitting validation from mutation is what makes the fallback honest: a
* shape failure on the SECOND collection (or the second data source) must
* not leave the first one already forced to TRANSLUCENT.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
*/
export function probeTranslucentMarkerBlend(dataSource) {
const cluster = dataSource?.clustering;
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
const ready = [];
let pending = 0;
let invariantFailed = false;
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
if (!(key in cluster)) {
invariantFailed = true;
continue;
}
const collection = cluster[key];
if (collection === undefined || collection === null) {
pending++;
continue;
}
if (!(collection instanceof type) || !('blendOption' in collection)) {
invariantFailed = true;
continue;
}
ready.push(collection);
}
// Any failure discards every collected target: nothing is mutated at all.
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
return { ready, pending, invariantFailed: false };
}
/**
* Commit a clean probe. Only ever called once every probe in the batch has
* passed, so this cannot land a partial application.
* @param {{ready:object[],pending:number}} probe
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function commitTranslucentMarkerBlend(probe) {
for (const collection of probe.ready) {
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
}
}
return {
applied: probe.ready.length,
pending: probe.pending,
invariantFailed: false,
};
}
/**
* Force one translucent draw pass for a data source's marker collections.
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
* collection, and every cable marker is translucent (alpha < 1), so the
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
* reference points. Cesium has no public accessor for the entity-backed
* collections, so this reaches through EntityCluster's private fields under
* a SHAPE INVARIANT the caller must honor loudly: the constructor
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
* shape changed — report `invariantFailed` and never touch anything —
* while a present-but-undefined value only means "not created yet"
* (`pending`). Wrong instance types also fail the invariant. The cables
* sources never enable clustering, so created collections persist.
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
* default blend — the fallback this helper promises.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function applyTranslucentMarkerBlend(dataSource) {
const probe = probeTranslucentMarkerBlend(dataSource);
if (probe.invariantFailed)
return { applied: 0, pending: 0, invariantFailed: true };
return commitTranslucentMarkerBlend(probe);
}