diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5c8c4756e..51434de9d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1541,12 +1541,14 @@ 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, Dams and Submarine Cables release their built Cesium data sources +Data Centers and Dams 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. +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"). CCTV exposes a factory through `./layers/cctv`. Catalog and health requests, frame/media URLs, camera records, ground placement, geometry queues, playback, @@ -8147,21 +8149,35 @@ 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. 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. +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. -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 +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 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, and Cesium reads the terrain -height as it creates each one. They start hidden and the horizon sweep shows -the visible ones 48 per rendered frame (`CABLE_LANDING_REVEALS_PER_FRAME`), -so switching the layer on does not do every read in one task; hiding is still +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. diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 6bee7c56d..d5d1cc086 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -24,7 +24,6 @@ export { createCableOverlayEntry, cableClassificationTypeForStack, cableClassificationTypeForScene, - applyTranslucentMarkerBlend, createCableReferenceSweepGate, updateCableReferenceStem, } from '../layers/submarineCables/index.js'; diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index b57055290..837486547 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import { MAP_STACKS } from '../mapStackController.js'; import { CABLE_LANDING_REVEALS_PER_FRAME } from '../layers/submarineCables/policy.js'; +import { featureParts } from '../layers/submarineCables/geometry.js'; import { CABLE_LABEL_DEPTH_DECISION, CABLE_OVERLAY_COLLISION_CAPACITY, @@ -12,7 +13,6 @@ import { CABLE_STEM_TIP_EPSILON_M, CABLE_SWEEP_MOTION_EPSILON_M, CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS, - applyTranslucentMarkerBlend, cableClassificationTypeForScene, cableClassificationTypeForStack, cableReferencePriority, @@ -24,9 +24,15 @@ import { updateCableReferenceStem, } from './telegeographySubmarineCables.js'; +// A WebGL context reports these limits. Node has none, and Cesium's +// development build refuses to make a render state (which a primitive does as +// it is constructed) while the line-width range is empty. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 1; + test('cable reference winners remain nearest-first and hard-capped', () => { const records = Array.from({ length: 300 }, (_, index) => ({ - entity: { id: `reference-${index}` }, + id: `reference-${index}`, label: `Reference ${index}`, visible: true, distanceM: 1000 + index, @@ -249,7 +255,7 @@ test('cable ground lines classify against exactly the active surface on every st ); }); -test('a map-stack change re-classifies every cable line once, and destroy detaches the listener', async () => { +test('a map-stack change re-classifies the cable lines in place, and destroy detaches the listener', async () => { const listeners = new Map(); const eventTarget = { addEventListener: (name, fn) => listeners.set(name, fn), @@ -260,15 +266,10 @@ test('a map-stack change re-classifies every cable line once, and destroy detach const env = await createRealCableLayerHarness({ mapStackEventTarget: eventTarget, }); - const cableSource = env.dataSources.find((ds) => - /Submarine Cables/.test(ds.name || ''), - ); - const classificationOf = (entity) => - entity.polyline.classificationType.getValue(); + const { lines } = env.parts(); // The harness scene has no globe → BOTH at init (safe unknown fallback). - const cableEntity = cableSource.entities.values[0]; - assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.BOTH); + assert.equal(lines.classificationType, Cesium.ClassificationType.BOTH); const listener = listeners.get('gev:map-stack-changed'); assert.equal( typeof listener, @@ -276,17 +277,17 @@ test('a map-stack change re-classifies every cable line once, and destroy detach 'init must subscribe to the stack event', ); + const built = env.built(); listener({ detail: { activeId: 'photoreal', status: 'ready' } }); assert.equal( - classificationOf(cableEntity), + lines.classificationType, Cesium.ClassificationType.CESIUM_3D_TILE, ); listener({ detail: { activeId: 'osm', status: 'ready' } }); - assert.equal( - classificationOf(cableEntity), - Cesium.ClassificationType.TERRAIN, - ); + assert.equal(lines.classificationType, Cesium.ClassificationType.TERRAIN); + assert.equal(env.parts().lines, lines, 'the same primitive, re-classified'); + assert.equal(env.built(), built, 'a stack switch builds nothing'); env.layer.destroy(env.viewer); assert.equal( @@ -296,126 +297,21 @@ test('a map-stack change re-classifies every cable line once, and destroy detach ); }); -test('marker collections blend in a single translucent pass behind a loud shape invariant', async () => { - // Helper contract on a REAL Cesium cluster shape. - const ds = new Cesium.CustomDataSource('blend-probe'); - let result = applyTranslucentMarkerBlend(ds); - assert.deepEqual( - result, - { applied: 0, pending: 2, invariantFailed: false }, - 'pre-visualizer clusters are pending, never a shape failure', - ); - - ds.clustering._billboardCollection = new Cesium.BillboardCollection(); - ds.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); - result = applyTranslucentMarkerBlend(ds); - assert.deepEqual(result, { applied: 2, pending: 0, invariantFailed: false }); - assert.equal( - ds.clustering._billboardCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - ); - assert.equal( - ds.clustering._pointCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - ); - - // Shape drift fails LOUDLY-detectably instead of applying blindly: a - // renamed field or a foreign object type must never be touched. - assert.equal( - applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed, - true, - 'missing private fields mean Cesium changed shape', - ); - const wrongType = new Cesium.CustomDataSource('wrong-type'); - wrongType.clustering._billboardCollection = { blendOption: 0 }; - const wrongResult = applyTranslucentMarkerBlend(wrongType); - assert.equal(wrongResult.invariantFailed, true); - assert.equal( - wrongType.clustering._billboardCollection.blendOption, - 0, - 'foreign objects are left untouched', - ); - assert.equal(applyTranslucentMarkerBlend(null).invariantFailed, true); - - // ATOMICITY: a shape failure on the SECOND collection must not leave the - // FIRST one already forced. Validation of every field/type completes before - // the first assignment, so a failed probe applies nothing at all — that is - // what "leaves Cesium's default blend untouched" has to mean. - const partial = new Cesium.CustomDataSource('partial-shape'); - partial.clustering._billboardCollection = new Cesium.BillboardCollection(); - partial.clustering._pointCollection = { blendOption: 0 }; // renamed/reshaped - const partialResult = applyTranslucentMarkerBlend(partial); - assert.equal(partialResult.invariantFailed, true); - assert.equal( - partialResult.applied, - 0, - 'a failed probe reports zero applications', - ); - assert.equal( - partial.clustering._billboardCollection.blendOption, - Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT, - "the valid first collection keeps Cesium's default blend when the second fails", - ); - assert.equal( - partial.clustering._pointCollection.blendOption, - 0, - 'the malformed collection is never touched', - ); - - // Real-layer wiring: once the visualizers have created the collections, - // the next sweep applies the single-pass blend to both marker sources. - const env = await createRealCableLayerHarness(); - env.raiseSweep(); - const landingSource = env.dataSources.find((source) => - /Landing Points/.test(source.name || ''), - ); - const referenceSource = env.dataSources.find((source) => - /References/.test(source.name || ''), - ); - landingSource.clustering._billboardCollection = - new Cesium.BillboardCollection(); - landingSource.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - referenceSource.clustering._billboardCollection = - new Cesium.BillboardCollection(); - referenceSource.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - assert.equal( - landingSource.clustering._billboardCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - 'landing billboards blend in one pass after the sweep', - ); - assert.equal( - referenceSource.clustering._pointCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - 'reference points blend in one pass after the sweep', - ); - env.layer.destroy(env.viewer); - - // The same atomicity holds ACROSS the two marker sources: the layer probes - // both before committing either, so a reshaped reference source can never - // leave the landing source already forced to TRANSLUCENT. - const failEnv = await createRealCableLayerHarness(); - const failLanding = failEnv.dataSources.find((source) => - /Landing Points/.test(source.name || ''), - ); - const failReference = failEnv.dataSources.find((source) => - /References/.test(source.name || ''), - ); - failLanding.clustering._billboardCollection = - new Cesium.BillboardCollection(); - failLanding.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - delete failReference.clustering._pointCollection; // Cesium reshaped - failEnv.raiseSweep(); - assert.equal( - failLanding.clustering._billboardCollection.blendOption, - Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT, - 'a reference-source shape failure leaves the landing source on the default blend', - ); - failEnv.layer.destroy(failEnv.viewer); +test('markers and reference tips each draw in a single translucent pass', async () => { + // Every marker is translucent (alpha < 1), so Cesium's default + // OPAQUE_AND_TRANSLUCENT blend would draw each collection twice. + const env = await createRealCableLayerHarness({ + source: geometrySource(multipartCableFixture(), landingPointFixture(3)), + }); + try { + const { markers, points } = env.parts(); + assert.equal(markers.length, 3); + assert.equal(markers.blendOption, Cesium.BlendOption.TRANSLUCENT); + assert.equal(points.length, 5, 'two cable and three landing references'); + assert.equal(points.blendOption, Cesium.BlendOption.TRANSLUCENT); + } finally { + env.layer.destroy(env.viewer); + } }); test('cable reference sweep gate is dirty-only — no timer path', () => { @@ -536,42 +432,26 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov function makeStemRecord(lon = -40, lat = 35) { const base = Cesium.Cartesian3.fromDegrees(lon, lat, 0); const tip = Cesium.Cartesian3.fromDegrees(lon, lat, 2500); - const stemPositionBuffers = [ - [base, tip], - [base, tip], - ]; - const setCalls = { position: 0, polyline: 0, polylineArrays: [] }; + const moves = []; return { record: { - entity: { - position: { - setValue: () => { - setCalls.position++; - }, - }, - polyline: { - positions: { - setValue: (value) => { - setCalls.polyline++; - setCalls.polylineArrays.push(value); - }, - }, + point: { + set position(value) { + moves.push(Cesium.Cartesian3.clone(value)); }, }, base, tip, nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, reference: { lon, lat }, }, - setCalls, - stemPositionBuffers, + moves, + tip, }; } -test('staticized stems redefine constants only on real tip changes, alternating buffers', () => { - const { record, setCalls, stemPositionBuffers } = makeStemRecord(); +test('a stem tip moves in place, and only on a real tip change', () => { + const { record, moves, tip } = makeStemRecord(); const canvasHeight = 900; const fov = Math.PI / 3; const camera = Cesium.Cartesian3.fromDegrees(-40, 35, 500_000); @@ -580,15 +460,11 @@ test('staticized stems redefine constants only on real tip changes, alternating updateCableReferenceStem(record, camera, canvasHeight, fov), true, ); - assert.equal(setCalls.position, 1); - assert.equal(setCalls.polyline, 1); - assert.equal( - setCalls.polylineArrays[0], - stemPositionBuffers[1], - 'first real update selects the alternate buffer', - ); + assert.equal(moves.length, 1, 'the tip point follows'); + assert.equal(record.tip, tip, 'the same Cartesian the label entry holds'); + assert.ok(Cesium.Cartesian3.equals(moves[0], tip)); - // Sub-epsilon camera noise must not redefine anything. + // Sub-epsilon camera noise must not move anything. const jitter = Cesium.Cartesian3.add( camera, new Cesium.Cartesian3(CABLE_STEM_TIP_EPSILON_M / 10, 0, 0), @@ -598,11 +474,9 @@ test('staticized stems redefine constants only on real tip changes, alternating updateCableReferenceStem(record, jitter, canvasHeight, fov), false, ); - assert.equal(setCalls.position, 1); - assert.equal(setCalls.polyline, 1); + assert.equal(moves.length, 1); - // A real camera move flips back to the first buffer: steady state allocates - // no arrays beyond the two preallocated ones. + // A real camera move does. const moved = Cesium.Cartesian3.add( camera, new Cesium.Cartesian3(0, 0, 400_000), @@ -612,8 +486,8 @@ test('staticized stems redefine constants only on real tip changes, alternating updateCableReferenceStem(record, moved, canvasHeight, fov), true, ); - assert.equal(setCalls.polylineArrays[1], stemPositionBuffers[0]); - assert.equal(new Set(setCalls.polylineArrays).size, 2); + assert.equal(moves.length, 2); + assert.equal(record.tip, tip); // Stem height honors the shipped 700 m..85 km clamp. const tipCarto = Cesium.Cartographic.fromCartesian(record.tip); @@ -664,14 +538,16 @@ test('cables create no native labels and no per-frame geometry callbacks', () => /setOverlayEntries/, 'labels must flow through the shared world-overlay host', ); - assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/); - assert.match( + assert.match(source, /record\.point\.position = record\.tip;/); + assert.doesNotMatch( source, - /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/, + /new Cesium\.CustomDataSource|GeoJsonDataSource\.load|dataSources\.add|new Cesium\.Entity|entities\.add/, + 'reverting to an entity per feature must fail this pin', ); - assert.match( + assert.doesNotMatch( source, - /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/, + /new Cesium\.PolylineCollection/, + 'a PolylineCollection serializes every polyline material on every frame', ); }); @@ -723,49 +599,65 @@ function fixtureForUrl(url) { : CABLE_FIXTURE; } -function makeStubViewer( - listeners, - dataSources, - addCalls = { count: 0 }, - addControl = null, -) { - return { - dataSources: { - // Mirror Cesium's real DataSourceCollection.add(): the returned promise - // resolves — and the collection mutates — on a DEFERRED tick, never in - // the caller's synchronous frame. A remove() issued before the deferred - // add settles therefore removes NOTHING and the add still materializes - // afterwards (empirically verified against Cesium). Tests may hold the - // settle window open via addControl to abort inside it, and may mark an - // add rejected-AFTER-push: Cesium pushes into the collection BEFORE - // raising dataSourceAdded, so a throwing listener rejects the promise - // after the mutation already landed. - add: (source) => { - const callIndex = addCalls.count; - addCalls.count += 1; - return new Promise((resolve, reject) => { - const settle = () => { - dataSources.push(source); - if (addControl?.rejectAfterPush?.has(callIndex)) { - reject( - new Error( - 'dataSourceAdded listener threw after the push landed', - ), - ); - return; - } - resolve(source); - }; - if (addControl?.held) addControl.pending.push(settle); - else Promise.resolve().then(settle); - }); +/** + * A scene stub with the two primitive collections the layer draws into. Like + * Cesium's, a collection destroys what it removes. `built` counts every add, + * so a test can tell "the same primitives" from "rebuilt ones". The height + * methods are what a ground-clamped billboard asks of its scene; `heights` + * counts them. + */ +function makeStubScene(listeners) { + const collection = () => { + const items = []; + return { + items, + built: 0, + add(primitive) { + this.built += 1; + items.push(primitive); + return primitive; }, - remove: (source) => { - const index = dataSources.indexOf(source); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; + remove(primitive) { + const index = items.indexOf(primitive); + if (index < 0) return false; + items.splice(index, 1); + if (!primitive.isDestroyed()) primitive.destroy(); + return true; + }, + }; + }; + const heights = { reads: 0, watches: 0 }; + return { + canvas: { clientHeight: 900 }, + requestRender() {}, + pick() { + return null; + }, + preRender: { + addEventListener: (fn) => { + listeners.preRender.add(fn); + return () => listeners.preRender.delete(fn); }, }, + primitives: collection(), + groundPrimitives: collection(), + frameState: { mode: Cesium.SceneMode.SCENE3D }, + heights, + getHeight() { + heights.reads += 1; + return 0; + }, + updateHeight() { + heights.watches += 1; + return () => {}; + }, + }; +} + +function makeStubViewer(listeners) { + const flights = []; + return { + flights, camera: { positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000), frustum: { fov: Math.PI / 3 }, @@ -777,35 +669,44 @@ function makeStubViewer( }, heading: 0, cancelFlight() {}, - flyTo() {}, - }, - scene: { - canvas: { clientHeight: 900 }, - requestRender() {}, - pick() { - return null; - }, - preRender: { - addEventListener: (fn) => { - listeners.preRender.add(fn); - return () => listeners.preRender.delete(fn); - }, + flyTo(options) { + flights.push(options); }, }, + scene: makeStubScene(listeners), }; } +/** What the layer has in a stub scene, by kind. */ +function sceneParts(scene) { + const of = (collection, type) => + collection.items.filter((primitive) => primitive instanceof type); + return { + lines: of(scene.groundPrimitives, Cesium.GroundPolylinePrimitive)[0], + markers: of(scene.primitives, Cesium.BillboardCollection)[0], + points: of(scene.primitives, Cesium.PointPrimitiveCollection)[0], + stems: of(scene.primitives, Cesium.Primitive), + }; +} + +const sceneSize = (scene) => + scene.primitives.items.length + scene.groundPrimitives.items.length; +const sceneBuilt = (scene) => + scene.primitives.built + scene.groundPrimitives.built; + +/** Node has no GPU, so nothing becomes ready by itself: say a primitive is. */ +function markReady(primitive) { + Object.defineProperty(primitive, 'ready', { value: true }); +} + /** * Harness whose fetches stay PENDING until the test releases them, honoring * the abort signal like real fetch (reject with AbortError on abort). Load * lifecycle races are sequenced deterministically through it. */ -function createDeferredCableLayerHarness() { +function createDeferredCableLayerHarness(options = {}) { const hostCalls = []; const listeners = { preRender: new Set(), moveEnd: new Set() }; - const dataSources = []; - const addCalls = { count: 0 }; - const addControl = { held: false, pending: [], rejectAfterPush: new Set() }; const pendingFetches = []; const abortError = () => Object.assign(new Error('The operation was aborted'), { @@ -853,18 +754,20 @@ function createDeferredCableLayerHarness() { setInputAction() {}, destroy() {}, }), + ...options, }); - const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl); + const viewer = makeStubViewer(listeners); layer.init(viewer); return { layer, viewer, hostCalls, - dataSources, - addCalls, listeners, pendingFetches, + /** Primitives now in the scene, and how many were ever added. */ + size: () => sceneSize(viewer.scene), + built: () => sceneBuilt(viewer.scene), raiseSweep() { for (const fn of listeners.preRender) fn(); }, @@ -872,20 +775,6 @@ function createDeferredCableLayerHarness() { for (const entry of [...pendingFetches]) entry.release(); pendingFetches.length = 0; }, - /** Park every subsequent dataSources.add in its deferred settle window. */ - holdAdds() { - addControl.held = true; - }, - /** Make the Nth add (0-based) push into the collection, THEN reject. */ - failAddAfterPush(callIndex) { - addControl.rejectAfterPush.add(callIndex); - }, - /** Let the held adds mutate the collection (the deferred settle lands). */ - releaseHeldAdds() { - addControl.held = false; - const pending = addControl.pending.splice(0); - for (const settle of pending) settle(); - }, async settle() { // Drain the microtask/timer interleave the load path spans. for (let i = 0; i < 8; i++) @@ -897,53 +786,12 @@ function createDeferredCableLayerHarness() { }; } -/** - * Gate Cesium's GeoJsonDataSource.load: each call runs the REAL load, then - * parks its resolution behind a test-owned gate. Opening gate N and aborting - * in the same synchronous frame lands the abort exactly at the caller's - * post-await ownership check for that boundary. - */ -function installGeoJsonLoadGate() { - const original = Cesium.GeoJsonDataSource.load; - const calls = []; - const gates = []; - Cesium.GeoJsonDataSource.load = function gatedLoad(...args) { - calls.push(args); - let open; - const opened = new Promise((resolve) => { - open = resolve; - }); - gates.push({ open }); - return original - .apply(Cesium.GeoJsonDataSource, args) - .then((result) => opened.then(() => result)); - }; - return { - calls, - open(index) { - gates[index]?.open(); - }, - /** - * Open every gate, including ones created by continuations the opening - * itself unblocks (a load calls the landing build only after the cable - * gate opens), until no new gates appear. - */ - async drainOpen(settle) { - let opened = 0; - while (opened < gates.length) { - for (; opened < gates.length; opened++) gates[opened].open(); - await settle(); - } - }, - restore() { - Cesium.GeoJsonDataSource.load = original; - }, - }; -} +// The fixture's two lines are one ground primitive, and its two references +// one point collection; their stems are built by the first sweep. +const LOADED_PRIMITIVES = 2; -test('an abort landing at the post-fetch boundary stops the stale load before GeoJson work', async () => { +test('an abort landing at the post-fetch boundary stops the stale load before anything is built', async () => { const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); try { env.layer.enable(env.viewer); // Release A's fetches and abort in the SAME frame: A's continuation runs @@ -952,172 +800,39 @@ test('an abort landing at the post-fetch boundary stops the stale load before Ge env.layer.disable(); env.layer.enable(env.viewer); await env.settle(); - assert.equal( - geojson.calls.length, - 0, - 'stale load A must not reach GeoJsonDataSource.load', - ); + assert.equal(env.built(), 0, 'stale load A must build nothing'); env.releaseAll(); await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal( - geojson.calls.length, - 2, - 'load B alone performs the two GeoJson loads', - ); - assert.equal(env.addCalls.count, 3); - assert.equal(env.dataSources.length, 3); + assert.equal(env.built(), LOADED_PRIMITIVES, 'load B alone builds'); + assert.equal(env.size(), LOADED_PRIMITIVES); assert.equal(env.layer.getStats().count, 2); } finally { - geojson.restore(); env.layer.destroy(env.viewer); env.cleanup(); } }); -test('an abort landing at the post-cable-GeoJson boundary stops the stale load before the landing build', async () => { - const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - assert.equal(env.pendingFetches.length, 0); - assert.equal( - geojson.calls.length, - 1, - 'load A is parked on the gated cable GeoJson build', - ); - - // Open the cable gate and abort in the same frame: A resumes at the - // post-cable owns() check with ownership lost. - geojson.open(0); - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); - // A already cached the fetched JSON, so load B skips the network and goes - // straight to its own CABLE build; the only new GeoJson call must be that - // one — a landing build here would mean stale A ran on past its check. - assert.equal( - env.pendingFetches.length, - 0, - 'load B rebuilds from the cached JSON without refetching', - ); - assert.equal( - geojson.calls.length, - 2, - 'stale load A must not start the landing GeoJson build', - ); - assert.ok( - Cesium.Color.equals( - geojson.calls[1][1].stroke, - geojson.calls[0][1].stroke, - ), - "the second GeoJson call is load B's cable build, not A's landing build", - ); - - env.releaseAll(); - await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal( - geojson.calls.length, - 3, - 'load B adds its own two GeoJson builds', - ); - assert.equal(env.addCalls.count, 3); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - geojson.restore(); - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('an abort landing at the pre-add boundary stops the stale load before any dataSources.add', async () => { - const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - geojson.open(0); - await env.settle(); - assert.equal( - geojson.calls.length, - 2, - 'load A is parked on the gated landing GeoJson build', - ); - - // Open the landing gate and abort in the same frame: A resumes at the - // pre-add owns() check with ownership lost. - geojson.open(1); - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); - assert.equal( - env.addCalls.count, - 0, - 'stale load A must never call dataSources.add', - ); - - env.releaseAll(); - await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal(env.addCalls.count, 3, 'load B alone adds the trio'); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - geojson.restore(); - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('a destroy inside the deferred add window leaves zero post-destroy data sources', async () => { +test('a destroy in the same turn as the fetch result leaves zero primitives', async () => { const env = createDeferredCableLayerHarness(); try { env.layer.enable(env.viewer); + // The fetch is the load's only await. A destroy that lands before its + // continuation runs must leave nothing behind for that continuation to + // add: there is no later point at which the load could add anything. env.releaseAll(); - env.holdAdds(); - // Let the load run until all three adds have been CALLED but none has - // settled — Cesium mutates the collection on a deferred tick, so this - // window is real, and a remove() issued inside it removes nothing. - const deadline = Date.now() + 2_000; - while (env.addCalls.count < 3 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.equal( - env.addCalls.count, - 3, - 'all three adds are in their deferred window', - ); - assert.equal( - env.dataSources.length, - 0, - 'nothing has settled into the collection yet', - ); - - // Destroy lands in the window: it can remove nothing, so the stale - // load's own post-add ownership check must compensate once the deferred - // adds settle. env.layer.destroy(env.viewer); - env.releaseHeldAdds(); await env.settle(); - assert.equal( - env.dataSources.length, - 0, - 'the deferred adds settled post-destroy and must be rolled back by their own load', - ); + assert.equal(env.built(), 0, 'the destroyed load built nothing'); + assert.equal(env.size(), 0); // The layer still honors its re-init contract afterwards. env.layer.enable(env.viewer); env.releaseAll(); await env.settle(); assert.equal( - env.dataSources.length, - 3, + env.size(), + LOADED_PRIMITIVES, 're-enable after the raced destroy loads cleanly', ); assert.equal(env.layer.getStats().count, 2); @@ -1127,46 +842,27 @@ test('a destroy inside the deferred add window leaves zero post-destroy data sou } }); -test('a destroy in the deferred window with a rejected-after-push add still leaves zero sources', async () => { - const env = createDeferredCableLayerHarness(); +test('a build that fails part-way leaves nothing in the scene and reports the error', async () => { + // The lines are added to the scene before the markers are made; a failure + // while making the markers must take the lines back out. + const originalWarn = console.warn; + console.warn = () => {}; + const env = await createRealCableLayerHarness({ + source: geometrySource(multipartCableFixture(), landingPointFixture(2)), + createMarkerImage: () => { + throw new Error('no canvas'); + }, + expectError: true, + }); try { - env.layer.enable(env.viewer); - env.releaseAll(); - env.holdAdds(); - // Cesium pushes the source into the collection BEFORE raising - // dataSourceAdded, so a throwing listener rejects the add promise AFTER - // its mutation landed. Make add #1 exactly that: pushed, then rejected; - // #2/#3 fulfill normally. - env.failAddAfterPush(0); - const deadline = Date.now() + 2_000; - while (env.addCalls.count < 3 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.equal( - env.addCalls.count, - 3, - 'all three adds are in their deferred window', - ); - assert.equal( - env.dataSources.length, - 0, - 'nothing has settled into the collection yet', - ); - - // Destroy lands in the window; the adds then settle as - // [rejected-after-push, fulfilled, fulfilled]. Compensation that removes - // only FULFILLED adds would strand the rejected-but-pushed source. - env.layer.destroy(env.viewer); - env.releaseHeldAdds(); - await env.settle(); - assert.equal( - env.dataSources.length, - 0, - 'a rejected add still landed its push, so compensation must remove it too', - ); + assert.equal(env.layer.getStats().error, 'no canvas'); + assert.equal(env.built() > 0, true, 'the lines had been added'); + assert.equal(env.size(), 0, 'and were removed again'); + env.raiseSweep(); + assert.equal(env.size(), 0, 'a sweep of a failed load draws nothing'); } finally { + console.warn = originalWarn; env.layer.destroy(env.viewer); - env.cleanup(); } }); @@ -1176,7 +872,7 @@ test('a direct destroy of a published layer clears and hides its host source', a env.layer.enable(env.viewer); env.releaseAll(); await env.settle(); - assert.equal(env.dataSources.length, 3, 'layer is fully loaded'); + assert.equal(env.size(), LOADED_PRIMITIVES, 'layer is fully loaded'); // Drive the sweep through the layer's real preRender listener so the // host actually holds published cable entries. env.raiseSweep(); @@ -1201,6 +897,7 @@ test('a direct destroy of a published layer clears and hides its host source', a ['visible', CABLE_OVERLAY_SOURCE_ID, false], 'direct destroy must hide the host source after clearing it', ); + assert.equal(env.size(), 0, 'and every primitive is gone'); } finally { env.cleanup(); } @@ -1236,15 +933,15 @@ test("a stale aborted load never clobbers the next load's lifecycle (A/B/C toggl "only load B's two fetches remain in flight — no duplicate load C", ); - // Releasing everything must produce exactly one set of scene adds. + // Releasing everything must produce exactly one set of primitives. env.releaseAll(); await env.settle(); assert.equal( - env.addCalls.count, - 3, - 'exactly one cable/landing/reference data-source trio is added', + env.built(), + LOADED_PRIMITIVES, + 'exactly one set of primitives is built', ); - assert.equal(env.dataSources.length, 3); + assert.equal(env.size(), LOADED_PRIMITIVES); const stats = env.layer.getStats(); assert.equal(stats.error, null); assert.equal(stats.count, 2, 'one cable + one landing point loaded once'); @@ -1279,11 +976,11 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', env.releaseAll(); await env.settle(); assert.equal( - env.addCalls.count, - 3, - 'the destroyed load added nothing; the re-enable added once', + env.built(), + LOADED_PRIMITIVES, + 'the destroyed load built nothing; the re-enable built once', ); - assert.equal(env.dataSources.length, 3); + assert.equal(env.size(), LOADED_PRIMITIVES); assert.equal(env.layer.getStats().count, 2); } finally { env.layer.destroy(env.viewer); @@ -1294,12 +991,17 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', async function createRealCableLayerHarness({ source, mapStackEventTarget = null, + createMarkerImage, + expectError = false, } = {}) { const hostCalls = []; // The sweep gate's motion-fallback clock is FROZEN here and advanced only by // the tests that exercise it, so every other harness test observes the pure // event-dirty behavior with no wall-clock coupling. let clockMs = 0; + const clicks = []; + // Node has no canvas for Cesium's PinBuilder; the marker image is a seam. + const markerImages = []; const layer = createTeleGeographySubmarineCableLayer({ overlayHost: { setVisible: (...args) => hostCalls.push(['visible', ...args]), @@ -1307,102 +1009,30 @@ async function createRealCableLayerHarness({ clearSource: (...args) => hostCalls.push(['clear', ...args]), }, screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, + setInputAction(action) { + clicks.push(action); + }, destroy() {}, }), + createMarkerImage: + createMarkerImage ?? + ((color) => { + const image = { width: 6, height: 6, color: color.toCssHexString() }; + markerImages.push(image); + return image; + }), ...(source ? { source } : {}), mapStackEventTarget, sweepClock: () => clockMs, }); const listeners = { preRender: new Set(), moveEnd: new Set() }; - const dataSources = []; - const viewer = { - dataSources: { - add: (source) => { - dataSources.push(source); - return Promise.resolve(source); - }, - remove: (source) => { - const index = dataSources.indexOf(source); - if (index >= 0) dataSources.splice(index, 1); - return true; - }, - }, - camera: { - positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000), - frustum: { fov: Math.PI / 3 }, - moveEnd: { - addEventListener: (fn) => { - listeners.moveEnd.add(fn); - return () => listeners.moveEnd.delete(fn); - }, - }, - heading: 0, - cancelFlight() {}, - flyTo() {}, - }, - scene: { - canvas: { clientHeight: 900 }, - requestRender() {}, - pick() { - return null; - }, - preRender: { - addEventListener: (fn) => { - listeners.preRender.add(fn); - return () => listeners.preRender.delete(fn); - }, - }, - }, - }; + const viewer = makeStubViewer(listeners); const originalFetch = globalThis.fetch; - // Node has no `document`, so Point features (whose GeoJSON default marker - // needs a canvas PinBuilder) cannot load here. Landing references only need - // `properties.coordinates`, so a line geometry exercises the same path. globalThis.fetch = async (url) => ({ ok: true, - json: async () => - String(url).includes('landing-point') - ? { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'lisbon', - properties: { - id: 'lisbon', - name: 'Lisbon, Portugal', - coordinates: [-9.1, 38.7], - }, - geometry: { - type: 'LineString', - coordinates: [ - [-9.1, 38.7], - [-9.05, 38.72], - ], - }, - }, - ], - } - : { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'ac1', - properties: { id: 'ac1', name: 'Atlantic Crossing-1' }, - geometry: { - type: 'LineString', - coordinates: [ - [-40, 35], - [-30, 40], - ], - }, - }, - ], - }, + json: async () => fixtureForUrl(url), }); try { layer.init(viewer); @@ -1412,10 +1042,11 @@ async function createRealCableLayerHarness({ const deadline = Date.now() + 2_000; while (Date.now() < deadline) { const stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; + if (!stats.loading && (stats.count > 0 || stats.error)) break; await new Promise((resolve) => setTimeout(resolve, 10)); } - assert.equal(layer.getStats().error, null, 'harness load must succeed'); + if (!expectError) + assert.equal(layer.getStats().error, null, 'harness load must succeed'); } finally { globalThis.fetch = originalFetch; } @@ -1429,13 +1060,61 @@ async function createRealCableLayerHarness({ layer, viewer, hostCalls, - dataSources, + markerImages, raiseSweep, listeners, advanceClock, + parts: () => sceneParts(viewer.scene), + size: () => sceneSize(viewer.scene), + built: () => sceneBuilt(viewer.scene), + /** Stem primitives ever built (each is one rebuild of every stem). */ + stemBuilds: () => viewer.scene.primitives.built - stemBuildBase(viewer), + /** A left click at `position`, through the layer's own handler. */ + click(position = { x: 1, y: 1 }) { + for (const action of clicks) action({ position }); + }, }; } +// The marker and point collections are added once, at load; every later add +// to scene.primitives is a stem primitive. +function stemBuildBase(viewer) { + const { markers, points } = sceneParts(viewer.scene); + return Number(Boolean(markers)) + Number(Boolean(points)); +} + +/** Landing points as real GeoJSON Points, all in view of the harness camera. */ +function landingPointFixture(count) { + return Array.from({ length: count }, (_, i) => ({ + type: 'Feature', + id: `landing-${i}`, + properties: { id: `landing-${i}`, name: `Landing ${i}` }, + geometry: { + type: 'Point', + coordinates: [-45 + (i % 20) * 0.5, 30 + Math.floor(i / 20)], + }, + })); +} + +/** + * Stand in for the ready line primitive's per-line attributes: Node never + * builds the geometry, so the show flags the layer writes land here. + */ +function fakeLineAttributes(lines) { + const attributes = new Map( + lines.geometryInstances.map((instance) => [ + instance.id, + { show: Uint8Array.from(instance.attributes.show.value) }, + ]), + ); + lines.getGeometryInstanceAttributes = (id) => attributes.get(id); + markReady(lines); + return () => + lines.geometryInstances.map((instance) => + Boolean(attributes.get(instance.id).show[0]), + ); +} + test('a real enabled cable layer publishes host entries and has zero native labels', async () => { const env = await createRealCableLayerHarness(); env.raiseSweep(); @@ -1452,24 +1131,21 @@ test('a real enabled cable layer publishes host entries and has zero native labe moving: false, }); - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - assert.ok(referenceSource, 'reference data source must exist'); - const referenceEntities = referenceSource.entities.values; - assert.ok(referenceEntities.length > 0); + const { points } = env.parts(); + assert.equal(points.length, 2, 'one reference tip per feature'); assert.ok( - referenceEntities.every((entity) => entity.label === undefined), - 'no reference entity may carry a native LabelGraphics', - ); - assert.ok( - referenceEntities.every( - (entity) => - entity.position?.isConstant === true && - entity.polyline?.positions?.isConstant === true, + env.viewer.scene.primitives.items.every( + (primitive) => !(primitive instanceof Cesium.LabelCollection), ), - 'stem properties must be constants, never per-frame callbacks', + 'no native label collection', ); + // The label entry holds the very Cartesian the stem tip is moved in. + const tips = entries.map((entry) => entry.position); + for (let i = 0; i < points.length; i++) + assert.ok( + tips.some((tip) => Cesium.Cartesian3.equals(tip, points.get(i).position)), + 'each label sits on a reference tip', + ); const stats = env.layer.getStats(); assert.equal(stats.referenceLabelCount, 2); @@ -1488,34 +1164,19 @@ test('a real enabled cable layer publishes host entries and has zero native labe ); env.layer.destroy(env.viewer); - assert.equal( - env.dataSources.length, - 0, - 'destroy must remove every data source', - ); + assert.equal(env.size(), 0, 'destroy must remove every primitive'); }); test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async () => { const env = await createRealCableLayerHarness(); env.raiseSweep(); // initial sizing at the parked camera - - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - const entity = referenceSource.entities.values[0]; - let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind( - entity.polyline.positions, - ); - entity.polyline.positions.setValue = (...args) => { - stemRebuilds++; - return originalSet(...args); - }; + const stemRebuilds = () => env.stemBuilds() - 1; + assert.equal(stemRebuilds(), 0, 'the first sweep builds the stems once'); // Mid-drag: the camera moves every tick and frames keep rendering, but no - // moveEnd fires. However long this goes on, the stem constants must never - // be redefined — each redefinition rebuilt the whole batched stem - // primitive, and its unready replacement frames were the felt hitch. + // moveEnd fires. However long this goes on, the stems must never be + // rebuilt — a stem cannot change length without new geometry, and a + // rebuild per frame was the felt hitch. const camera = env.viewer.camera.positionWC; for (let tick = 0; tick < 30; tick++) { env.viewer.camera.positionWC = Cesium.Cartesian3.add( @@ -1527,14 +1188,14 @@ test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async await new Promise((resolve) => setTimeout(resolve, 25)); } assert.equal( - stemRebuilds, + stemRebuilds(), 0, - 'render ticks without moveEnd must never redefine a stem', + 'render ticks without moveEnd must never rebuild the stems', ); // The gesture ends close over the cable's reference point (inside the // 700 m..85 km stem clamp band, so the re-size is a real tip change): - // one moveEnd, one sweep, exactly one redefinition. + // one moveEnd, one sweep, exactly one rebuild. env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( -35, 37.5, @@ -1542,9 +1203,88 @@ test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async ); for (const fn of env.listeners.moveEnd) fn(); env.raiseSweep(); - assert.equal(stemRebuilds, 1, 'moveEnd re-sizes the stem exactly once'); + assert.equal(stemRebuilds(), 1, 'moveEnd re-sizes the stems exactly once'); env.raiseSweep(); - assert.equal(stemRebuilds, 1, 'the next frame does not re-run the sweep'); + assert.equal(stemRebuilds(), 1, 'the next frame does not re-run the sweep'); + + // A moveEnd that changes nothing builds nothing. + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + assert.equal(stemRebuilds(), 1, 'an unchanged sweep keeps the stems'); + + env.layer.destroy(env.viewer); +}); + +test('rebuilt stems replace the shown ones only once their geometry is ready', async () => { + const env = await createRealCableLayerHarness(); + let renderRequests = 0; + env.viewer.scene.requestRender = () => { + renderRequests++; + }; + env.raiseSweep(); + const [first] = env.parts().stems; + assert.equal(env.parts().stems.length, 1); + assert.equal(first.show, false, 'hidden while it builds'); + assert.equal(first.geometryInstances.length, 2, 'one stem per reference'); + assert.deepEqual( + first.geometryInstances.map((instance) => instance.geometry._width), + [2, 2.4], + 'cable and landing stem widths', + ); + assert.ok(first.appearance instanceof Cesium.PolylineColorAppearance); + assert.equal(first.appearance.translucent, true); + + renderRequests = 0; + env.raiseSweep(); + assert.ok(renderRequests > 0, 'frames are asked for until it is ready'); + markReady(first); + env.raiseSweep(); + assert.equal(first.show, true, 'shown once ready'); + renderRequests = 0; + env.raiseSweep(); + assert.equal(renderRequests, 0, 'settled stems ask for no frames'); + + // A camera move re-sizes the stems: the shown ones stay until the + // replacement is ready. + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -35, + 37.5, + 400_000, + ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + const [, second] = env.parts().stems; + assert.deepEqual(env.parts().stems, [first, second]); + assert.equal(first.show, true); + assert.equal(second.show, false); + + // Superseded before it was ready: it is dropped, never shown. + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -35, + 37.5, + 300_000, + ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + const [, third] = env.parts().stems; + assert.deepEqual(env.parts().stems, [first, third]); + assert.equal(second.isDestroyed(), true); + + markReady(third); + env.raiseSweep(); + assert.deepEqual(env.parts().stems, [third], 'the old stems are removed'); + assert.equal(third.show, true); + assert.equal(first.isDestroyed(), true); + + // Every reference over the horizon: no stems at all. + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 140, + -35, + 4_500_000, + ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + assert.deepEqual(env.parts().stems, []); env.layer.destroy(env.viewer); }); @@ -1561,19 +1301,7 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' 400_000, ); env.raiseSweep(); // initial sizing at the tracking handoff - - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - const entity = referenceSource.entities.values[0]; - let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind( - entity.polyline.positions, - ); - entity.polyline.positions.setValue = (...args) => { - stemRebuilds++; - return originalSet(...args); - }; + const stemRebuilds = () => env.stemBuilds() - 1; // Entity tracking / orbit: the follow camera advances every frame and // moveEnd NEVER fires. Frames are simulated at 60 Hz on the harness clock. @@ -1592,10 +1320,10 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' }; // Inside the first probe window the sweep stays event-dirty-only, so the - // batched stem primitive is not rebuilt mid-motion (the shipped tradeoff). + // stems are not rebuilt mid-motion (the shipped tradeoff). trackFrames(0, 60); assert.equal( - stemRebuilds, + stemRebuilds(), 0, 'inside the probe window a tracked camera still costs no rebuilds', ); @@ -1603,7 +1331,7 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' // Past the window the fallback fires — exactly once, not per frame. trackFrames(60, 130); assert.equal( - stemRebuilds, + stemRebuilds(), 1, 'the tracked camera earns exactly one fallback sweep per window', ); @@ -1618,7 +1346,7 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' }; parkFrames(240); // 4 s parked: two full probe windows assert.equal( - stemRebuilds, + stemRebuilds(), 2, 'a stopped camera settles its residual motion in one further sweep', ); @@ -1626,7 +1354,7 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' // ...and the parked camera then costs zero sweeps for as long as it sits. parkFrames(3600); // 60 s parked assert.equal( - stemRebuilds, + stemRebuilds(), 2, 'a settled parked camera never spends another sweep', ); @@ -1676,17 +1404,21 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i env.layer.destroy(env.viewer); }); -test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => { +test('a toggle-off hides the primitives and a toggle-on shows the same ones without refetching or rebuilding', async () => { const env = await createRealCableLayerHarness(); - assert.equal( - env.dataSources.length, - 3, - 'cable + landing + reference sources while on', - ); - const before = env.layer.getStats().count; - assert.equal(before, 2); + env.raiseSweep(); + markReady(env.parts().stems[0]); + env.raiseSweep(); + const shown = () => { + const { lines, points, stems } = env.parts(); + return [lines.show, points.show, ...stems.map((stem) => stem.show)]; + }; + const { lines, points, stems } = env.parts(); + const built = env.built(); + assert.deepEqual(shown(), [true, true, true]); + assert.equal(env.layer.getStats().count, 2); - // Any fetch after this point is a bug: the rebuild must come from the cache. + // Any fetch after this point is a bug: nothing is loaded twice. const originalFetch = globalThis.fetch; let fetches = 0; globalThis.fetch = async () => { @@ -1695,37 +1427,40 @@ test('a toggle-off releases every data source and a toggle-on rebuilds them with }; try { env.layer.disable(); - assert.equal( - env.dataSources.length, - 0, - 'hidden entities are not free; disable must remove the sources', + assert.deepEqual( + shown(), + [false, false], + 'a hidden primitive is skipped by the scene: disable hides, it does not free', ); + assert.equal(env.parts().lines, lines); + assert.equal(env.parts().points, points); + // Stems are sized for the view; kept ones would show stale on enable. + assert.equal(stems[0].isDestroyed(), true, 'only the stems are dropped'); + env.raiseSweep(); + assert.deepEqual(shown(), [false, false], 'a frame while off'); + assert.equal(env.built(), built, 'and builds nothing'); env.layer.enable(env.viewer); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline && env.dataSources.length < 3) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.equal(fetches, 0, 'the rebuild must not touch the network'); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(shown(), [true, true]); + env.raiseSweep(); + assert.equal(fetches, 0, 're-enable must not touch the network'); assert.equal(env.layer.getStats().error, null); - assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio'); - assert.equal(env.layer.getStats().count, before); - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - assert.ok( - referenceSource?.entities.values.length > 0, - 'reference stems are rebuilt', - ); + assert.equal(env.parts().lines, lines, 'the very same lines'); + assert.equal(env.parts().points, points, 'and reference tips'); + assert.equal(env.built(), built + 1, 'only the stems are redrawn'); + assert.equal(env.parts().stems.length, 1); + assert.equal(env.layer.getStats().count, 2); env.layer.destroy(env.viewer); - assert.equal(env.dataSources.length, 0); + assert.equal(env.size(), 0); + assert.ok(lines.isDestroyed() && points.isDestroyed()); } finally { globalThis.fetch = originalFetch; } }); -test('another GeoJSON source renders with its own label and rebuilds from accepted cache', async () => { +test('another GeoJSON source renders with its own label and is read once', async () => { let reads = 0; const source = { label: 'Test cable directory', @@ -1739,17 +1474,13 @@ test('another GeoJSON source renders with its own label and rebuilds from accept try { assert.equal(env.layer.source, source.label); assert.equal(reads, 1); - assert.equal(env.dataSources.length, 3); - assert.ok( - env.dataSources.every((item) => item.name.startsWith(source.label)), - ); + assert.equal(env.size(), LOADED_PRIMITIVES); env.layer.disable(); - assert.equal(env.dataSources.length, 0); env.layer.enable(env.viewer); for (let i = 0; i < 8; i++) await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(env.layer.getStats().count, 2); - assert.equal(env.dataSources.length, 3); + assert.equal(env.size(), LOADED_PRIMITIVES); assert.equal(reads, 1); } finally { env.layer.destroy(env.viewer); @@ -1815,6 +1546,16 @@ function geometrySource(cables, landingPoints = []) { }; } +const lineLabels = (env) => + env.parts().lines.geometryInstances.map((instance) => instance.id.label); +const referenceIds = (env) => { + const { points } = env.parts(); + return Array.from( + { length: points.length }, + (_, i) => points.get(i).id.featureId, + ); +}; + test('multipart association accepts frozen provider collections without modifying properties', async () => { const payload = { cables: { type: 'FeatureCollection', features: multipartCableFixture() }, @@ -1823,8 +1564,6 @@ test('multipart association accepts frozen provider collections without modifyin features: multipartCableFixture(), }, }; - payload.cables.features[0].properties.__gevSourceFeatureIndex = - 'provider value'; const before = structuredClone(payload); const freeze = (value) => { if (value && typeof value === 'object') { @@ -1838,19 +1577,14 @@ test('multipart association accepts frozen provider collections without modifyin source: { label: 'Frozen multipart source', fetch: async () => payload }, }); try { - for (const dataSource of env.dataSources.slice(0, 2)) { - assert.deepEqual( - dataSource.entities.values.map( - (entity) => entity.__gevTeleGeography?.label, - ), - ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], - ); - } - assert.equal(env.dataSources[2].entities.values.length, 4); + const part = ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable']; + assert.deepEqual(lineLabels(env), [...part, ...part]); + assert.deepEqual( + env.parts().lines.geometryInstances.map((instance) => instance.id.kind), + [...Array(4).fill('cable'), ...Array(4).fill('landing-point')], + ); + assert.equal(env.parts().points.length, 4); assert.deepEqual(payload, before); - const entity = env.dataSources[0].entities.values[0]; - assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); - assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); } finally { env.layer.destroy(env.viewer); } @@ -1863,34 +1597,82 @@ test('multipart cables retain their own styling, picking and culling on every se source: geometrySource(features), }); try { - const entities = env.dataSources[0].entities.values; - assert.equal(entities.length, 4); - for (const [index, entity] of entities.entries()) { + const { lines } = env.parts(); + const instances = lines.geometryInstances; + assert.equal(instances.length, 4); + assert.equal(new Set(instances.map((instance) => instance.id)).size, 4); + for (const [index, instance] of instances.entries()) { const feature = features[index < 3 ? 0 : 1]; - const info = entity.__gevTeleGeography; - assert.equal(info?.label, feature.properties.name, entity.id); + const info = instance.id; + assert.equal(info.label, feature.properties.name); + assert.equal(info.kind, 'cable'); assert.deepEqual(info.reference, { lon: feature.properties.coordinates[0], lat: feature.properties.coordinates[1], }); assert.deepEqual( - entity.polyline.material.color.getValue(), - Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( - 0.92, + [...instance.attributes.color.value], + Cesium.Color.fromCssColorString(feature.properties.color) + .withAlpha(0.92) + .toBytes(), + ); + assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry); + assert.equal(instance.geometry.width, 2.5); + assert.equal(instance.geometry.arcType, Cesium.ArcType.RHUMB); + // A float attribute beside the width keeps 2.5 px from being stored as + // a byte (2 px): the entity display's lines carried this one too. + assert.equal( + instance.attributes.distanceDisplayCondition.componentDatatype, + Cesium.ComponentDatatype.FLOAT, + ); + } + + // Picking: a click on any segment flies to its cable's reference. + for (const instance of instances) { + env.viewer.scene.pick = () => ({ primitive: lines, id: instance.id }); + env.click(); + const flight = env.viewer.flights.at(-1); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + flight.destination, + Cesium.Cartesian3.fromDegrees( + instance.id.reference.lon, + instance.id.reference.lat, + 6500, + ), + 1e-9, ), ); - assert.equal(entity.polyline.width.getValue(), 2.5); } + const flights = env.viewer.flights.length; + assert.equal(flights, 4); + // A pick that is not one of the layer's own records is not acted on, + // whatever it looks like. + env.viewer.scene.pick = () => ({ + id: { kind: 'cable', reference: { lon: 1, lat: 2 }, label: 'Foreign' }, + }); + env.click(); + env.viewer.scene.pick = () => null; + env.click(); + assert.equal(env.viewer.flights.length, flights); + + // Culling: each segment follows its cable's reference over the horizon. + // The show flags are written once the line geometry is ready. env.raiseSweep(); - assert.deepEqual( - entities.map((entity) => entity.show), - [true, true, true, false], - ); - assert.equal( - env.dataSources[2].entities.values.length, - 2, - 'one reference per feature', + const shows = fakeLineAttributes(lines); + assert.deepEqual(shows(), [true, true, true, true], 'not written yet'); + env.raiseSweep(); + assert.deepEqual(shows(), [true, true, true, false]); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 140, + -35, + 4_500_000, ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + assert.deepEqual(shows(), [false, false, false, true]); + + assert.equal(env.parts().points.length, 2, 'one reference per feature'); assert.equal( JSON.stringify(features), before, @@ -1901,7 +1683,7 @@ test('multipart cables retain their own styling, picking and culling on every se } }); -test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { +test('multipart association supports missing and repeated IDs', async () => { const features = multipartCableFixture(); features[1].id = 'alpha_2'; delete features[1].properties.id; @@ -1917,28 +1699,20 @@ test('multipart association supports missing and repeated IDs without guessing C source: geometrySource(features), }); try { - const entities = env.dataSources[0].entities.values; - assert.deepEqual( - entities.map((entity) => entity.id), - ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], - ); - assert.deepEqual( - entities.map((entity) => entity.__gevTeleGeography?.label), - [ - 'Alpha cable', - 'Alpha cable', - 'Alpha cable', - 'Beta cable', - 'Generated cable', - 'Repeated ID cable', - ], - ); - assert.deepEqual( - env.dataSources[2].entities.values.map( - (entity) => entity.__gevTeleGeography.featureId, - ), - ['alpha', 'alpha_2', 'cable-2', 'alpha'], - ); + assert.deepEqual(lineLabels(env), [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ]); + assert.deepEqual(referenceIds(env), [ + 'alpha', + 'alpha_2', + 'cable-2', + 'alpha', + ]); } finally { env.layer.destroy(env.viewer); } @@ -1950,22 +1724,86 @@ test('multipart landing geometry keeps every pick with one reference per feature source: geometrySource([], features), }); try { - const entities = env.dataSources[1].entities.values; - assert.deepEqual( - entities.map((entity) => entity.__gevTeleGeography?.label), - ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], - ); + const instances = env.parts().lines.geometryInstances; + assert.deepEqual(lineLabels(env), [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + ]); assert.ok( - entities.every( - (entity) => entity.__gevTeleGeography.kind === 'landing-point', - ), + instances.every((instance) => instance.id.kind === 'landing-point'), ); - assert.equal(env.dataSources[2].entities.values.length, 2); + // Landing lines keep the GeoJSON stroke: the landing colour, 2 px. + assert.ok(instances.every((instance) => instance.geometry.width === 2)); + assert.equal(env.parts().points.length, 2); } finally { env.layer.destroy(env.viewer); } }); +test('every geometry type a feature can carry is drawn as lines and markers', () => { + const feature = (geometry) => ({ type: 'Feature', geometry }); + assert.deepEqual( + featureParts(feature({ type: 'Point', coordinates: [1, 2] })), + { lines: [], points: [[1, 2]] }, + ); + assert.deepEqual( + featureParts( + feature({ + type: 'MultiPoint', + coordinates: [ + [1, 2, 30], + ['x', 2], + [3, 4], + ], + }), + ), + { + lines: [], + points: [ + [1, 2, 30], + [3, 4], + ], + }, + 'a height is kept; a position that is not numbers is skipped', + ); + const line = [ + [0, 0], + [1, 1], + ]; + assert.deepEqual( + featureParts(feature({ type: 'LineString', coordinates: line })), + { lines: [line], points: [] }, + ); + assert.deepEqual( + featureParts( + feature({ type: 'MultiLineString', coordinates: [line, [[5, 5]], line] }), + ), + { lines: [line, line], points: [] }, + 'a part with fewer than two positions is not a line', + ); + assert.deepEqual( + featureParts( + feature({ + type: 'GeometryCollection', + geometries: [ + { type: 'Point', coordinates: [9, 9] }, + { type: 'LineString', coordinates: line }, + { + type: 'Polygon', + coordinates: [[...line, [1, 0], [0, 0]]], + }, + ], + }), + ), + { lines: [line], points: [[9, 9]] }, + 'polygons are not drawn', + ); + assert.deepEqual(featureParts(null), { lines: [], points: [] }); + assert.deepEqual(featureParts(feature(null)), { lines: [], points: [] }); +}); + test('every bundled cable segment is associated with its source feature', async () => { const { features } = JSON.parse( readFileSync( @@ -1985,72 +1823,106 @@ test('every bundled cable segment is associated with its source feature', async source: geometrySource(features), }); try { - const entities = env.dataSources[0].entities.values; - assert.equal(entities.length, expandedFeatures.length); - for (const [index, entity] of entities.entries()) { + const instances = env.parts().lines.geometryInstances; + assert.equal(instances.length, expandedFeatures.length); + for (const [index, instance] of instances.entries()) { const feature = expandedFeatures[index]; - assert.equal( - entity.__gevTeleGeography?.label, - feature.properties.name, - entity.id, - ); - assert.deepEqual(entity.__gevTeleGeography.reference, { + assert.equal(instance.id.label, feature.properties.name); + assert.deepEqual(instance.id.reference, { lon: feature.properties.coordinates[0], lat: feature.properties.coordinates[1], }); } - assert.equal(env.dataSources[2].entities.values.length, features.length); + assert.equal(env.parts().points.length, features.length); + assert.equal(env.built(), LOADED_PRIMITIVES, 'in two primitives'); } finally { env.layer.destroy(env.viewer); } }); -test('landing markers are revealed a few per frame, never all in one', async () => { - // Node has no canvas for GeoJSON point pins, so short lines stand in for - // the markers (see createRealCableLayerHarness); the staging is the same. - const count = CABLE_LANDING_REVEALS_PER_FRAME * 2 + 10; - const landingPoints = Array.from({ length: count }, (_, i) => { - const lon = -45 + (i % 20) * 0.5; - const lat = 30 + Math.floor(i / 20); - return { - type: 'Feature', - id: `landing-${i}`, - properties: { id: `landing-${i}`, name: `Landing ${i}` }, - geometry: { - type: 'LineString', - coordinates: [ - [lon, lat], - [lon + 0.01, lat + 0.01], - ], - }, - }; - }); +test('the bundled landing points are one billboard collection of small pins', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/landing-point-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); const env = await createRealCableLayerHarness({ - source: geometrySource([], landingPoints), + source: geometrySource([], features), + }); + try { + const { markers, points } = env.parts(); + assert.equal(markers.length, features.length); + assert.equal(points.length, features.length); + assert.deepEqual( + env.markerImages, + [{ width: 6, height: 6, color: '#8fffd2' }], + 'one image for every landing marker, in the landing colour', + ); + for (let i = 0; i < markers.length; i += 97) { + const marker = markers.get(i); + assert.equal(marker.id.kind, 'landing-point'); + assert.equal(marker.id.label, features[i].properties.name); + assert.equal(marker.verticalOrigin, Cesium.VerticalOrigin.BOTTOM); + assert.equal( + marker.disableDepthTestDistance, + undefined, + 'depth tested, as the entity display left it', + ); + assert.equal(marker.show, false, 'hidden until it is on the ground'); + } + assert.equal(env.viewer.scene.heights.reads, 0, 'no height read at load'); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('landing markers are put on the ground a few per frame, once each', async () => { + const count = CABLE_LANDING_REVEALS_PER_FRAME * 2 + 10; + const env = await createRealCableLayerHarness({ + source: geometrySource([], landingPointFixture(count)), }); try { let renderRequests = 0; env.viewer.scene.requestRender = () => { renderRequests++; }; - const entities = env.dataSources[1].entities.values; - const shown = () => entities.filter((entity) => entity.show).length; - assert.equal(entities.length, count); + const { heights } = env.viewer.scene; + const { markers } = env.parts(); + const all = Array.from({ length: markers.length }, (_, i) => + markers.get(i), + ); + const shown = () => all.filter((marker) => marker.show).length; + const clamped = () => + all.filter( + (marker) => + marker.heightReference === Cesium.HeightReference.CLAMP_TO_GROUND, + ).length; + assert.equal(all.length, count); assert.equal(shown(), 0, 'nothing is shown before the first sweep'); + assert.equal(heights.reads, 0, 'and no terrain height has been read'); env.raiseSweep(); assert.equal(shown(), CABLE_LANDING_REVEALS_PER_FRAME); + assert.equal(heights.reads, CABLE_LANDING_REVEALS_PER_FRAME); assert.ok(renderRequests > 0, 'another frame is asked for'); env.raiseSweep(); assert.equal(shown(), CABLE_LANDING_REVEALS_PER_FRAME * 2); + assert.equal(heights.reads, CABLE_LANDING_REVEALS_PER_FRAME * 2); env.raiseSweep(); assert.equal(shown(), count, 'every visible marker ends up shown'); + assert.equal(clamped(), count, 'on the ground'); + assert.equal(heights.reads, count, 'one height read per marker'); + assert.equal( + heights.watches, + count, + 'Cesium follows the terrain from here', + ); - renderRequests = 0; - env.raiseSweep(); - assert.equal(renderRequests, 0, 'a finished reveal asks for no frames'); - - // Markers over the horizon are hidden at once and are not revealed. + // Markers over the horizon are hidden at once... env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( 140, -35, @@ -2059,6 +1931,29 @@ test('landing markers are revealed a few per frame, never all in one', async () for (const fn of env.listeners.moveEnd) fn(); env.raiseSweep(); assert.equal(shown(), 0); + + // ...and back in view they all show in the same frame: a marker is put + // on the ground once, so showing it again reads nothing. + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -40, + 35, + 4_500_000, + ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + assert.equal(shown(), count); + + // So does a disable and enable. + env.layer.disable(); + assert.equal(markers.show, false); + env.layer.enable(env.viewer); + await new Promise((resolve) => setTimeout(resolve, 10)); + env.raiseSweep(); + assert.equal(markers.show, true); + assert.equal(shown(), count); + assert.equal(heights.reads, count, 'no marker was read twice'); + assert.equal(heights.watches, count); + assert.equal(env.parts().markers, markers, 'the same collection'); } finally { env.layer.destroy(env.viewer); } diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index b4b434710..b41fa33a5 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -77,3 +77,58 @@ 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; +} diff --git a/src/layers/submarineCables/index.js b/src/layers/submarineCables/index.js index 5de4fe6e2..989420077 100644 --- a/src/layers/submarineCables/index.js +++ b/src/layers/submarineCables/index.js @@ -10,6 +10,7 @@ export function createSubmarineCableLayer({ screenSpaceEventHandlerFactory, mapStackEventTarget = null, sweepClock = defaultSweepClock, + createMarkerImage, }) { if (!source?.fetch || !source.label) throw new TypeError( @@ -23,6 +24,7 @@ export function createSubmarineCableLayer({ source, screenSpaceEventHandlerFactory, mapStackEventTarget, + createMarkerImage, }; parts.rendering = createRendering(context); parts.ingestion = createIngestion(context); @@ -40,7 +42,6 @@ export { export { cableClassificationTypeForStack, cableClassificationTypeForScene, - applyTranslucentMarkerBlend, } from './surface.js'; export { CABLE_REFERENCE_LABEL_WINNER_CAP, diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index fb9f1888c..c68a38872 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -1,27 +1,82 @@ import { msg, t } from '../../i18n/index.js'; import { featureLabel, + featureParts, featureReference, normalizeFeatures, } from './geometry.js'; import * as Cesium from 'cesium'; -const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; - -function indexedCollection(features) { - return { - type: 'FeatureCollection', - features: features.map((feature, index) => ({ - ...feature, - properties: { - ...feature.properties, - [SOURCE_FEATURE_INDEX]: index, - }, - })), - }; -} +const toCartesian = ([lon, lat, height]) => + Cesium.Cartesian3.fromDegrees(lon, lat, height ?? 0); 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; @@ -30,10 +85,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 every await and + // one is still settling. The stale load must bail after its 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 data sources got added. + // or post-destroy primitives would get added. const generation = ++state._loadGeneration; const abort = new AbortController(); state._abort = abort; @@ -41,175 +96,34 @@ export function createIngestion({ state, parts, source }) { generation === state._loadGeneration && !abort.signal.aborted; try { - 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 { 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', + ); } const cableFeatures = normalizeFeatures(cableJson, 'cable'); const landingFeatures = normalizeFeatures(landingJson, 'landing'); - - 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; + try { + parts.rendering.buildScene( + viewer, + describeScene(cableFeatures, landingFeatures), + ); + } catch (error) { + // Leave nothing half built in the scene. + parts.rendering.destroyScene(viewer); + throw error; } - // 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); - // Hidden until the sweep reveals it: a shown landing marker reads the - // terrain height as Cesium creates it, and all of them at once froze - // the page (see CABLE_LANDING_REVEALS_PER_FRAME). - entity.show = false; - parts.interaction.registerPickEntity(entity, { - kind: 'landing-point', - reference, - label: featureLabel(feature), - }); - state._surfaceRecords.push({ - entity, - base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), - staged: true, - }); - 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(); diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js index aaa4f8aad..531b6dce1 100644 --- a/src/layers/submarineCables/interaction.js +++ b/src/layers/submarineCables/interaction.js @@ -2,9 +2,10 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; export function createInteraction({ state, screenSpaceEventHandlerFactory }) { - function registerPickEntity(entity, info) { - entity.__gevTeleGeography = info; - state._pickByEntity.set(entity, info); + /** Every drawn part carries its own record as the Cesium pick id. */ + function registerPick(info) { + state._pickRecords.add(info); + return info; } function beginInteraction(viewer) { @@ -22,32 +23,8 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) { } function resolvePickRecord(picked) { - 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; + const info = picked?.id; + return info && state._pickRecords.has(info) ? info : null; } function flyToReference(viewer, reference) { @@ -70,7 +47,7 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) { }); } return { - registerPickEntity, + registerPick, beginInteraction, resolvePickRecord, flyToReference, diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js index 38dc7ba8c..f77757408 100644 --- a/src/layers/submarineCables/lifecycle.js +++ b/src/layers/submarineCables/lifecycle.js @@ -39,8 +39,11 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { // 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; - // Landing markers left over from the last sweep, a few per frame. + // 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; parts.rendering.updateReferenceVisibility(); }, @@ -78,10 +81,15 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { state._loading = false; state._loadingLabel = ''; } - // 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); + // 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?.(); }, update(viewer) { @@ -90,9 +98,7 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { destroy(viewer) { if (state._abort) state._abort.abort(); - parts.rendering.releaseDataSources(viewer || state._viewer); - state._cachedCableJson = null; - state._cachedLandingJson = null; + parts.rendering.destroyScene(viewer || state._viewer); // 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. @@ -126,16 +132,6 @@ 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(); }, diff --git a/src/layers/submarineCables/overlay.js b/src/layers/submarineCables/overlay.js index a51d2769b..59072da4d 100644 --- a/src/layers/submarineCables/overlay.js +++ b/src/layers/submarineCables/overlay.js @@ -41,7 +41,7 @@ export function selectCableReferenceLabelWinners( .sort( (a, b) => a.distanceM - b.distanceM || - String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')), + String(a.id || '').localeCompare(String(b.id || '')), ) .slice(0, cap); } @@ -248,16 +248,15 @@ export function createCableReferenceSweepGate({ } /** - * 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. + * 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. * @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. + * @returns {boolean} True when the tip moved. */ export function updateCableReferenceStem( @@ -289,13 +288,7 @@ export function updateCableReferenceStem( 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); + record.point.position = record.tip; return true; } diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js index 5e875c718..f7feb259e 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -1,5 +1,3 @@ -import * as Cesium from 'cesium'; - export const BASE_CABLE_COLOR = '#39d5ff'; export const BASE_LANDING_COLOR = '#8fffd2'; @@ -67,15 +65,21 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({ }); /** - * Landing markers shown per rendered frame. Each one is a ground-clamped - * billboard, and Cesium reads the terrain height when it creates one. - * Showing all 1,917 in the frame after the layer was switched on did those - * reads in 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. + * 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 @@ -91,19 +95,6 @@ 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()` diff --git a/src/layers/submarineCables/rendering.js b/src/layers/submarineCables/rendering.js index fffbce968..080487376 100644 --- a/src/layers/submarineCables/rendering.js +++ b/src/layers/submarineCables/rendering.js @@ -1,9 +1,8 @@ import * as Cesium from 'cesium'; import { - probeTranslucentMarkerBlend, - commitTranslucentMarkerBlend, -} from './surface.js'; -import { CABLE_LANDING_REVEALS_PER_FRAME } from './policy.js'; + CABLE_LANDING_REVEALS_PER_FRAME, + CABLE_MARKER_SIZE, +} from './policy.js'; import { clampLabel, createCableOverlayEntry, @@ -12,7 +11,13 @@ import { cableReferencePriority, } from './overlay.js'; -export function createRendering({ state }) { +const defaultMarkerImage = (color) => + new Cesium.PinBuilder().fromColor(color, CABLE_MARKER_SIZE); + +export function createRendering({ + state, + createMarkerImage = defaultMarkerImage, +}) { function resetPublishSignature() { state._lastPublishedIds.length = 0; state._lastPublishedPriorities.length = 0; @@ -20,130 +25,131 @@ export function createRendering({ state }) { } /** - * 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. + * 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. */ - function releaseDataSources(viewer) { - const sources = [ - state._cableDataSource, - state._landingDataSource, - state._referenceDataSource, - ]; - state._cableDataSource = null; - state._landingDataSource = null; - state._referenceDataSource = null; + function buildScene(viewer, { lines, markers, references }) { + const scene = viewer.scene; + state._lineRecords = []; + state._markerRecords = []; state._referenceRecords = []; - state._surfaceRecords = []; state._pendingLandingReveals.length = 0; - 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 */ - } - } - } - 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.", + 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, + }); } - return; } - 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; + 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); } - 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; + state._lineShowDirty = false; } 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, @@ -156,42 +162,31 @@ export function createRendering({ state }) { label, featureId: feature?.id || feature?.properties?.id || null, }; + state._pickRecords.add(info); const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94); - 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'}`, + // 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, - 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, - }, + pixelSize: kind === 'cable' ? 7 : 8, + color: pointColor, + outlineColor: Cesium.Color.BLACK.withAlpha(0.65), + outlineWidth: 1, + disableDepthTestDistance: 0, + show: false, }); - - entity.__gevTeleGeography = info; - state._pickByEntity.set(entity, info); const record = { - id: entity.id, - entity, + id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`, + info, + point, base, tip, nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, + stemColor: Cesium.ColorGeometryInstanceAttribute.fromColor( + color.withAlpha(kind === 'cable' ? 0.58 : 0.68), + ), + stemWidth: kind === 'cable' ? 2 : 2.4, reference, kind, label: clampLabel(label), @@ -204,10 +199,192 @@ export function createRendering({ state }) { } /** - * Show the next few landing markers the last sweep found visible, 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. + * 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() { @@ -216,9 +393,13 @@ export function createRendering({ state }) { let budget = CABLE_LANDING_REVEALS_PER_FRAME; while (pending.length && budget > 0) { const record = pending.pop(); - if (!record.visible || record.entity.show) continue; - record.entity.show = true; - budget--; + 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?.(); } @@ -238,22 +419,33 @@ export function createRendering({ state }) { const visible = occluder.isPointVisible(record.base); record.visible = visible; record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base); - if (record.entity.show !== visible) { - record.entity.show = visible; + if (record.point.show !== visible) { + record.point.show = visible; + 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); + // 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; } + 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._surfaceRecords) { + for (const record of state._markerRecords) { const visible = occluder.isPointVisible(record.base); record.visible = visible; - if (record.entity.show === visible) continue; - // Landing markers are shown a few per frame; hiding is immediate. - if (visible && record.staged) state._pendingLandingReveals.push(record); - else record.entity.show = 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; } revealLandingMarkers(); const winners = selectCableReferenceLabelWinners(state._referenceRecords); @@ -273,9 +465,6 @@ export function createRendering({ state }) { } 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; @@ -288,13 +477,13 @@ export function createRendering({ state }) { } return { resetPublishSignature, - releaseDataSources, + buildScene, + destroyScene, updateVisibility, - styleCableEntity, - applyMarkerBlendOnce, applyCableClassification, - styleLandingEntity, - addReferenceStem, + syncLineShow, + settleStems, + dropStems, revealLandingMarkers, updateReferenceVisibility, }; diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js index e447c5030..ccfeabaf3 100644 --- a/src/layers/submarineCables/state.js +++ b/src/layers/submarineCables/state.js @@ -30,32 +30,51 @@ export function createState({ sweepClock, overlayHost }) { state._loadGeneration = 0; - state._cableDataSource = null; - - state._landingDataSource = null; - - state._referenceDataSource = null; - /** - * 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. + * 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. */ - state._cachedCableJson = null; + state._cablePrimitive = null; - state._cachedLandingJson = 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._referenceRecords = []; - state._surfaceRecords = []; + /** One record per drawn line: its pick record, horizon base and show state. */ - /** Landing records the last sweep found visible but not shown yet; drained + 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._clickHandler = null; state._preRenderRemover = null; @@ -68,14 +87,6 @@ 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({ diff --git a/src/layers/submarineCables/surface.js b/src/layers/submarineCables/surface.js index 36556bbb3..515698681 100644 --- a/src/layers/submarineCables/surface.js +++ b/src/layers/submarineCables/surface.js @@ -1,8 +1,5 @@ import * as Cesium from 'cesium'; -import { - CABLE_GLOBE_STACK_IDS, - MARKER_COLLECTION_EXPECTATIONS, -} from './policy.js'; +import { CABLE_GLOBE_STACK_IDS } from './policy.js'; /** * Ground-line classification for one map stack. The photoreal stack renders @@ -10,7 +7,7 @@ import { * 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 batched + * Classifying against just the active surface halves the * 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. @@ -40,87 +37,3 @@ 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); -}