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/shellFacade.js",
"src/ui/streetLevelControls.js", "src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js", "src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js", "src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js", "src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js", "src/ui/localSdrPresentation.js",
+4 -20
View File
@@ -5,14 +5,13 @@ import { createCredit } from './credits.js';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js'; import { createViewerHost } from './viewerHost.js';
import { normalizeFilter, sameFilter } from './filter.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 { createCoverage } from './providers/mapillary/coverage.js';
import { createSequences } from './providers/mapillary/sequences.js'; import { createSequences } from './providers/mapillary/sequences.js';
import { createMapillaryViewer } from './providers/mapillary/viewer.js'; import { createMapillaryViewer } from './providers/mapillary/viewer.js';
import { import {
MAPILLARY_CREDIT_HTML, MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID, MAPILLARY_KEY_ID,
MAPILLARY_SOURCE_METHODS,
} from './providers/mapillary/policy.js'; } from './providers/mapillary/policy.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js'; export { STREET_LEVEL_LAYER_ID } from './policy.js';
@@ -39,12 +38,6 @@ export function createStreetLevelLayer({
services = {}, services = {},
photoViewer = null, 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 state = createState({ services });
const parts = {}; const parts = {};
const context = { state, parts }; const context = { state, parts };
@@ -169,10 +162,7 @@ export function createStreetLevelLayer({
requiresKeyId: MAPILLARY_KEY_ID, requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) { init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer; state.viewer = viewer;
state.initialized = true;
parts.marker.ensure(viewer); parts.marker.ensure(viewer);
parts.marker.setVisible(false); parts.marker.setVisible(false);
parts.sequences.ensureCollections(viewer); parts.sequences.ensureCollections(viewer);
@@ -209,14 +199,13 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(null); parts.framing.attachNavigation(null);
state.listeners.clear(); state.listeners.clear();
state.viewer = null; state.viewer = null;
state.initialized = false;
}, },
getStats() { getStats() {
const coverage = coverageStats(); const coverage = coverageStats();
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED'; const keyLabel = keyStatusLabel(coverage);
let loadingLabel = ''; let loadingLabel = '';
if (coverage.keyRequired) loadingLabel = keyLabel; if (keyLabel) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...'; else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return { return {
@@ -224,10 +213,7 @@ export function createStreetLevelLayer({
loading: coverage.loading, loading: coverage.loading,
keyRequired: coverage.keyRequired, keyRequired: coverage.keyRequired,
// A rejected key's message names the fix; a missing one says so. // A rejected key's message names the fix; a missing one says so.
error: error: keyLabel && !coverage.keyRejected ? keyLabel : coverage.error,
coverage.keyRequired && !coverage.keyRejected
? keyLabel
: coverage.error,
loadingLabel, loadingLabel,
}; };
}, },
@@ -283,8 +269,6 @@ export function createStreetLevelLayer({
}, },
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(), resizeViewer: () => parts.viewerHost.resize(),
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
}; };
return layer; 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); 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({ const source = fakeMapillarySource({
getSequenceImages: async () => { getSequenceImages: async () => {
throw new Error('Sequence images unavailable'); throw new Error('Sequence images unavailable');
}, },
}); });
const { layer } = await startLayer(t, { source }); // The photo's pose names its sequence, which opening selects.
await layer.selectSequence('seq-1'); const photoViewer = fakePhotoViewer({
layer.attachViewerHost(null); pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
await layer.openImage('img1'); });
const { layer } = await startLayer(t, { source, photoViewer });
layer.attachViewerHost({});
assert.equal(await layer.openImage('img1'), true);
await settle();
let ui = layer.getUIState(); let ui = layer.getUIState();
assert.equal(ui.sequence.error, 'Sequence images unavailable'); assert.equal(ui.sequence.error, 'Sequence images unavailable');
assert.match(ui.street.error, /Open the Street Level panel/); assert.equal(ui.street.error, null, 'the photo itself is fine');
layer.clearSequence(); layer.closeViewer();
ui = layer.getUIState(); ui = layer.getUIState();
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence'); 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) => { 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) => { 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); const { layer } = await startLayer(t);
assert.deepEqual(layer.getParams(), { assert.deepEqual(layer.getParams(), {
mapillary: true, 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. */ /** Filter the layer starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); 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); const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s])); entry.sequences = new Map(sequences.map((s) => [s.id, s]));
entry.primitives = buildSequencePrimitives(sequences); entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const primitive of entry.primitives) for (const primitive of entry.primitives)
scene.groundPrimitives.add(primitive); scene.groundPrimitives.add(primitive);
} }
@@ -212,7 +211,7 @@ export function createCoverage({ state, source }) {
) )
return; return;
const decoded = decodeCoverageTile(bytes, tile); 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. // Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort( entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
@@ -311,7 +310,7 @@ export function createCoverage({ state, source }) {
if (!bbox) return { hint: NO_GROUND_HINT }; if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from between the camera's ground point and the screen centre's: // Ranked from between the camera's ground point and the screen centre's:
// a tilted view's box centre can sit kilometres past both. // 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, limit: COVERAGE_MAX_TILES,
from: viewFocus(viewer, ranged), from: viewFocus(viewer, ranged),
}); });
@@ -430,7 +429,6 @@ export function createCoverage({ state, source }) {
setKeyStatus, setKeyStatus,
rebuild, rebuild,
highlight, highlight,
sequenceCount,
stats, stats,
}; };
} }
@@ -66,7 +66,6 @@ function fakeViewer(view) {
view.lat + 0.002 - (point.y / 100) * 0.004, 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(); await settle();
const [entry] = state.coverage.tiles.values(); const [entry] = state.coverage.tiles.values();
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES); 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(
assert.equal(coverage.sequenceCount(), Math.min(300, cap)); 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. // 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.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false); assert.equal(entry.sequences.has('flat-699'), false);
state.filter = { pano: 'all', sinceDays: 0 }; state.filter = { pano: 'all', sinceDays: 0 };
coverage.rebuild(); 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.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap'); assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear(); coverage.clear();
@@ -359,7 +362,7 @@ test('a sequence that crosses a tile edge is counted once', async () => {
); );
} }
await settle(); 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(); coverage.clear();
}); });
-1
View File
@@ -52,7 +52,6 @@ export function createState({ services }) {
services, services,
viewer: null, viewer: null,
enabled: false, enabled: false,
initialized: false,
listeners: new Set(), listeners: new Set(),
notify: null, notify: null,
/** The share link's `mapillary` switch: off draws nothing. */ /** 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) { export function normalizeBbox(input) {
const values = Array.isArray(input) if (!Array.isArray(input) || input.length !== 4) return null;
? input const [w, s, e, n] = input.map(Number);
: 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 (![w, s, e, n].every(Number.isFinite)) return null; if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e)); const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(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), * crosses the date line), nearest to `from` first (default: the box centre),
* capped at `limit`. Distances are measured the short way round ±180°. * capped at `limit`. Distances are measured the short way round ±180°.
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options] * @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 } = {}) { export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
const [west, south, east, north] = Array.isArray(bbox) 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], [-180, south, east, north],
] ]
: [bbox]; : [bbox];
const tiles = halves const grid = halves
.map(normalizeBbox) .map(normalizeBbox)
.filter(Boolean) .filter(Boolean)
.flatMap((box) => tileGrid(box, z)); .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 cx = tileXAt(wrapLon(centre.lon), z);
const cy = tileYAt(centre.lat, z); const cy = tileYAt(centre.lat, z);
// Each tile's distance is computed once; ties keep their row order. // Each tile's distance is computed once; ties keep their row order.
const ranked = tiles const ranked = grid
.map((tile) => { .map((tile) => {
const dx = Math.abs(tile.x + 0.5 - cx); const dx = Math.abs(tile.x + 0.5 - cx);
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2; 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) .sort((a, b) => a.d - b.d)
.map(({ tile }) => tile); .map(({ tile }) => tile);
const truncated = ranked.length > limit; return ranked.slice(0, limit);
return {
tiles: truncated ? ranked.slice(0, limit) : ranked,
truncated,
total: ranked.length,
};
} }
/** The tiles at zoom z in a normalized box, row by row. */ /** 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, north: 38.69,
}); });
assert.equal(normalizeBbox([0, 0, 0, 1]), null); 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); assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
}); });
test('tilesForBbox orders from the centre outwards and honours the cap', () => { 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, limit: 5,
}); });
assert.equal(result.total, 160); assert.equal(tiles.length, 5);
assert.equal(result.truncated, true);
assert.equal(result.tiles.length, 5);
const centreX = lonToTileX(-121.46, 14); const centreX = lonToTileX(-121.46, 14);
const centreY = latToTileY(38.565, 14); const centreY = latToTileY(38.565, 14);
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1); assert.ok(Math.abs(tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1); assert.ok(Math.abs(tiles[0].y - centreY) <= 1);
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14); const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
assert.equal(full.truncated, false); assert.equal(full.length, 160);
assert.equal(full.tiles.length, 160);
}); });
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => { 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(179.99, 1), 1);
assert.equal(lonToTileX(-180, 1), 0); assert.equal(lonToTileX(-180, 1), 0);
const columns = new Set( 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]); 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', () => { 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)); const columns = new Set(tiles.map((tile) => tile.x));
assert.ok(columns.has(255), 'west of the line'); assert.ok(columns.has(255), 'west of the line');
assert.ok(columns.has(0), 'east 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', 'nothing from the middle of the map',
); );
// The limit keeps the tiles nearest the line. // 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)); 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 key = (t) => `${t.x}/${t.y}`;
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)]; const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
const plain = tilesForBbox(bbox, 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 // 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. // 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`); 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( assert.ok(
[n - 2, n - 1, 0, 1].includes(tile.x), [n - 2, n - 1, 0, 1].includes(tile.x),
`${key(tile)} near the line`, `${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 { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
const lat = (rowSouth + rowNorth) / 2; const lat = (rowSouth + rowNorth) / 2;
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } }); const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
assert.deepEqual(west.tiles.slice(0, 2).map(key), [ assert.deepEqual(west.slice(0, 2).map(key), [`${n - 1}/${row}`, `0/${row}`]);
`${n - 1}/${row}`,
`0/${row}`,
]);
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } }); const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
assert.deepEqual(east.tiles.slice(0, 2).map(key), [ assert.deepEqual(east.slice(0, 2).map(key), [`0/${row}`, `${n - 1}/${row}`]);
`0/${row}`,
`${n - 1}/${row}`,
]);
}); });
test('ranking from the camera keeps the ground under it, not the box centre', () => { 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 z = 14;
const key = (t) => `${t.x}/${t.y}`; const key = (t) => `${t.x}/${t.y}`;
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`; 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'); assert.equal(plain.includes(under), false, 'the box centre drops it');
const ranked = tilesForBbox(bbox, z, { limit: 9, from }); const ranked = tilesForBbox(bbox, z, { limit: 9, from });
assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first'); assert.equal(key(ranked[0]), under, 'the tile under the camera first');
assert.equal(ranked.total > 9, true);
}); });
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => { 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; 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 * Visible [west, south, east, north] degrees, or null when fewer than four
* hit count, so a horizon cannot inflate the box to the world. * 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 * `groundHeight` raises the ellipsoid to the ground (rays to the bare
* ellipsoid under Denver land kilometres too far out) and `maxRange` (m) * ellipsoid under Denver land kilometres too far out) and `maxRange` (m)
* drops near-horizon hits; with it, too few hits give null rather than * drops near-horizon hits.
* Cesium's view rectangle, which can span the horizon.
*/ */
export function visibleBbox( export function visibleBbox(
viewer, viewer,
{ grid = 5, groundHeight = null, maxRange = null } = {}, { groundHeight = null, maxRange = null } = {},
) { ) {
const scene = viewer?.scene; const scene = viewer?.scene;
const camera = viewer?.camera; const camera = viewer?.camera;
@@ -101,10 +103,10 @@ export function visibleBbox(
groundHeight, groundHeight,
); );
const point = new Cesium.Cartesian2(); const point = new Cesium.Cartesian2();
for (let i = 0; i <= grid; i++) { for (let i = 0; i <= BBOX_GRID; i++) {
for (let j = 0; j <= grid; j++) { for (let j = 0; j <= BBOX_GRID; j++) {
point.x = (width * i) / grid; point.x = (width * i) / BBOX_GRID;
point.y = (height * j) / grid; point.y = (height * j) / BBOX_GRID;
const carto = groundHit(camera, point, ellipsoid, maxRange); const carto = groundHit(camera, point, ellipsoid, maxRange);
if (carto) hits.push(carto); if (carto) hits.push(carto);
} }
@@ -142,15 +144,7 @@ export function visibleBbox(
} }
if (west !== east && north - south > 0) return [west, south, east, north]; if (west !== east && north - south > 0) return [west, south, east, north];
} }
if (Number.isFinite(maxRange)) return null; 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),
];
} }
/** The point on the ground under the camera, as {lon, lat} degrees. */ /** 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 ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540;
const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`; const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`;
const chosen = (from) => const chosen = (from) =>
tilesForBbox(bbox, 13, { limit: 9, from }).tiles.map( tilesForBbox(bbox, 13, { limit: 9, from }).map(
(tile) => `${tile.x}/${tile.y}`, (tile) => `${tile.x}/${tile.y}`,
); );
assert.ok( assert.ok(
+2 -10
View File
@@ -121,20 +121,12 @@ export function createViewerHost({ state, parts, adapter }) {
} }
function resize() { function resize() {
try { if (unsubscribe) adapter.resize();
if (unsubscribe) adapter.resize();
} catch {
/* no-op */
}
} }
function setRenderMode(mode) { function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try { if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
} catch {
/* adapter not ready */
}
notify(); notify();
} }
-1
View File
@@ -103,6 +103,5 @@ export function rayCamera({
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; 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 { 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. // 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) { function presentStatus(state) {
const pressed = drawing(state); const pressed = drawing(state);
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected) const key = keyStatusLabel(state);
return { text: 'KEY REJECTED', tone: 'warn', pressed, title }; if (key) return { text: key, tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
if (state.coverage.loading) if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title }; return { text: 'LOADING', tone: 'busy', pressed, title };
return pressed return pressed