diff --git a/changelog.d/1277-annotation-issue-fix.md b/changelog.d/1277-annotation-issue-fix.md index 6f1799237..ba5eb97f3 100644 --- a/changelog.d/1277-annotation-issue-fix.md +++ b/changelog.d/1277-annotation-issue-fix.md @@ -1 +1,4 @@ -- Cull and budget surface height sampling (asturksever, #1277). +- Stop screen annotations sampling surface height for marks that are hidden, + behind the camera or over the horizon. Visible marks still waiting for + terrain share 32 samples per projection pass and retry once a second, so a + large board no longer stalls the frame (asturksever, #1277). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 45562707d..2c919bcbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -127,6 +127,24 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + +Overpass reserves each normalized query before its disk-cache lookup. The +initial disk lookup, upstream quota admission, fetching and memory publication +are coalesced under that reservation. A caller that joined a reservation +rejected by another client's quota retries admission with its own quota. +Stale fallback runs per response after the reservation is released, and disk +writes finish asynchronously. Settled and rejected reservations are removed +only while they still own their map entry. + +External ALPR tags, surveillance-tool fields, voice context text and compact +analyst strings become one line of at most 180 characters (scene labels: 120). +Empty ALPR values stay null, and the cap never splits a surrogate pair. +Canonical analyst identifiers such as `id` and `routeId` stay unchanged, because +voice follow-ups match on them and bikeshare uses the raw station name as its +ID. The text is still untrusted: normalization does not prevent prompt +injection. + Screen annotations skip hidden records and, before surface sampling, reject an unsettled anchor only when its whole accepted height range (-430 to 9000 m) is behind the camera or below the globe horizon; a settled height is tested as the diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 48e64ddb8..301402f98 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -153,9 +153,8 @@ export function createScreenAnnotationRenderer( // Best-effort surface height under a coordinate (clamps onto the 3D tiles). // Returns 0 until the tile loads, then caches the validated height. // canSample=false returns the cached height (or 0) without surface sampling. - function groundHeight(lon, lat, canSample = true) { - const key = heightKey(lon, lat); - const cached = heightCache.get(key); + // `key` and `cached` are the caller's cache lookup for this coordinate. + function groundHeight(lon, lat, key, cached, canSample = true) { if (cached && cached.settled) { // Mark hot for this frame + bump to most-recently-used so heights for live // marks survive eviction while places we've navigated away from age out. @@ -413,9 +412,11 @@ export function createScreenAnnotationRenderer( // A settled height is tested as the actual point below. An unknown height // is rejected before GPU-backed surface sampling only when the whole // accepted height range is proven hidden by one constraint. + const key = heightKey(lon, lat); + const cached = heightCache.get(key); let height; - if (heightCache.get(heightKey(lon, lat))?.settled) { - height = groundHeight(lon, lat); + if (cached?.settled) { + height = groundHeight(lon, lat, key, cached); } else { if (heightRangeHidden(lon, lat)) return null; // The frustum restricts sampling only; an endpoint outside it still @@ -431,7 +432,7 @@ export function createScreenAnnotationRenderer( const inFrustum = cullingVolume?.computeVisibility(heightBounds) !== Cesium.Intersect.OUTSIDE; - height = groundHeight(lon, lat, inFrustum); + height = groundHeight(lon, lat, key, cached, inFrustum); } const world = Cesium.Cartesian3.fromDegrees(lon, lat, height); // Reject points behind the camera (they produce wrapped/extreme coords). @@ -552,7 +553,10 @@ export function createScreenAnnotationRenderer( } function projectAll() { - if (!records.size) return; + if (!records.size) { + publish(); + return; + } // Serve the oldest refused keys first; the rest of the budget is open. heightReserved = new Set(); for (const key of heightQueue) { @@ -931,6 +935,8 @@ export function createScreenAnnotationRenderer( store.publish(null); } records.clear(); + leaving.clear(); + byKey.clear(); clearHeights(); } diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ae7cbcef2..3dec5314e 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -584,17 +584,7 @@ test('a mark hidden from Draw is not drawn or placed, and comes back when shown' }); test('hidden and back-facing annotations do not sample; visible unresolved heights have bounded retries', (t) => { - const previous = globalThis.document; - const previousRaf = globalThis.requestAnimationFrame; - globalThis.document = fakeDocument(); - globalThis.requestAnimationFrame = (fn) => { - fn(); - return 1; - }; - t.after(() => { - globalThis.document = previous; - globalThis.requestAnimationFrame = previousRaf; - }); + const store = samplingBoard(t); t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 }); t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, @@ -618,7 +608,7 @@ test('hidden and back-facing annotations do not sample; visible unresolved heigh }, requestRender() {}, }; - const renderer = createScreenAnnotationRenderer({ scene, camera }); + const renderer = samplingRenderer(store, scene); const mark = (id, lon) => ({ id, type: 'label', @@ -679,18 +669,29 @@ function sampledLon(world) { return Number(Cesium.Math.toDegrees(carto.longitude).toFixed(5)); } -function installDom(t) { - const previous = globalThis.document; +/** + * A board with no view mounted, for tests that count surface samples: the + * renderer projects on `add` and on each post-render event, and the store + * holds what it would draw. Animation frames run at once. + */ +function samplingBoard(t) { const previousRaf = globalThis.requestAnimationFrame; - globalThis.document = fakeDocument(); globalThis.requestAnimationFrame = (fn) => { fn(); return 1; }; t.after(() => { - globalThis.document = previous; globalThis.requestAnimationFrame = previousRaf; }); + return useScreenAnnotationsStore(createPinia()); +} + +/** The renderer under test, on the given board and scene. */ +function samplingRenderer(store, scene) { + return createScreenAnnotationRenderer( + { scene, camera: scene.camera, trackedEntity: null }, + { store, activeTrackedReadoutId: () => null }, + ); } function squareFrustum() { @@ -703,7 +704,7 @@ function squareFrustum() { } test('an arrow with one endpoint outside a real frustum keeps its visible connector', (t) => { - installDom(t); + const store = samplingBoard(t); t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, world) => sampledLon(world) > 0.1 ? { x: 1100, y: 400 } : { x: 400, y: 400 }, ); @@ -739,7 +740,7 @@ test('an arrow with one endpoint outside a real frustum keeps its visible connec volume.computeVisibility(new Cesium.BoundingSphere(far, 9500)), Cesium.Intersect.OUTSIDE, ); - const renderer = createScreenAnnotationRenderer({ scene, camera }); + const renderer = samplingRenderer(store, scene); renderer.add({ id: 'partial-arrow', type: 'arrow', @@ -748,16 +749,16 @@ test('an arrow with one endpoint outside a real frustum keeps its visible connec to: { lon: 0.2, lat: 0 }, }); postRender.raiseEvent(); - const { group } = findAnnotationGroup(globalThis.document); - assert.equal(group.style.display, '', 'the arrow stays drawn'); - const path = group.children.find((child) => child.classList.contains('gev-anno-arrow')); - assert.match(path.getAttribute('d'), /^M 400\.0 400\.0 Q .* 1100\.0 400\.0$/); + const [mark] = store.board.marks; + assert.equal(mark.display, '', 'the arrow stays drawn'); + const path = mark.children.find((child) => child.attrs.class?.includes('gev-anno-arrow')); + assert.match(path.attrs.d, /^M 400\.0 400\.0 Q .* 1100\.0 400\.0$/); assert.deepEqual([...new Set(sampled)], [0], 'only the in-frustum endpoint samples'); renderer.destroy(); }); test('a low-angle cached roof stays visible when range endpoints fail different tests', (t) => { - installDom(t); + const store = samplingBoard(t); t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 380 })); // Camera 1 m above the ellipsoid, heading east, pitched 25 degrees down. const pitch = Cesium.Math.toRadians(25); @@ -794,19 +795,18 @@ test('a low-angle cached roof stays visible when range endpoints fail different assert.equal(ahead(20), true); assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 20)), true); - const renderer = createScreenAnnotationRenderer({ scene, camera }); + const renderer = samplingRenderer(store, scene); renderer.add({ id: 'roof', type: 'label', label: 'Roof', anchor: { lon: roofLon, lat: 0 } }); - const { group } = findAnnotationGroup(globalThis.document); assert.equal(samples, 1, 'the unknown height is sampled, not culled'); - assert.equal(group.style.display, ''); + assert.equal(store.board.marks[0].display, ''); for (let i = 0; i < 5; i++) postRender.raiseEvent(); assert.equal(samples, 1, 'the settled height is reused'); - assert.equal(group.style.display, '', 'the cached roof point is tested and shown'); + assert.equal(store.board.marks[0].display, '', 'the cached roof point is tested and shown'); renderer.destroy(); }); test('height retries beyond the first budget are served under sparse passes', (t) => { - installDom(t); + const store = samplingBoard(t); t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 }); t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 })); const camera = { @@ -830,7 +830,7 @@ test('height retries beyond the first budget are served under sparse passes', (t return tilesReady && lon === last ? Cesium.Cartesian3.fromDegrees(lon, 0, 12) : undefined; }, }; - const renderer = createScreenAnnotationRenderer({ scene, camera }); + const renderer = samplingRenderer(store, scene); for (const lon of lons) { renderer.add({ id: `p${lon}`, type: 'label', label: 'P', anchor: { lon, lat: 0 } }); }