mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
34d43c7e3f
commit
e214129e45
41 files changed
+335
-909
No files matched your search
@@ -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');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -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,
|
||||
+3
-3
@@ -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
|
||||
File renamed without changes.
@@ -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
-1
@@ -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
|
||||
File renamed without changes.
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
File renamed without changes.
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
-7
@@ -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. */
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
|
||||
Reference in new issue
Block a user