From ba58a5ec50286ce4b06ac483b2eff9ddcc12aa91 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:05 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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), }); }