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

+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