refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory

- No pointer cursor over coverage lines (no other layer has one); a click
  test replaces the hover tests.
- No legend: one source, one colour.
- The on-globe credit lives in index.js; credits.js is gone.
- Billboard glyphs are SVG data URLs with the same geometry, so tests need
  no canvas stub.
- The tile cache keeps the 120 most recently used tiles, same 24 h TTL,
  instead of a byte budget; listTileLayers was test-only.
- providers/mapillary/ is flattened into the layer and its policy merged
  into one policy.js; the source's method list is a literal in the catalog.
- One fakeCesiumViewer for the layer tests.
- The fixture gate keeps the steps the unit tests do not cover: coverage and
  credit, a rejected key, line -> cone -> photo, close, a keyless page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-08 19:17:45 +04:00
1 parent 34d43c7e3f
commit e214129e45
41 files changed
+335 -909

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@
- Street Level: Mapillary street-level imagery (free client token, CC BY-SA
4.0). A right-rail panel holds an ON/OFF pill, 360°/flat and captured-since
filters, a legend and an embedded viewer with EXPAND and FIT/FILL. Coverage
filters and an embedded viewer with EXPAND and FIT/FILL. Coverage
lines (below 60 km) and image cones drape on terrain and Google 3D tiles;
opening a photo frames it with one camera flight. Share links carry the
switch, the filters and the panel's collapsed state. A server proxy adds the
+1 -1
View File
@@ -3063,7 +3063,7 @@ so the whole photo fits.
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
from fixtures (`src/layers/streetLevel/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), but its server (default `:4173`) still needs
some `MAPILLARY_CLIENT_TOKEN`. CI runs it `--strict` against `vite preview`.
+1 -1
View File
@@ -260,7 +260,7 @@
"node": "./server/providers/mapillary.js"
},
"./layers/street-level": "./src/layers/streetLevel/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
+11 -13
View File
@@ -862,7 +862,7 @@
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/layers/streetLevel/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
@@ -870,18 +870,16 @@
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/coverage.js",
"src/layers/streetLevel/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/decode.js",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/viewer.js",
"src/layers/streetLevel/source.js",
"server/providers/mapillary.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
@@ -892,15 +890,15 @@
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/mapillary.test.mjs",
"src/layers/streetLevel/decode.test.mjs",
"src/layers/streetLevel/coverage.test.mjs",
"src/layers/streetLevel/coverageFixture.mjs",
"src/layers/streetLevel/cameraFraming.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/sequences.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
+18 -24
View File
@@ -1190,23 +1190,21 @@
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/coverage.js",
"src/layers/streetLevel/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/decode.js",
"src/layers/streetLevel/coverageFixture.mjs",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/viewer.js",
"src/layers/streetLevel/source.js",
"scripts/qa-street-level.mjs"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -2130,23 +2128,21 @@
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/coverage.js",
"src/layers/streetLevel/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/decode.js",
"src/layers/streetLevel/coverageFixture.mjs",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/viewer.js",
"src/layers/streetLevel/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
"src/sources/capability.js",
@@ -2645,23 +2641,21 @@
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/coverage.js",
"src/layers/streetLevel/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/decode.js",
"src/layers/streetLevel/coverageFixture.mjs",
"src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/viewer.js",
"src/layers/streetLevel/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
"src/sources/capability.js",
+5 -153
View File
@@ -12,7 +12,7 @@
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
import { encodeCoverageTile } from '../src/layers/streetLevel/coverageFixture.mjs';
import {
hookRenderErrors,
readRenderErrors,
@@ -28,7 +28,6 @@ import {
} from './fixtures/street-level/mapillaryGraph.mjs';
const VIEWPORT = { width: 1440, height: 900 };
const PHONE = { width: 390, height: 844 };
/** Over downtown Sacramento, above the fixture photo line, looking down. */
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
const DAY_MS = 86_400_000;
@@ -38,7 +37,7 @@ const DAY_MS = 86_400_000;
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
* @returns {Uint8Array} empty below z11, which the layer never asks for
*/
export function fixtureTile(z, x, y, now = Date.now()) {
function fixtureTile(z, x, y, now = Date.now()) {
if (z < 11) return new Uint8Array(0);
const { west, east, south, north } = tileBounds(x, y, z);
const at = (t, lo, hi) => lo + (hi - lo) * t;
@@ -387,33 +386,7 @@ async function main() {
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)',
);
await step('the panel starts as a collapsed right-rail strip', async () => {
const box = await page.evaluate(() => {
const panel = document.getElementById('street-level-panel');
const r = panel.getBoundingClientRect();
return {
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement.id === 'right-context-rail',
body: getComputedStyle(document.getElementById('sl-body')).display,
width: r.width,
right: r.right,
};
});
assert.equal(box.collapsed, true);
assert.equal(box.inRail, true);
assert.equal(box.body, 'none');
assert.ok(box.width <= 200 && box.right <= VIEWPORT.width);
});
await step('expanding the strip shows the body and legend', async () => {
await expandPanel(page);
assert.equal(
await page.$$eval('#sl-legend li', (items) => items.length),
2,
'Mapillary and Selected',
);
});
await expandPanel(page);
if (!configured) {
await step('keyless server: KEY REQUIRED gates the controls', () =>
assertKeyRequired(page),
@@ -443,79 +416,6 @@ async function main() {
},
);
await step('the header pill switches the layer off and on', async () => {
await press(page, '#sl-status');
await statusIs(page, 'OFF');
assert.equal(
await page.evaluate(() =>
window.__godsEyeView.dataManager.isEnabled('street-level'),
),
false,
);
assert.equal((await getUI(page)).coverage.count, 0);
await press(page, '#sl-status');
await waitForCoverage(page);
await statusIs(page, 'ON');
});
await step('the 360° filter narrows coverage', async () => {
const before = (await getUI(page)).coverage.count;
await press(page, '[data-sl-pano="pano"]');
await uiUntil(
page,
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
);
const after = (await getUI(page)).coverage.count;
// Fixtures hold flat sequences too, so the filter must drop some.
assert.ok(
fixtures ? after < before : after <= before,
`${after} vs ${before}`,
);
await press(page, '[data-sl-pano="all"]');
await uiUntil(
page,
(u, n) => u.filter.pano === 'all' && u.coverage.count === n,
before,
);
});
await step(
'the SINCE slider narrows coverage and names its cut-off',
async () => {
const before = (await getUI(page)).coverage.count;
const setStop = (index) =>
page.$eval(
'#sl-since',
(input, value) => {
input.value = String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
},
index,
);
await setStop(5);
await uiUntil(
page,
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
);
const after = (await getUI(page)).coverage.count;
assert.ok(
fixtures ? after < before : after <= before,
`${after} vs ${before}`,
);
assert.match(
await page.$eval('#sl-since-label', (node) => node.textContent),
/^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/,
);
await setStop(0);
await uiUntil(
page,
(u, n) => u.filter.sinceDays === 0 && u.coverage.count === n,
before,
);
},
);
await fixtureStep(
'a rejected key reads KEY REJECTED and clears when switched off',
async () => {
@@ -549,7 +449,6 @@ async function main() {
},
);
let imageId = null;
await park(page);
await waitForCoverage(page);
await step(
@@ -585,7 +484,6 @@ async function main() {
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
imageId = street.imageId;
await page.waitForSelector('#sl-viewer .mapillary-dom', {
timeout: 60_000,
});
@@ -606,8 +504,8 @@ async function main() {
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
if (fixtures) {
assert.ok(
fixture.images.some((image) => image.id === imageId),
`fixture photo ${imageId}`,
fixture.images.some((image) => image.id === street.imageId),
`fixture photo ${street.imageId}`,
);
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
}
@@ -631,52 +529,6 @@ async function main() {
);
});
await step('on a phone the whole photo fits in the panel', async () => {
// Width alone drives the phone layout; toggling isMobile would reload.
await page.setViewport(PHONE);
await page.waitForFunction((w) => innerWidth === w, {}, PHONE.width);
await expandPanel(page);
await page.evaluate((id) => {
void window.__godsEyeView.dataManager.layers
.get('street-level')
.module.openImage(id);
}, imageId);
await uiUntil(
page,
(u, id) => u.street.imageId === id && !u.street.loading,
imageId,
60_000,
);
// The panel settles its phone layout over a frame or two.
const fits = () => {
const inner = document
.querySelector('.street-level-panel-inner')
.getBoundingClientRect();
const meta = document
.getElementById('sl-image-meta')
.getBoundingClientRect();
const viewer = document
.getElementById('sl-viewer')
.getBoundingClientRect();
return {
cut: Math.round(meta.bottom - inner.bottom),
height: Math.round(viewer.height),
overflowX: document.documentElement.scrollWidth > innerWidth,
};
};
await page
.waitForFunction(
`(${fits})().cut <= 1 && (${fits})().height >= 100`,
{ timeout: 10_000 },
)
.catch(() => {});
const fit = await page.evaluate(fits);
assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`);
assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`);
assert.equal(fit.overflowX, false, 'no sideways scroll');
await page.setViewport(VIEWPORT);
});
await fixtureStep(
'keyless server (second page): KEY REQUIRED gates the controls',
async () => {
+13 -29
View File
@@ -18,9 +18,8 @@ const FETCH_TIMEOUT_MS = 60_000;
export const TILE_UPSTREAM_CONCURRENCY = 6;
/** Tiles change only when new imagery is processed. */
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
/** Per-entry cost on top of its bytes, so empty tiles still count. */
const ENTRY_OVERHEAD_BYTES = 1024;
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
export const TILE_MEMORY_MAX_TILES = 120;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
@@ -139,39 +138,30 @@ function unlessAborted(promise, signal) {
});
}
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
const memory = new Map();
let memoryBytes = 0;
/**
* Upstream fetches shared per tile, with the requests still waiting on each.
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
*/
const inFlight = new Map();
const cost = (bytes) => bytes.length + ENTRY_OVERHEAD_BYTES;
function forget(key) {
const hit = memory.get(key);
if (!hit) return null;
memory.delete(key);
memoryBytes -= cost(hit.bytes);
return hit;
}
function remember(key, bytes, at = Date.now()) {
// One huge (untrimmed) tile must not flush everything else.
if (cost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
forget(key);
memory.delete(key);
memory.set(key, { bytes, at });
memoryBytes += cost(bytes);
for (const [oldest] of memory) {
if (memoryBytes <= TILE_MEMORY_BUDGET_BYTES) break;
forget(oldest);
for (const oldest of memory.keys()) {
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
memory.delete(oldest);
}
}
function recall(key) {
const hit = forget(key);
if (!hit || Date.now() - hit.at > TILE_TTL_MS) return null;
const hit = memory.get(key);
if (!hit) return null;
if (Date.now() - hit.at > TILE_TTL_MS) {
memory.delete(key);
return null;
}
remember(key, hit.bytes, hit.at); // most recently used again
return hit.bytes;
}
@@ -225,11 +215,5 @@ export async function fetchTile(address, { signal } = {}) {
/** Test seam: forget every cached and in-flight tile. */
export function _resetTileCacheForTest() {
memory.clear();
memoryBytes = 0;
inFlight.clear();
}
/** Test seam: how many tiles memory holds and the bytes charged for them. */
export function _tileMemoryForTest() {
return { entries: memory.size, bytes: memoryBytes };
}
-13
View File
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
}, null);
return dropped ? Buffer.from(writer.finish()) : bytes;
}
/** Names of the layers inside a vector tile, in order. */
export function listTileLayers(bytes) {
const names = [];
if (!bytes?.length) return names;
new PbfReader(bytes).readFields((tag, _result, pbf) => {
if (tag !== TILE_LAYER_FIELD) return;
const end = pbf.readVarint() + pbf.pos;
names.push(layerName(bytes.subarray(pbf.pos, end)));
pbf.pos = end;
}, null);
return names;
}
+1 -2
View File
@@ -3,7 +3,6 @@ import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -57,7 +56,7 @@ const SOURCE_METHODS = Object.freeze({
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
mapillary: MAPILLARY_SOURCE_METHODS,
mapillary: ['getStatus', 'getTile', 'getSequenceImages'],
});
/**
+23 -52
View File
@@ -2,48 +2,23 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFraming } from './cameraFraming.js';
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights) {
let current = null;
function stop() {
const flight = current;
current = null;
flight?.options.cancel?.();
return Boolean(flight);
}
const camera = {
cancelled: 0,
flyToBoundingSphere(sphere, options = {}) {
stop();
current = { sphere, options };
flights.push(current);
},
cancelFlight() {
if (stop()) camera.cancelled++;
},
land() {
const flight = current;
current = null;
flight?.options.complete?.();
},
};
return camera;
}
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose globe terrain is `globe`.
*/
function setup({ sampled, globe = null, altitude = 149 }) {
const flights = [];
const viewer = fakeCesiumViewer();
Object.assign(viewer.scene, {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
});
const state = {
services: {},
street: {
@@ -51,19 +26,13 @@ function setup({ sampled, globe = null, altitude = 149 }) {
bearing: 105,
altitude,
},
viewer: {
scene: {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
},
camera: flightCamera(flights),
},
viewer,
};
return {
framing: createCameraFraming({ state }),
state,
flights,
viewer,
flights: viewer.flights.list,
};
}
@@ -88,35 +57,37 @@ test('a sample kilometres underground falls back to the globe, then the image al
});
test('cancel stops the framing flight while it is still ours', () => {
const { framing, state } = setup({ sampled: 121 });
const { camera } = state.viewer;
const { framing, viewer } = setup({ sampled: 121 });
framing.frame(framing.begin());
framing.cancel();
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
assert.equal(
viewer.flights.cancelled,
1,
'the flight toward the photo stops',
);
framing.cancel();
assert.equal(camera.cancelled, 1, 'and only once');
assert.equal(viewer.flights.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
framing.frame(framing.begin());
framing.frame(framing.begin());
framing.cancel();
assert.equal(camera.cancelled, 2);
assert.equal(viewer.flights.cancelled, 2);
});
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
const { framing, state, flights } = setup({ sampled: 121 });
const { camera } = state.viewer;
const { framing, viewer, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
camera.land();
viewer.flights.land();
framing.cancel();
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
framing.frame(framing.begin());
// A search result flies the globe elsewhere; Cesium cancels ours first.
camera.flyToBoundingSphere(null, {});
viewer.camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
framing.cancel();
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
});
/**
@@ -1,9 +1,9 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { isActive } from '../../state.js';
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
import { passesImageryFilter, resolveFilter } from './filter.js';
import { isActive } from './state.js';
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
import {
COLORS,
COVERAGE_LINE_WIDTH_PX,
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
import { freshCoverage } from '../../state.js';
import { freshCoverage } from './state.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js';
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
import { lonToTileX, latToTileY, tileBounds } from './tileMath.js';
const RAD = Math.PI / 180;
// Node has no WebGL context to report line-width limits; a browser's
-32
View File
@@ -1,32 +0,0 @@
import * as Cesium from 'cesium';
/**
* A static Cesium credit shown while the layer draws: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredit(html) {
let shown = null;
function show(viewer) {
if (shown || !viewer?.creditDisplay) return;
try {
const credit = new Cesium.Credit(html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown = credit;
} catch {
/* credit display unavailable */
}
}
function hide(viewer) {
if (!shown) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(shown);
} catch {
/* credit display already torn down */
}
shown = null;
}
return { show, hide };
}
@@ -1,6 +1,6 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
import { tileLocalToLonLat } from './tileMath.js';
/**
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
+35 -53
View File
@@ -1,48 +1,27 @@
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position marker. */
const _cache = new Map();
const px = (n) => Number(n.toFixed(2));
/** A square canvas glyph drawn once per key, centred at (c, c). */
function cachedGlyph(key, size, draw) {
if (_cache.has(key)) return _cache.get(key);
const element = document.createElement('canvas');
element.width = size;
element.height = size;
draw(element.getContext('2d'), size / 2);
_cache.set(key, element);
return element;
function svg(size, body) {
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
}
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.fill();
ctx.globalAlpha = 1;
function wedge(size, radius, halfAngle, color, opacity) {
const c = size / 2;
const dx = px(radius * Math.sin(halfAngle));
const dy = px(radius * Math.cos(halfAngle));
const r = px(radius);
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
}
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.globalAlpha = alpha;
ctx.stroke();
ctx.globalAlpha = 1;
function ring(size, radius, color, width, opacity) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
}
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = width;
ctx.strokeStyle = outline;
ctx.stroke();
function dot(size, radius, color, outline, width) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
}
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
@@ -51,25 +30,28 @@ export function imageConeGlyph({
color = '#e8eaed',
pano = false,
} = {}) {
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
dot(
ctx,
c,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
);
});
const shape = pano
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
: wedge(size, size * 0.46, 0.62, color, 0.42);
return svg(
size,
shape +
dot(
size,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
),
);
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
});
return svg(
size,
wedge(size, size * 0.48, 0.55, color, 0.55) +
dot(size, size * 0.2, color, '#0a0a0f', 2) +
ring(size, size * 0.3, color, 1.5, 0.8),
);
}
+24 -11
View File
@@ -1,18 +1,19 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredit } from './credits.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { normalizeFilter, sameFilter } from './filter.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 {
keyStatusLabel,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
} from './providers/mapillary/policy.js';
STREET_LEVEL_LAYER_ID,
} from './policy.js';
import { createCoverage } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
@@ -33,8 +34,8 @@ const COVERAGE_OFF = Object.freeze({
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
* sequence's image cones, and the photo viewer in the panel.
* @param {{source: object, services?: object, photoViewer?: object}} options
* `source` is providers/mapillary/source.js or a stand-in; `photoViewer`
* stands in for the MapillaryJS adapter (providers/mapillary/viewer.js).
* `source` is source.js or a stand-in; `photoViewer` stands in for the
* MapillaryJS adapter (viewer.js).
*/
export function createStreetLevelLayer({
source,
@@ -44,7 +45,6 @@ export function createStreetLevelLayer({
const state = createState({ services });
const parts = {};
const context = { state, parts };
const credit = createCredit(MAPILLARY_CREDIT_HTML);
parts.marker = createMarker(context);
parts.framing = createCameraFraming(context);
parts.coverage = createCoverage({ state, source });
@@ -97,12 +97,25 @@ export function createStreetLevelLayer({
return keyCheck;
}
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
let credit = null;
function showCredit(viewer) {
if (credit || !viewer?.creditDisplay) return;
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
viewer.creditDisplay.addStaticCredit(credit);
}
function hideCredit(viewer) {
if (!credit) return;
viewer?.creditDisplay?.removeStaticCredit?.(credit);
credit = null;
}
function startDrawing() {
const viewer = state.viewer;
if (!viewer) return;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
credit.show(viewer);
showCredit(viewer);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
@@ -116,7 +129,7 @@ export function createStreetLevelLayer({
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
credit.hide(state.viewer);
hideCredit(state.viewer);
}
/** The share link's `mapillary` switch: off, the layer draws nothing. */
+5 -78
View File
@@ -2,83 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { MAPILLARY_CREDIT_HTML } from './policy.js';
import {
fakeCesiumViewer,
fakeMapillarySource,
fakePhotoViewer,
rayCamera,
} from '../../testSupport/streetLevelFakes.mjs';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display. With
* `view`, the camera looks straight down on Sacramento from 900 m, so
* coverage asks for tiles.
*/
function fakeViewer({ view = false } = {}) {
const credits = [];
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
let flight = null;
const flights = { started: 0, cancelled: 0 };
const stopFlight = () => {
const current = flight;
flight = null;
current?.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = options;
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
},
};
}
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
* and (unless `enable` is false) enabled.
@@ -93,18 +23,15 @@ async function startLayer(
} = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
// The click handler listens for Esc on the document.
globalThis.document = new EventTarget();
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeViewer({ view });
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ source, photoViewer });
layer.init(viewer);
if (enable) layer.enable(viewer);
-7
View File
@@ -3,13 +3,6 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
+50 -2
View File
@@ -1,11 +1,21 @@
/** Identity and shared tuning for the Street Level layer. */
/** Identity and tuning for the Street Level layer and its Mapillary imagery. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Pick id of the viewer position marker. */
export const POSITION_PICK_ID = 'sl:pos';
/** The selection highlight and the position marker. */
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
export const PICK_PREFIX = Object.freeze({
root: 'mly:',
sequence: 'mly:seq:',
image: 'mly:img:',
});
/** Mapillary green for lines and cones, GEV cyan for the selection, amber for the marker. */
export const COLORS = Object.freeze({
coverage: '#05cb63',
selected: '#00d4ff',
position: '#ffb300',
});
@@ -19,6 +29,44 @@ 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 });
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own';
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
/** Deep link to an image on mapillary.com, as the web app shares them. */
export function mapillaryImageUrl(imageId) {
const id = String(imageId || '').trim();
if (!id) return 'https://www.mapillary.com/app/';
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
}
/** Fields requested for a sequence's images. */
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano';
/** 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';
@@ -1,63 +0,0 @@
import { COLORS as SHARED_COLORS } from '../../policy.js';
/** Identity and tuning for Street Level's Mapillary imagery. */
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have. */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
export const PICK_PREFIX = Object.freeze({
root: 'mly:',
sequence: 'mly:seq:',
image: 'mly:img:',
});
/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */
export const COLORS = Object.freeze({
coverage: '#05cb63',
selected: SHARED_COLORS.selected,
});
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own';
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
/** Deep link to an image on mapillary.com, as the web app shares them. */
export function mapillaryImageUrl(imageId) {
const id = String(imageId || '').trim();
if (!id) return 'https://www.mapillary.com/app/';
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
}
/** Fields requested for a sequence's images. */
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano';
+8 -80
View File
@@ -1,9 +1,12 @@
import * as Cesium from 'cesium';
import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
import { PICK_PREFIX } from './providers/mapillary/policy.js';
import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
import {
PICK_PREFIX,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
/** One click/hover handler for coverage lines, image cones and the marker. */
/** One click handler for coverage lines, image cones and the marker. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
@@ -51,67 +54,6 @@ export function createSelection({ state, parts }) {
parts.sequences.clearSelection();
}
/** The pointer over the globe, and whether a frame is queued to pick it. */
let pointer = null;
let hoverQueued = false;
let hoverCursor = false;
/** Buttons held: any of them, with any modifier, is a camera drag. */
let pointerButtons = 0;
let removeHoverWatchers = null;
function setHoverCursor(canvas, hit) {
if (hit === hoverCursor) return;
canvas.style.cursor = hit ? 'pointer' : '';
hoverCursor = hit;
}
function pickHover() {
hoverQueued = false;
const canvas = state.viewer?.scene?.canvas;
// Switched off (or torn down) since the frame was queued, or dragging.
if (!canvas || !state.enabled || !state.clickHandler || pointerButtons)
return;
let hit = false;
try {
const picked = state.viewer.scene.pick(pointer);
hit = ownsPick(
picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id,
);
} catch {
hit = false;
}
setHoverCursor(canvas, hit);
}
/** Pick the pointer on the next frame; at most one pick a frame. */
function queueHoverPick() {
if (hoverQueued || !pointer || pointerButtons) return;
hoverQueued = true;
requestAnimationFrame(pickHover);
}
/** Pointer cursor over anything this layer owns. */
function onMove(movement) {
if (!state.enabled) return;
pointer = Cesium.Cartesian2.clone(movement.endPosition, pointer);
queueHoverPick();
}
/** Track held buttons, and pick again once the camera rests under a still pointer. */
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
const onButtons = (event) => {
pointerButtons = event.buttons ?? 0;
};
const types = ['pointerdown', 'pointermove', 'pointerup'];
for (const type of types) canvas.addEventListener?.(type, onButtons);
const removeEnd = viewer.camera?.moveEnd?.addEventListener(queueHoverPick);
removeHoverWatchers = () => {
for (const type of types) canvas.removeEventListener?.(type, onButtons);
removeEnd?.();
};
}
function install(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
@@ -121,11 +63,6 @@ export function createSelection({ state, parts }) {
onClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
state.clickHandler.setInputAction(
onMove,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
watchHover(viewer);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
}
@@ -133,16 +70,7 @@ export function createSelection({ state, parts }) {
function uninstall() {
// Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return;
removeHoverWatchers?.();
removeHoverWatchers = null;
pointer = null;
pointerButtons = 0;
if (hoverCursor && state.viewer?.scene?.canvas) {
state.viewer.scene.canvas.style.cursor = '';
hoverCursor = false;
}
if (state.clickHandler && !state.clickHandler.isDestroyed())
state.clickHandler.destroy();
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
+28 -103
View File
@@ -3,17 +3,9 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import { createSelection } from './selection.js';
/**
* The selection installed on a stand-in viewer. Animation frames queue until
* `frame()` runs them.
*/
/** The selection installed on a stand-in viewer, with what its clicks reach. */
function harness({ selected = false } = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task);
const saved = globalThis.document;
globalThis.document = new EventTarget();
// The document's keydown listeners, so a test can hand them any target.
const keyListeners = new Set();
@@ -31,26 +23,17 @@ function harness({ selected = false } = {}) {
if (type === 'keydown') keyListeners.delete(listener);
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const sequences = { selected, cleared: 0, selects: [] };
const opened = [];
/** What the pointer is over: a pick id, or nothing. */
const scene = { under: null };
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
const camera = { moveEnd: new Cesium.Event() };
const viewer = {
camera,
scene: {
canvas,
pick(position) {
picks.push(position);
return scene.under && { id: scene.under };
},
},
scene: { canvas, pick: () => scene.under && { id: scene.under } },
};
const state = {
viewer,
@@ -68,20 +51,24 @@ function harness({ selected = false } = {}) {
state,
parts: {
sequences: {
select: (id) => sequences.selects.push(id),
clearSelection() {
sequences.cleared++;
sequences.selected = false;
},
},
openImage: (id) => opened.push(id),
},
});
selection.install(viewer);
const action = (type) =>
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
const onMove = action('MOUSE_MOVE');
return {
state,
sequences,
opened,
scene,
click: () => action('LEFT_CLICK')({ position: { x: 10, y: 10 } }),
/** A keydown reaching the document from `target`; the event as handled. */
key(key, target, { defaultPrevented = false } = {}) {
const event = {
@@ -95,94 +82,32 @@ function harness({ selected = false } = {}) {
for (const listener of [...keyListeners]) listener(event);
return event;
},
scene,
canvas,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
camera,
/** A pointer event on the canvas with `buttons` held. */
buttons(type, buttons) {
const event = new Event(type);
Object.defineProperty(event, 'buttons', { value: buttons });
canvas.dispatchEvent(event);
},
frame() {
for (const task of frames.splice(0)) task();
},
restore() {
selection.uninstall();
Object.assign(globalThis, saved);
globalThis.document = saved;
},
};
}
test('hover picks once a frame, at the latest position, and shows a pointer over a line', () => {
test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
for (let x = 0; x < 5; x++) h.move(x);
h.frame();
assert.deepEqual(
h.picks.map(({ x }) => x),
[4],
'one pick for the burst',
);
assert.equal(h.canvas.style.cursor, 'pointer');
h.scene.under = null;
h.move(6);
h.frame();
assert.equal(h.canvas.style.cursor, '', 'off the line');
h.frame();
assert.equal(h.picks.length, 2, 'a still pointer costs nothing');
} finally {
h.restore();
}
});
test('a drag with any button picks nothing, and a release with a modifier held does not stick', () => {
const h = harness();
try {
for (const held of [1, 2, 4]) {
h.buttons('pointerdown', held);
h.move(held);
h.frame();
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
h.scene.under = 'mly:img:42';
h.click();
assert.deepEqual(h.opened, ['42']);
for (const under of [null, 'cctv:7', 'sl:pos']) {
h.scene.under = under;
h.click();
}
assert.equal(h.picks.length, 0, 'left, right and middle drags');
// Released with Shift held: Cesium's plain LEFT_UP never fires.
h.buttons('pointerup', 0);
h.move(5);
h.frame();
assert.equal(h.picks.length, 1, 'hover picks again');
} finally {
h.restore();
}
});
test('when the camera rests, a still pointer is picked again so the cursor cannot stick', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
h.move(5);
h.frame();
assert.equal(h.canvas.style.cursor, 'pointer');
// The camera moves the line out from under a still pointer.
h.scene.under = null;
h.camera.moveEnd.raiseEvent();
h.frame();
assert.equal(h.picks.length, 2);
assert.equal(h.canvas.style.cursor, '');
} finally {
h.restore();
}
});
test('a hover frame queued before the layer went off does not pick', () => {
const h = harness();
try {
h.move(2);
h.state.enabled = false;
h.frame();
assert.equal(h.picks.length, 0, 'the layer went off');
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
// Mapillary switched off under a layer that is on: lines are not drawn.
h.state.providerOn = false;
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
} finally {
h.restore();
}
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import { imageConeGlyph } from './glyphs.js';
import { passesImageryFilter, resolveFilter } from './filter.js';
import { createHorizonCull, metresBetween } from './view.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
@@ -3,13 +3,6 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
+1 -2
View File
@@ -27,8 +27,7 @@ registerHooks({
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
await import('./providers/mapillary/viewer.js');
const { createMapillaryViewer } = await import('./viewer.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
+1 -1
View File
@@ -17,7 +17,7 @@ import { createWeatherSource } from '../layers/weather/source.js';
import { createCycloneSource } from '../layers/cyclones/source.js';
import { createWindSource } from '../layers/wind/source.js';
import { createFirmsSource } from '../layers/firms/source.js';
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
import { createMapillarySource } from '../layers/streetLevel/source.js';
import { createReferenceSources } from '../sources/reference.js';
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
+82 -1
View File
@@ -1,4 +1,4 @@
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
import * as Cesium from 'cesium';
const RAD = Math.PI / 180;
@@ -48,6 +48,87 @@ export function fakePhotoViewer({ pose = null } = {}) {
};
}
/**
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
* them, lists them and can `land` the current one), primitive lists and a
* credit display. With `view`, the camera looks straight down on Sacramento
* from 900 m, so coverage asks for tiles.
*/
export function fakeCesiumViewer({ view = false } = {}) {
const credits = [];
let flight = null;
const flights = {
started: 0,
cancelled: 0,
/** Every flight asked for, oldest first: `{ sphere, options }`. */
list: [],
land() {
const current = flight;
flight = null;
current?.options.complete?.();
},
};
const stopFlight = () => {
const current = flight;
flight = null;
current?.options.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
},
};
}
/**
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
+12 -25
View File
@@ -5,19 +5,15 @@ import {
mapillaryProxy,
TILE_ROUTE_MAX_PER_MIN,
} from 'gods-eye-view/server/providers/mapillary';
import {
listTileLayers,
stripTileLayers,
} from '../../server/providers/mapillary/trim.js';
import { stripTileLayers } from '../../server/providers/mapillary/trim.js';
import {
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_MEMORY_MAX_TILES,
TILE_UPSTREAM_CONCURRENCY,
TILE_TTL_MS,
fetchTile,
parseTilePath,
_resetTileCacheForTest,
_tileMemoryForTest,
} from '../../server/providers/mapillary/tiles.js';
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
@@ -545,26 +541,18 @@ test('a tile held in memory past its TTL is fetched again', async (t) => {
);
});
test('the memory cache evicts the least recently used tile past its budget', async () => {
const MB = 1024 * 1024;
// Three 30 MB tiles fit in the budget, a fourth does not.
const big = tile([{ name: 'sequence', payload: Buffer.alloc(30 * MB) }]);
assert.ok(3 * big.length < TILE_MEMORY_BUDGET_BYTES);
assert.ok(4 * big.length > TILE_MEMORY_BUDGET_BYTES);
test('the memory cache evicts the least recently used tile past its size', async () => {
await withUpstream(
() => ok(big),
async ({ calls, call }) => {
() => ok(),
async ({ calls }) => {
const get = async (x) =>
(await call('/api/mapillary/tiles', `/coverage/14/${x}/20`)).headers[
'x-gev-cache'
];
for (const x of [20, 21, 22]) assert.equal(await get(x), 'upstream');
assert.equal(await get(20), 'memory', 'A is used again');
assert.equal(await get(23), 'upstream');
assert.equal(_tileMemoryForTest().entries, 3);
for (const x of [20, 22, 23]) assert.equal(await get(x), 'memory');
assert.equal(await get(21), 'upstream', 'B was evicted');
assert.equal(calls.length, 5);
(await fetchTile(parseTilePath(`/coverage/14/${x}/20`))).source;
for (let x = 0; x < TILE_MEMORY_MAX_TILES; x++) await get(x);
assert.equal(await get(0), 'memory', 'the first is used again');
await get(TILE_MEMORY_MAX_TILES);
assert.equal(await get(0), 'memory', 'so it stays');
assert.equal(await get(1), 'upstream', 'the least recently used went');
assert.equal(calls.length, TILE_MEMORY_MAX_TILES + 2);
},
);
});
@@ -578,7 +566,6 @@ test('dropping a layer keeps the others byte-for-byte', () => {
{ name: 'overview' },
]);
const trimmed = stripTileLayers(bytes, ['image']);
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
});
-91
View File
@@ -1,89 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fixtureTile } from '../../scripts/qa-street-level.mjs';
import {
answerMapillaryRequest,
describeCall,
PHOTO_LINE,
PHOTO_SEQUENCE_ID,
photoImages,
THUMB_HOST,
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
import { redact } from '../../scripts/qa-browserEvidence.mjs';
test('fixture tiles give the hermetic gate something real to filter', async () => {
const { decodeCoverageTile } =
await import('../layers/streetLevel/providers/mapillary/decode.js');
const now = Date.UTC(2026, 9, 1);
const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), {
x: 2662,
y: 6286,
z: 14,
});
const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID);
assert.equal(grid.length, 8);
const pano = grid.filter((s) => s.isPano).length;
assert.ok(pano > 0 && pano < 8, '360° and flat both present');
const year = 365 * 86_400_000;
const old = grid.filter((s) => now - s.capturedAt > year).length;
assert.ok(old > 0 && old < 8, 'recent and older both present');
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
});
/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */
test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => {
const { decodeCoverageTile } =
await import('../layers/streetLevel/providers/mapillary/decode.js');
const { lonToTileX, latToTileY } =
await import('../layers/streetLevel/tileMath.js');
const x = lonToTileX(PHOTO_LINE.lon, 14);
const rows = new Set(
[PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)),
);
assert.ok(rows.size > 1, 'the line crosses a tile edge');
let south = Infinity;
let north = -Infinity;
for (const y of rows) {
const line = decodeCoverageTile(fixtureTile(14, x, y), {
x,
y,
z: 14,
}).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID);
assert.ok(line, `tile 14/${x}/${y} carries the photo line`);
for (const [lon, lat] of line.parts[0]) {
assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4);
south = Math.min(south, lat);
north = Math.max(north, lat);
}
}
assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4);
assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4);
// Far away there is no photo line, only the grid.
const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), {
x: 100,
y: 100,
z: 14,
});
assert.equal(
elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID),
false,
);
});
test('the fixture photos: one sequence, 360° and flat, spaced past the cone thinning', () => {
const images = photoImages(Date.UTC(2026, 9, 1));
assert.ok(images.length >= 3);
assert.ok(images.some((image) => image.isPano));
assert.ok(images.some((image) => !image.isPano));
for (let i = 1; i < images.length; i++) {
const gap = (images[i].lat - images[i - 1].lat) * 110_540;
assert.ok(gap > 3, `gap ${gap}`);
}
assert.equal(new Set(images.map((image) => image.id)).size, images.length);
});
const graph = (path, params) =>
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
const answer = (url, method = 'GET') => {
@@ -94,22 +19,6 @@ const answer = (url, method = 'GET') => {
};
};
test('the Graph fixtures answer the provider’s sequence lookups', () => {
const images = photoImages();
const sequence = answer(
graph('images', {
sequence_ids: PHOTO_SEQUENCE_ID,
fields: 'id,geometry,compass_angle,captured_at,is_pano',
limit: '2000',
}),
);
assert.equal(sequence.json.data.length, images.length);
assert.deepEqual(
answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data,
[],
);
});
test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => {
const [first, second] = photoImages();
const spatial = answer(
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/providers/mapillary/policy.js';
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/policy.js';
/** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */
function allows(sources, url) {
+2 -4
View File
@@ -24,8 +24,8 @@ const setAttr = (node, key, value) => {
};
/**
* Fills the Street Level panel body: filters, legend and viewer. The panel
* chrome itself belongs to the application shell.
* Fills the Street Level panel body: filters and viewer. The panel chrome
* itself belongs to the application shell.
*/
export class StreetLevelControls {
constructor({ root, layer, actions }) {
@@ -58,7 +58,6 @@ export class StreetLevelControls {
errorText: byId('sl-error-text'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'),
viewerWrap: byId('sl-viewer-wrap'),
viewerExpand: byId('sl-viewer-expand'),
viewerClose: byId('sl-viewer-close'),
@@ -376,7 +375,6 @@ export class StreetLevelControls {
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
}
}
setProp(el.legend, 'hidden', !view.legend);
}
_renderViewer(view) {
-6
View File
@@ -223,7 +223,6 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' });
add(root, 'ul', { id: 'sl-legend' });
add(root, 'div', { id: 'sl-coverage-meta' });
return { document, root, wrap };
}
@@ -475,11 +474,6 @@ test('a layer that is on with Mapillary switched off reads OFF, and the pill bri
dom.root.querySelector('#sl-coverage-meta').textContent,
/Mapillary is off in this view/,
);
assert.equal(
dom.root.querySelector('#sl-legend').hidden,
true,
'no legend',
);
status.click();
await settle();
assert.deepEqual(calls.setParams, [
-1
View File
@@ -130,7 +130,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now),
legend: state.providerOn !== false,
viewer: presentViewer(state),
meta: presentMeta(state),
};
+1 -37
View File
@@ -118,7 +118,7 @@
color: #ff8a80;
}
/* ── Filters and legend ──────────────────────────────────────────────────── */
/* ── Filters ──────────────────────────────────────────────────── */
.sl-filters {
display: flex;
align-items: center;
@@ -196,42 +196,6 @@
color: var(--text-primary);
}
.sl-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0;
padding: 0;
list-style: none;
}
.sl-legend li {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.sl-legend[hidden] {
display: none;
}
.sl-legend-swatch {
width: 14px;
height: 3px;
border-radius: 2px;
display: inline-block;
background: var(--sl-green);
}
/* COLORS.selected in src/layers/streetLevel/policy.js */
.sl-legend-swatch.is-selected {
background: #00d4ff;
}
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
.sl-viewer-wrap {
position: relative;
-4
View File
@@ -124,10 +124,6 @@
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours">
<li><i class="sl-legend-swatch"></i><span>Mapillary</span></li>
<li><i class="sl-legend-swatch is-selected"></i><span>Selected</span></li>
</ul>
<div id="sl-coverage-meta" class="sl-meta"></div>
</div>
</div>