From ba58a5ec50286ce4b06ac483b2eff9ddcc12aa91 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:05 -0400 Subject: [PATCH 01/11] fix(street-level): deduplicate overlapping dateline tile halves --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ .../street-level-unique-tiles.test.mjs | 15 +++++++++++++++ src/layers/streetLevel/tileMath.js | 7 ++++++- 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/layers/streetLevel/street-level-unique-tiles.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..0814f7e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d0014227f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(street-level): deduplicate overlapping dateline tile halves + +Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs new file mode 100644 index 000000000..f96f0b892 --- /dev/null +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tilesForBbox } from './tileMath.js'; +test('coarse dateline coverage counts each tile exactly once', () => { + for (const z of [0, 1, 2]) { + const result = tilesForBbox([170, -1, -170, 1], z); + const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); + assert.equal(new Set(keys).size, keys.length); + assert.equal(result.total, keys.length); + const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); + assert.equal(limited.total, keys.length); + assert.equal(limited.truncated, keys.length > 1); + assert.equal(limited.tiles.length, 1); + } +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..ca7b09cd5 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -147,7 +147,12 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; // Each tile's distance is computed once; ties keep their row order. - const ranked = tiles + const unique = [ + ...new Map( + tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), + ).values(), + ]; + const ranked = unique .map((tile) => ({ tile, d: distance2(tile) })) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); From f67888be00327c45bb2f0a651979ea9eb77489f4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:01 -0400 Subject: [PATCH 02/11] docs: address review scope and feature placement for #1032 --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 9 +++--- .../street-level-unique-tiles.test.mjs | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814f7e7c..1d76a1963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0014227f..c2291829e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3122,6 +3122,10 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. +Mapillary coverage ranks unique tile addresses before applying the 16-tile +overview budget. Overlapping dateline halves in a polar view therefore do not +waste overview slots on the same tile; loading already skips duplicate keys. + On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out above 1,800 m) the surface mode switches from `draped` to `terrain`: draped lines land on roofs and tree tops, so `groundCast.js` instead places lines, @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(street-level): deduplicate overlapping dateline tile halves - -Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index f96f0b892..224de523b 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -1,15 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { tilesForBbox } from './tileMath.js'; -test('coarse dateline coverage counts each tile exactly once', () => { - for (const z of [0, 1, 2]) { - const result = tilesForBbox([170, -1, -170, 1], z); - const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); - assert.equal(new Set(keys).size, keys.length); - assert.equal(result.total, keys.length); - const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); - assert.equal(limited.total, keys.length); - assert.equal(limited.truncated, keys.length > 1); - assert.equal(limited.tiles.length, 1); - } + +test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => { + // coverage uses the actual view bbox above zoom 1; polar views can wrap. + const bbox = [-10, 60, -20, 85]; + const result = tilesForBbox(bbox, 3); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(keys.length, 24); + assert.equal(new Set(keys).size, 24); + assert.equal(result.total, 24); + assert.equal(result.truncated, false); + const overview = tilesForBbox(bbox, 3, { limit: 16 }); + assert.equal(overview.total, 24); + assert.equal(overview.tiles.length, 16); + assert.equal( + new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size, + 16, + ); + assert.equal(overview.truncated, true); }); From 4adcb3f7274951bd5c763cc59e190ef559c169de Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:17 -0400 Subject: [PATCH 03/11] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d76a1963..69748384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. - - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. From 84714030c86ca42a770aa04e19ae52fabf457dc8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:47 -0700 Subject: [PATCH 04/11] test: show a capped overview no longer holds repeat tiles Adds a case that fails before de-duplication (a 24-slot overview held repeats and reported itself truncated) and says why the date-line halves overlap. --- .../streetLevel/street-level-unique-tiles.test.mjs | 9 +++++++++ src/layers/streetLevel/tileMath.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index 224de523b..52e7b6aea 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -20,3 +20,12 @@ test('reachable polar dateline coverage ranks 24 distinct tiles before overview ); assert.equal(overview.truncated, true); }); + +test('a limit that fits every distinct tile is not spent on repeats', () => { + // Before de-duplication this box yielded 27 entries for 24 tiles, so a + // 24-slot overview held repeats and still reported itself truncated. + const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 }); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(new Set(keys).size, 24); + assert.equal(result.truncated, false); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index ca7b09cd5..350188200 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -146,7 +146,10 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { const dx = Math.abs(tile.x - centre.x); return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; - // Each tile's distance is computed once; ties keep their row order. + // The date-line halves share a column when the gap between east and west is + // narrower than a tile; count that column once so a capped overview does not + // spend its slots on repeats. Each tile's distance is computed once; ties keep + // their row order. const unique = [ ...new Map( tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), From c5ac24b6005d4c070378a99544fd78d6bc21ef09 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:12 -0400 Subject: [PATCH 05/11] fix(recent-imagery): reject absent box edges instead of inventing zero degrees --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++++ .../imagery-box-missing-edge.test.mjs | 16 ++++++++++++++++ src/layers/recentImagery/model.js | 17 +++++++++++------ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/imagery-box-missing-edge.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..95a92c46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..1ad836aba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(recent-imagery): reject absent box edges instead of inventing zero degrees + +Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs new file mode 100644 index 000000000..3e5a8e8a5 --- /dev/null +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateBox, boxCentre } from './model.js'; +test('missing imagery edges do not turn into invented zero coordinates', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + const box = { west: value, south: 0, east: 1, north: 1 }; + assert.equal(validateBox(box).ok, false); + assert.equal(validateBox(box).reason, 'invalid'); + assert.equal(boxCentre(box), null); + } + assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true); + assert.equal( + validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok, + true, + ); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 23f503e9e..63c5ec16c 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -77,12 +77,17 @@ const pad2 = (value) => String(value).padStart(2, '0'); */ function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const [west, south, east, north] = [ - box.west, - box.south, - box.east, - box.north, - ].map(Number); + const edges = [box.west, box.south, box.east, box.north]; + if ( + edges.some( + (value) => + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()), + ) + ) + return null; + const [west, south, east, north] = edges.map(Number); if (![west, south, east, north].every(Number.isFinite)) return null; return { west, From 9c9c80a7ed489c5ba1bb4a7159e87103e7ae25d2 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:03 -0400 Subject: [PATCH 06/11] docs: address review scope and feature placement for #1036 --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a92c46f..dddcdeef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Director timeline seeks cross the dateline along the short longitude arc. - Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. +- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or + boolean programmatic box edges instead of interpreting them as zero coordinates. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1ad836aba..054aff963 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,11 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Programmatic boxes passed to the exported `setBox` and `searchHls` operations +reject missing, blank or boolean edges instead of interpreting them as zero +coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, +pins and share links already supply complete boxes. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. @@ -5326,8 +5331,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(recent-imagery): reject absent box edges instead of inventing zero degrees - -Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. From 6ffbf7f5244ccaaf8959118ad857efe8761e58a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:36:29 -0700 Subject: [PATCH 07/11] fix(imagery): refuse blank box edges at every conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share-link integers and Cesium radians were converted before normalizeBox checked them, so a blank edge still became 0°. Edges are now accepted only as numbers or non-blank numeric strings, which also refuses [] and {}. Tests cover all four edges through validateBox, dequantizeBox, boxFromRectangle and searchHls. --- CHANGELOG.md | 1 - .../imagery-box-missing-edge.test.mjs | 34 +++++++++++++++- src/layers/recentImagery/model.js | 40 +++++++++++-------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddcdeef8..9ab3b03cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. -- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or boolean programmatic box edges instead of interpreting them as zero coordinates. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs index 3e5a8e8a5..698bcaee5 100644 --- a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { validateBox, boxCentre } from './model.js'; +import { + boxCentre, + boxFromRectangle, + dequantizeBox, + validateBox, +} from './model.js'; +import { searchHls } from './catalog.js'; + test('missing imagery edges do not turn into invented zero coordinates', () => { for (const value of [null, undefined, '', ' ', false, true]) { const box = { west: value, south: 0, east: 1, north: 1 }; @@ -14,3 +21,28 @@ test('missing imagery edges do not turn into invented zero coordinates', () => { true, ); }); + +test('every edge and every box entry point refuses a missing edge', async () => { + const blanks = [null, undefined, '', ' ', false, [], {}]; + for (const edge of ['west', 'south', 'east', 'north']) { + for (const value of blanks) { + const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value }; + assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`); + // Share-link integers and Cesium radians are converted per edge. + assert.equal(dequantizeBox(box), null, `share ${edge}`); + assert.equal(boxFromRectangle(box), null, `radians ${edge}`); + } + } + let fetched = false; + await assert.rejects( + searchHls({ + box: { west: ' ', south: 1, east: 2, north: 2 }, + fetchImpl: async () => { + fetched = true; + }, + }), + TypeError, + ); + assert.equal(fetched, false); + assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 })); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 63c5ec16c..24ef54fc6 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -75,20 +75,23 @@ const pad2 = (value) => String(value).padStart(2, '0'); * east are not swapped: west > east crosses the dateline and `validateBox` * rejects it. Null when any edge is nonfinite. */ +/** A box edge as a number, or null when it is missing, blank or not numeric. */ +function edgeNumber(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string' || !value.trim()) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const edges = [box.west, box.south, box.east, box.north]; - if ( - edges.some( - (value) => - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()), - ) - ) - return null; - const [west, south, east, north] = edges.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return null; + const [west, south, east, north] = [ + box.west, + box.south, + box.east, + box.north, + ].map(edgeNumber); + if ([west, south, east, north].some((edge) => edge == null)) return null; return { west, south: Math.min(south, north), @@ -216,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) { function mapBox(box, fn) { if (!box || typeof box !== 'object') return null; + // Converting first would turn a blank edge into 0 before normalizeBox sees it. + const edge = (value) => { + const number = edgeNumber(value); + return number == null ? null : fn(number); + }; return normalizeBox({ - west: fn(box.west), - south: fn(box.south), - east: fn(box.east), - north: fn(box.north), + west: edge(box.west), + south: edge(box.south), + east: edge(box.east), + north: edge(box.north), }); } From 9f56e9df981eeb6f090c757d36bd081415edb34d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:47:56 -0400 Subject: [PATCH 08/11] fix(terrain): refresh cache recency when resolving warm batches --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/services/terrain-warm-batch-lru.test.mjs | 24 ++++++++++++++++++++ src/services/terrainHeights.js | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..e3075234c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..246f3067c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(terrain): refresh cache recency when resolving warm batches + +Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs new file mode 100644 index 000000000..ac718b998 --- /dev/null +++ b/src/services/terrain-warm-batch-lru.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTerrainHeights } from './terrainHeights.js'; +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index a6502aeb5..124777150 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -273,7 +273,7 @@ export function createTerrainHeights({ const seenKeys = new Set(); const now = Date.now(); for (const item of work) { - const entry = cache.get(item.key); + const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && From 07757a3cae25956f362525432bf2b45bb1908bd3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:26 -0400 Subject: [PATCH 09/11] docs: address maintainer review for PR #1040 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 246f3067c..fcb2f3b3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,8 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +Warm batch lookups also promote their cached entries in the least-recently-used eviction order. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(terrain): refresh cache recency when resolving warm batches - -Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. From b4aa61d6ef53982ed2cf34f0eea65cabf181c86d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:37:30 -0700 Subject: [PATCH 10/11] fix(terrain): keep a batch's warm points when its own fetches evict them A warm entry could be evicted by the same call's later fetches and then reported unresolved. The call now remembers warm hits as it does fetched ones. Moves the batch LRU test beside the existing cache tests and folds the docs note into the section's eviction paragraph. --- docs/CURRENT-STATE.md | 10 ++--- src/services/terrain-warm-batch-lru.test.mjs | 24 ------------ src/services/terrainHeights.js | 20 +++++----- src/services/terrainHeights.test.mjs | 41 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 41 deletions(-) delete mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcb2f3b3f..d28fee7e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,19 +1192,17 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound -Warm batch lookups also promote their cached entries in the least-recently-used eviction order. - The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated visits to one place share a key but does not bound how many distinct places a session visits, and the cache previously had no eviction, so a long session retained every coordinate it ever resolved. Eviction is least-recently-used: -a consumer read promotes its entry, so the cell being watched is not dropped for -having been resolved early. The bound sits far above a city-scale session, so +a consumer read, including a warm hit in a batch lookup, promotes its entry, so +the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so normal use never evicts and issues no additional proxy requests. A batch's -results are assembled from what that call resolved, so eviction during a large -batch cannot report a just-resolved point as unresolved. Re:Earth and +results are assembled from what that call resolved or found warm, so eviction +during a large batch cannot report one of its points as unresolved. Re:Earth and geoid-fallback entry semantics, the fallback cooldown and the abort-time flush are unchanged. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs deleted file mode 100644 index ac718b998..000000000 --- a/src/services/terrain-warm-batch-lru.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTerrainHeights } from './terrainHeights.js'; -test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { - let calls = 0; - const terrain = createTerrainHeights({ - maxCacheEntries: 2, - source: { - async getHeights(points) { - calls++; - return points.map((p) => ({ ellipsoid: 100 + p.lon })); - }, - }, - }); - const p = (lon) => ({ lat: 0, lon }); - await terrain.resolveEllipsoidalGround([p(1)]); - await terrain.resolveEllipsoidalGround([p(2)]); - await terrain.resolveEllipsoidalGround([p(1)]); - assert.equal(calls, 2); - await terrain.resolveEllipsoidalGround([p(3)]); - assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); - assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); - assert.equal(calls, 3); -}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index 124777150..f84a4413f 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -272,28 +272,26 @@ export function createTerrainHeights({ const uncached = []; const seenKeys = new Set(); const now = Date.now(); + // What this call resolved or found warm, so the assembly below can report a + // point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. + // Without it a batch big enough to evict its own entries would report + // points as unresolved that it had in fact resolved. + const resolvedThisCall = new Map(); for (const item of work) { const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && now < entry.retryAt; - if ( - (entry && entry.source === 'reearth') || - fallbackCooling || - seenKeys.has(item.key) - ) + if ((entry && entry.source === 'reearth') || fallbackCooling) { + resolvedThisCall.set(item.key, entry); continue; + } + if (seenKeys.has(item.key)) continue; seenKeys.add(item.key); uncached.push(item); } - // What this call resolved, so the assembly below can report a point even - // if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a - // batch big enough to evict its own earlier chunks would report points as - // unresolved that it had in fact just resolved. - const resolvedThisCall = new Map(); - // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's // failure is isolated to that chunk's points (geoid fallback), so a single // bad chunk doesn't lose results for the rest of a large batch. diff --git a/src/services/terrainHeights.test.mjs b/src/services/terrainHeights.test.mjs index c9884e89c..5e80b9be2 100644 --- a/src/services/terrainHeights.test.mjs +++ b/src/services/terrainHeights.test.mjs @@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved' assert.equal(warm.length, 4); }); +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); + +test('a batch still reports its warm points after its own fetches evict them', async () => { + const terrain = createTerrainHeights({ + source: stubSource(), + maxCacheEntries: 2, + }); + await terrain.resolveEllipsoidalGround([pointAt(0)]); + + const results = await terrain.resolveEllipsoidalGround([ + pointAt(0), + pointAt(1), + pointAt(2), + ]); + + results.forEach((result, i) => { + assert.equal(result.source, 'reearth', `point ${i} reported resolved`); + assert.equal(result.ellipsoid, heightAt(lonAt(i))); + }); +}); + test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => { for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) { const terrain = createTerrainHeights({ From d861f24d3e4456de567590ba253cd6a5b84a68f3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:11:57 -0400 Subject: [PATCH 11/11] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3075234c..80d2a4544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update,