refactor(street-level): trim dead paths, duplicated labels and test-only API

- visibleBbox: no view-rectangle fallback or grid option; its one caller
  always ranges the rays
- tilesForBbox returns the tiles; normalizeBbox takes arrays only
- viewerHost: no try/catch around adapter calls that catch for themselves
- coverage: counts come from the drawn sequences; no sequenceCount export
- one keyStatusLabel for the pill and the layer list
- no constructor source check, double-init guard or selectSequence /
  clearSequence on the layer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 17:04:05 +04:00
1 parent 777aa62586
commit d59b4d05bb
14 files changed
+73 -116

No files matched your search

+1
View File
@@ -660,6 +660,7 @@
"src/ui/shellFacade.js",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js",
+4 -20
View File
@@ -5,14 +5,13 @@ import { createCredit } from './credits.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { normalizeFilter, sameFilter } from './filter.js';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
import { keyStatusLabel, STREET_LEVEL_LAYER_ID } from './policy.js';
import { createCoverage } from './providers/mapillary/coverage.js';
import { createSequences } from './providers/mapillary/sequences.js';
import { createMapillaryViewer } from './providers/mapillary/viewer.js';
import {
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_SOURCE_METHODS,
} from './providers/mapillary/policy.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
@@ -39,12 +38,6 @@ export function createStreetLevelLayer({
services = {},
photoViewer = null,
}) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
const state = createState({ services });
const parts = {};
const context = { state, parts };
@@ -169,10 +162,7 @@ export function createStreetLevelLayer({
requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
parts.sequences.ensureCollections(viewer);
@@ -209,14 +199,13 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(null);
state.listeners.clear();
state.viewer = null;
state.initialized = false;
},
getStats() {
const coverage = coverageStats();
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
const keyLabel = keyStatusLabel(coverage);
let loadingLabel = '';
if (coverage.keyRequired) loadingLabel = keyLabel;
if (keyLabel) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
@@ -224,10 +213,7 @@ export function createStreetLevelLayer({
loading: coverage.loading,
keyRequired: coverage.keyRequired,
// A rejected key's message names the fix; a missing one says so.
error:
coverage.keyRequired && !coverage.keyRejected
? keyLabel
: coverage.error,
error: keyLabel && !coverage.keyRejected ? keyLabel : coverage.error,
loadingLabel,
};
},
@@ -283,8 +269,6 @@ export function createStreetLevelLayer({
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
};
return layer;
}
+11 -9
View File
@@ -205,23 +205,26 @@ test('switching Mapillary off clears coverage and closes the image it shows', as
assert.equal(layer.getUIState().providerOn, false);
});
test('a sequence error goes with the sequence and leaves the viewer’s own', async (t) => {
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
const source = fakeMapillarySource({
getSequenceImages: async () => {
throw new Error('Sequence images unavailable');
},
});
const { layer } = await startLayer(t, { source });
await layer.selectSequence('seq-1');
layer.attachViewerHost(null);
await layer.openImage('img1');
// The photo's pose names its sequence, which opening selects.
const photoViewer = fakePhotoViewer({
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
});
const { layer } = await startLayer(t, { source, photoViewer });
layer.attachViewerHost({});
assert.equal(await layer.openImage('img1'), true);
await settle();
let ui = layer.getUIState();
assert.equal(ui.sequence.error, 'Sequence images unavailable');
assert.match(ui.street.error, /Open the Street Level panel/);
layer.clearSequence();
assert.equal(ui.street.error, null, 'the photo itself is fine');
layer.closeViewer();
ui = layer.getUIState();
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
assert.match(ui.street.error, /Open the Street Level panel/, 'kept');
});
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
@@ -245,7 +248,6 @@ test('closing the photo, switching the layer off or destroying it stops the fram
});
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
const { layer } = await startLayer(t);
assert.deepEqual(layer.getParams(), {
mapillary: true,
+6
View File
@@ -18,3 +18,9 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter the layer starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
export function keyStatusLabel({ keyRequired, keyRejected }) {
if (keyRejected) return 'KEY REJECTED';
return keyRequired ? 'KEY REQUIRED' : null;
}
@@ -162,7 +162,6 @@ export function createCoverage({ state, source }) {
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const primitive of entry.primitives)
scene.groundPrimitives.add(primitive);
}
@@ -212,7 +211,7 @@ export function createCoverage({ state, source }) {
)
return;
const decoded = decodeCoverageTile(bytes, tile);
const entry = { primitives: [], count: 0, sequences: new Map() };
const entry = { primitives: [], sequences: new Map() };
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
@@ -311,7 +310,7 @@ export function createCoverage({ state, source }) {
if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from between the camera's ground point and the screen centre's:
// a tilted view's box centre can sit kilometres past both.
const { tiles } = tilesForBbox(bbox, zoom, {
const tiles = tilesForBbox(bbox, zoom, {
limit: COVERAGE_MAX_TILES,
from: viewFocus(viewer, ranged),
});
@@ -430,7 +429,6 @@ export function createCoverage({ state, source }) {
setKeyStatus,
rebuild,
highlight,
sequenceCount,
stats,
};
}
@@ -66,7 +66,6 @@ function fakeViewer(view) {
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
};
}
@@ -318,14 +317,18 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
await settle();
const [entry] = state.coverage.tiles.values();
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
assert.equal(
entry.sequences.size,
Math.min(300, cap),
'every 360° line is drawn',
);
assert.equal(coverage.stats().count, Math.min(300, cap));
// Picking and the highlight look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.filter = { pano: 'all', sinceDays: 0 };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.equal(entry.sequences.size, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear();
@@ -359,7 +362,7 @@ test('a sequence that crosses a tile edge is counted once', async () => {
);
}
await settle();
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
assert.equal(coverage.stats().count, 3, 'across, plus one per tile');
coverage.clear();
});
-1
View File
@@ -52,7 +52,6 @@ export function createState({ services }) {
services,
viewer: null,
enabled: false,
initialized: false,
listeners: new Set(),
notify: null,
/** The share link's `mapillary` switch: off draws nothing. */
+7 -17
View File
@@ -58,15 +58,10 @@ export function tileBounds(x, y, z) {
};
}
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
/** A [west, south, east, north] array as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (!Array.isArray(input) || input.length !== 4) return null;
const [w, s, e, n] = input.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
@@ -81,7 +76,7 @@ export function normalizeBbox(input) {
* crosses the date line), nearest to `from` first (default: the box centre),
* capped at `limit`. Distances are measured the short way round ±180°.
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
* @returns {Array<{x:number,y:number,z:number}>}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
const [west, south, east, north] = Array.isArray(bbox)
@@ -95,7 +90,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
[-180, south, east, north],
]
: [bbox];
const tiles = halves
const grid = halves
.map(normalizeBbox)
.filter(Boolean)
.flatMap((box) => tileGrid(box, z));
@@ -107,7 +102,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
const cx = tileXAt(wrapLon(centre.lon), z);
const cy = tileYAt(centre.lat, z);
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
const ranked = grid
.map((tile) => {
const dx = Math.abs(tile.x + 0.5 - cx);
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2;
@@ -115,12 +110,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
})
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
return {
tiles: truncated ? ranked.slice(0, limit) : ranked,
truncated,
total: ranked.length,
};
return ranked.slice(0, limit);
}
/** The tiles at zoom z in a normalized box, row by row. */
+16 -26
View File
@@ -37,24 +37,21 @@ test('normalizeBbox orders and clamps coordinates', () => {
north: 38.69,
});
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
assert.equal(normalizeBbox('nope'), null);
assert.equal(normalizeBbox([1, 2, 3]), null);
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
});
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
const tiles = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
limit: 5,
});
assert.equal(result.total, 160);
assert.equal(result.truncated, true);
assert.equal(result.tiles.length, 5);
assert.equal(tiles.length, 5);
const centreX = lonToTileX(-121.46, 14);
const centreY = latToTileY(38.565, 14);
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1);
assert.ok(Math.abs(tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(tiles[0].y - centreY) <= 1);
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
assert.equal(full.truncated, false);
assert.equal(full.tiles.length, 160);
assert.equal(full.length, 160);
});
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
@@ -71,13 +68,13 @@ test('longitude 180 is the last column, so a world view keeps the eastern hemisp
assert.equal(lonToTileX(179.99, 1), 1);
assert.equal(lonToTileX(-180, 1), 0);
const columns = new Set(
tilesForBbox([-180, -85, 180, 85], 1).tiles.map((tile) => tile.x),
tilesForBbox([-180, -85, 180, 85], 1).map((tile) => tile.x),
);
assert.deepEqual([...columns].sort(), [0, 1]);
});
test('a box across the date line takes tiles on both sides of it, not the far side of the globe', () => {
const { tiles } = tilesForBbox([179, -1, -179, 1], 8);
const tiles = tilesForBbox([179, -1, -179, 1], 8);
const columns = new Set(tiles.map((tile) => tile.x));
assert.ok(columns.has(255), 'west of the line');
assert.ok(columns.has(0), 'east of the line');
@@ -86,7 +83,7 @@ test('a box across the date line takes tiles on both sides of it, not the far si
'nothing from the middle of the map',
);
// The limit keeps the tiles nearest the line.
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles;
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 });
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
});
@@ -97,12 +94,12 @@ test('a box across the date line ranks from the line, or from the camera across
const key = (t) => `${t.x}/${t.y}`;
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
const plain = tilesForBbox(bbox, z);
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
assert.equal(plain.length, 10 * (south - north + 1), '5 columns either side');
// The box centre is the date line itself, not the middle of the map: the
// columns touching it come first, then the ones beside them.
for (const tile of plain.tiles.slice(0, 4))
for (const tile of plain.slice(0, 4))
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
for (const tile of plain.tiles.slice(4, 12))
for (const tile of plain.slice(4, 12))
assert.ok(
[n - 2, n - 1, 0, 1].includes(tile.x),
`${key(tile)} near the line`,
@@ -112,15 +109,9 @@ test('a box across the date line ranks from the line, or from the camera across
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
const lat = (rowSouth + rowNorth) / 2;
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
assert.deepEqual(west.tiles.slice(0, 2).map(key), [
`${n - 1}/${row}`,
`0/${row}`,
]);
assert.deepEqual(west.slice(0, 2).map(key), [`${n - 1}/${row}`, `0/${row}`]);
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
assert.deepEqual(east.tiles.slice(0, 2).map(key), [
`0/${row}`,
`${n - 1}/${row}`,
]);
assert.deepEqual(east.slice(0, 2).map(key), [`0/${row}`, `${n - 1}/${row}`]);
});
test('ranking from the camera keeps the ground under it, not the box centre', () => {
@@ -131,11 +122,10 @@ test('ranking from the camera keeps the ground under it, not the box centre', ()
const z = 14;
const key = (t) => `${t.x}/${t.y}`;
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
const plain = tilesForBbox(bbox, z, { limit: 9 }).map(key);
assert.equal(plain.includes(under), false, 'the box centre drops it');
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first');
assert.equal(ranked.total > 9, true);
assert.equal(key(ranked[0]), under, 'the tile under the camera first');
});
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
+12 -18
View File
@@ -77,17 +77,19 @@ function groundHit(camera, point, ellipsoid, maxRange) {
return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null;
}
/** Screen rays per side, less one, that `visibleBbox` casts. */
const BBOX_GRID = 5;
/**
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* Visible [west, south, east, north] degrees, or null when fewer than four
* rays hit. Only hits count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground (rays to the bare
* ellipsoid under Denver land kilometres too far out) and `maxRange` (m)
* drops near-horizon hits; with it, too few hits give null rather than
* Cesium's view rectangle, which can span the horizon.
* drops near-horizon hits.
*/
export function visibleBbox(
viewer,
{ grid = 5, groundHeight = null, maxRange = null } = {},
{ groundHeight = null, maxRange = null } = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
@@ -101,10 +103,10 @@ export function visibleBbox(
groundHeight,
);
const point = new Cesium.Cartesian2();
for (let i = 0; i <= grid; i++) {
for (let j = 0; j <= grid; j++) {
point.x = (width * i) / grid;
point.y = (height * j) / grid;
for (let i = 0; i <= BBOX_GRID; i++) {
for (let j = 0; j <= BBOX_GRID; j++) {
point.x = (width * i) / BBOX_GRID;
point.y = (height * j) / BBOX_GRID;
const carto = groundHit(camera, point, ellipsoid, maxRange);
if (carto) hits.push(carto);
}
@@ -142,15 +144,7 @@ export function visibleBbox(
}
if (west !== east && north - south > 0) return [west, south, east, north];
}
if (Number.isFinite(maxRange)) return null;
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
if (!rectangle) return null;
return [
Cesium.Math.toDegrees(rectangle.west),
Cesium.Math.toDegrees(rectangle.south),
Cesium.Math.toDegrees(rectangle.east),
Cesium.Math.toDegrees(rectangle.north),
];
return null;
}
/** The point on the ground under the camera, as {lon, lat} degrees. */
+1 -1
View File
@@ -155,7 +155,7 @@ test('a tilted view from 3 km loads the tile at the centre of the screen', () =>
const ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540;
const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`;
const chosen = (from) =>
tilesForBbox(bbox, 13, { limit: 9, from }).tiles.map(
tilesForBbox(bbox, 13, { limit: 9, from }).map(
(tile) => `${tile.x}/${tile.y}`,
);
assert.ok(
+2 -10
View File
@@ -121,20 +121,12 @@ export function createViewerHost({ state, parts, adapter }) {
}
function resize() {
try {
if (unsubscribe) adapter.resize();
} catch {
/* no-op */
}
if (unsubscribe) adapter.resize();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
} catch {
/* adapter not ready */
}
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
notify();
}
-1
View File
@@ -103,6 +103,5 @@ export function rayCamera({
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
},
computeViewRectangle: () => undefined,
};
}
+3 -4
View File
@@ -1,4 +1,5 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
import { keyStatusLabel } from '../layers/streetLevel/policy.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -59,10 +60,8 @@ const drawing = (state) => state.enabled === true && state.providerOn !== false;
function presentStatus(state) {
const pressed = drawing(state);
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
const key = keyStatusLabel(state);
if (key) return { text: key, tone: 'warn', pressed, title };
if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title };
return pressed