diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6073b5c84..5c8c4756e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8158,6 +8158,12 @@ Cesium's multipart suffixes. Each segment gets its own feature's styling, click destination and horizon-culling reference, and each source feature has one reference stem. The source collections are not modified. +Landing markers are ground-clamped billboards, 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 +immediate. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 110f65f18..b57055290 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; 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 { CABLE_LABEL_DEPTH_DECISION, CABLE_OVERLAY_COLLISION_CAPACITY, @@ -2003,3 +2004,62 @@ test('every bundled cable segment is associated with its source feature', async 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], + ], + }, + }; + }); + const env = await createRealCableLayerHarness({ + source: geometrySource([], landingPoints), + }); + 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); + assert.equal(shown(), 0, 'nothing is shown before the first sweep'); + + env.raiseSweep(); + assert.equal(shown(), 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); + env.raiseSweep(); + assert.equal(shown(), count, 'every visible marker ends up shown'); + + 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. + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 140, + -35, + 4_500_000, + ); + for (const fn of env.listeners.moveEnd) fn(); + env.raiseSweep(); + assert.equal(shown(), 0); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index 714b8753b..fb9f1888c 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -185,6 +185,10 @@ export function createIngestion({ state, parts, source }) { 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, @@ -193,6 +197,7 @@ export function createIngestion({ state, parts, source }) { state._surfaceRecords.push({ entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), + staged: true, }); if (landingReferences.has(index)) return; landingReferences.add(index); diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js index 39e41f630..38dc7ba8c 100644 --- a/src/layers/submarineCables/lifecycle.js +++ b/src/layers/submarineCables/lifecycle.js @@ -38,12 +38,10 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) { // never emit moveEnd — reach the motion fallback. No render is // requested for a fallback sweep: it only fires while the camera is // already moving, so frames are flowing by construction. - if ( - !state._enabled || - !state._loaded || - !state._referenceSweepGate.shouldRun(viewer.camera) - ) - return; + if (!state._enabled || !state._loaded) return; + // Landing markers left over from the last sweep, a few per frame. + parts.rendering.revealLandingMarkers(); + if (!state._referenceSweepGate.shouldRun(viewer.camera)) return; parts.rendering.updateReferenceVisibility(); }, ); diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js index 68009af2e..5e875c718 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -66,6 +66,16 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({ depthTested: false, }); +/** + * 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. + */ + +export const CABLE_LANDING_REVEALS_PER_FRAME = 48; + /** * `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion * Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that diff --git a/src/layers/submarineCables/rendering.js b/src/layers/submarineCables/rendering.js index 91936573f..fffbce968 100644 --- a/src/layers/submarineCables/rendering.js +++ b/src/layers/submarineCables/rendering.js @@ -3,6 +3,7 @@ import { probeTranslucentMarkerBlend, commitTranslucentMarkerBlend, } from './surface.js'; +import { CABLE_LANDING_REVEALS_PER_FRAME } from './policy.js'; import { clampLabel, createCableOverlayEntry, @@ -40,6 +41,7 @@ export function createRendering({ state }) { state._referenceDataSource = null; state._referenceRecords = []; state._surfaceRecords = []; + state._pendingLandingReveals.length = 0; state._pickByEntity = new WeakMap(); state._referenceLabelCount = 0; state._publishScratch.length = 0; @@ -201,6 +203,26 @@ export function createRendering({ state }) { state._referenceRecords.push(record); } + /** + * 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. + */ + + function revealLandingMarkers() { + const pending = state._pendingLandingReveals; + if (!pending.length) return; + let budget = CABLE_LANDING_REVEALS_PER_FRAME; + while (pending.length && budget > 0) { + const record = pending.pop(); + if (!record.visible || record.entity.show) continue; + record.entity.show = true; + budget--; + } + if (pending.length) state._viewer?.scene?.requestRender?.(); + } + function updateReferenceVisibility() { if (!state._enabled || !state._viewer?.camera) return; const cameraPos = state._viewer.camera.positionWC; @@ -224,12 +246,16 @@ export function createRendering({ state }) { if (visible) updateCableReferenceStem(record, cameraPos, canvasHeight, fov); } + state._pendingLandingReveals.length = 0; for (const record of state._surfaceRecords) { const visible = occluder.isPointVisible(record.base); - if (record.entity.show !== visible) { - record.entity.show = visible; - } + 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; } + revealLandingMarkers(); const winners = selectCableReferenceLabelWinners(state._referenceRecords); let changed = winners.length !== state._lastPublishedCount; for (let i = 0; i < winners.length; i++) { @@ -269,6 +295,7 @@ export function createRendering({ state }) { applyCableClassification, styleLandingEntity, addReferenceStem, + revealLandingMarkers, updateReferenceVisibility, }; } diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js index 0761688aa..e447c5030 100644 --- a/src/layers/submarineCables/state.js +++ b/src/layers/submarineCables/state.js @@ -51,6 +51,11 @@ export function createState({ sweepClock, overlayHost }) { state._surfaceRecords = []; + /** Landing records the last sweep found visible but not shown yet; drained + * a few per frame (CABLE_LANDING_REVEALS_PER_FRAME). */ + + state._pendingLandingReveals = []; + state._clickHandler = null; state._preRenderRemover = null;