fix(street-level): read the older {zoom, kind} zoomFor shape (#1288)

Restore legacy provider zoomFor compatibility and document supported return values.

Contribution by @asturksever.
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-24 23:33:10 -07:00
1 parent 8dec9eaa10
commit 5b8e6fbc38
4 files changed
+67 -7

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the
shape before the core went slim, draws its lines again instead of a silent
blank; an overview level or any answer that is not a whole zoom shows the
zoom-in hint (asturksever, #1288).
+4
View File
@@ -3753,6 +3753,10 @@ disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the pr
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile. requests for a tile.
Line coverage accepts a provider’s numeric `zoomFor` result or its legacy
`{zoom, kind}` shape. Overview levels and invalid zooms show the zoom-in hint
instead of silently requesting no tiles.
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
the panel says to zoom in. Tiles come from the visible ground within range of the panel says to zoom in. Tiles come from the visible ground within range of
the camera, nearest first, at most 9 (the halves of a view across the camera, nearest first, at most 9 (the halves of a view across
+23 -3
View File
@@ -105,6 +105,25 @@ function pointsOf(sequence) {
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first). * @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
*/ */
/**
* The tile zoom a provider's `zoomFor` answered, or null. Providers written
* before the core went slim return `{zoom, kind}`: its zoom is read, and an
* `'overview'` level draws nothing, since nothing is drawn from orbit. Any
* other answer that is not a whole zoom draws nothing too, rather than
* asking for no tiles without a hint.
* @param {unknown} level
* @returns {number|null}
*/
export function coverageZoom(level) {
const zoom =
level && typeof level === 'object'
? level.kind === 'overview'
? null
: level.zoom
: level;
return Number.isInteger(zoom) && zoom >= 0 ? zoom : null;
}
/** /**
* Camera-driven sequence lines from any XYZ line-tile source, near the * Camera-driven sequence lines from any XYZ line-tile source, near the
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from * ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
@@ -115,8 +134,9 @@ function pointsOf(sequence) {
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A * A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`. * rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
* @param {(heightM: number|null) => number|null} options.zoomFor * @param {(heightM: number|null) => number|{zoom: number, kind?: string}|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing. * Tile zoom for a camera height above ground; null draws nothing. The
* older `{zoom, kind}` shape is still read (see `coverageZoom`).
* @param {{coverage: string, selected: string}} options.colors * @param {{coverage: string, selected: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the lines. * @param {string} options.pickPrefix Pick-id prefix of the lines.
* @param {string} options.renderKey Render-governor reason. * @param {string} options.renderKey Render-governor reason.
@@ -380,7 +400,7 @@ export function createLineCoverage({
const height = Number.isFinite(cameraHeight) const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0) ? cameraHeight - (ground ?? 0)
: null; : null;
const zoom = zoomFor(height); const zoom = coverageZoom(zoomFor(height));
if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT }; if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT };
// Rays meet the ground where it really is (1,600 m up in Denver) and stop // Rays meet the ground where it really is (1,600 m up in Denver) and stop
// short of the horizon, which would stretch the box to the world. // short of the horizon, which would stretch the box to the world.
+36 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
coverageZoom,
createLineCoverage, createLineCoverage,
freshCoverage, freshCoverage,
ZOOM_IN_HINT, ZOOM_IN_HINT,
@@ -28,7 +29,7 @@ const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } =
COVERAGE_TUNING; COVERAGE_TUNING;
/** Line coverage as the Mapillary provider builds it, over `source`. */ /** Line coverage as the Mapillary provider builds it, over `source`. */
function createCoverage({ state, source }) { function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
return createLineCoverage({ return createLineCoverage({
state, state,
fetchTile: async (tile, { signal }) => fetchTile: async (tile, { signal }) =>
@@ -36,7 +37,7 @@ function createCoverage({ state, source }) {
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile, tile,
), ),
zoomFor: coverageZoomForHeight, zoomFor,
colors: COLORS, colors: COLORS,
pickPrefix: PICK_PREFIX.sequence, pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage', renderKey: 'mapillary-coverage',
@@ -123,7 +124,7 @@ function deferredSource() {
}; };
} }
function setup() { function setup({ zoomFor } = {}) {
const centre = tileCentre(-121.4944, 38.5816); const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource(); const source = deferredSource();
@@ -146,7 +147,7 @@ function setup() {
}, },
sequence: { selectedId: null }, sequence: { selectedId: null },
}; };
const coverage = createCoverage({ state, source }); const coverage = createCoverage({ state, source, zoomFor });
const bytes = encodeCoverageTile(centre.tile, { const bytes = encodeCoverageTile(centre.tile, {
sequences: [ sequences: [
{ {
@@ -239,6 +240,37 @@ test('panning off a tile that is still loading stops counting it as loading', as
coverage.clear(); coverage.clear();
}); });
test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () => {
// Providers written before the core went slim (#1139's Vegbilder) answer
// an object: it must not ask for no tiles and say nothing.
const { viewer, source, coverage, bytes } = setup({
zoomFor: (height) => {
const zoom = coverageZoomForHeight(height);
return zoom == null ? null : { zoom, kind: 'sequence' };
},
});
coverage.refresh();
assert.equal(source.calls.length, 1, 'the tile is asked for');
assert.equal(source.calls[0].z, 14);
source.calls[0].resolve(bytes);
await settle();
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn');
assert.equal(coverage.stats().hint, '');
coverage.clear();
});
test('a zoom level is read from either shape; an overview or a bad answer draws nothing', () => {
assert.equal(coverageZoom(14), 14);
assert.equal(coverageZoom({ zoom: 15, kind: 'sequence' }), 15);
assert.equal(coverageZoom({ zoom: 12 }), 12);
assert.equal(coverageZoom({ zoom: 5, kind: 'overview' }), null, 'orbit');
assert.equal(coverageZoom(null), null);
assert.equal(coverageZoom(undefined), null);
assert.equal(coverageZoom({}), null);
assert.equal(coverageZoom(14.5), null);
assert.equal(coverageZoom('14'), null);
});
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => { test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup(); const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh(); coverage.refresh();