mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(street-level): provider contract with Mapillary as the first provider
Street Level is now a provider-neutral layer modelled on the iD editor's photo overlay. The core owns what every imagery provider shares: the enable state and a switch per provider, the imagery filter (360°/flat and captured-since in relative days), one click handler routed by pick prefix, one viewer host that mounts whichever provider's viewer adapter owns the open image, the position marker, camera follow, one on-globe credit per active provider, and share-link parameters (one bit per provider plus the filter). Providers implement the contract in registry.js: coverage, sequences, nearest-image lookup and a viewer adapter emitting a common pose. Mapillary is the only registered provider; Google Street View, KartaView and Panoramax slot in beside it. Panel: a PROVIDERS chip row (one chip per registered provider, keyless chips read as errors), relative-day SINCE options, a provider-neutral caption whose link label follows the provider, and no vendor mark in the header. The panel inner is a rail scroller like CCTV's. Tests cover the registry, params codec, filter, pick router, UI-state composition, the provider definition and coverage-tile decoding; the QA harness switches the provider off and on and checks the filter. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
2dbc684fb1
commit
28e6a1faa7
41 files changed
+2460
-744
No files matched your search
@@ -255,6 +255,7 @@
|
||||
"node": "./server/providers/mapillary.js"
|
||||
},
|
||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js"
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,18 @@
|
||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||
"src/streetLevelMarkup.test.mjs",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
@@ -865,5 +877,12 @@
|
||||
"server/providers/mapillary/trim.js",
|
||||
"src/layers/streetLevel/tileMath.test.mjs",
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/tooling/streetLevelQa.test.mjs"
|
||||
"src/tooling/streetLevelQa.test.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/uiState.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs"
|
||||
]
|
||||
@@ -1117,10 +1117,23 @@
|
||||
"street-level-layer": {
|
||||
"exports": [
|
||||
"./layers/street-level",
|
||||
"./layers/street-level/mapillary",
|
||||
"./layers/street-level/mapillary/source"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/registry.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",
|
||||
@@ -1998,6 +2011,18 @@
|
||||
"src/ui/streetLevelControls.js",
|
||||
"src/ui/streetLevelPresentation.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/registry.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",
|
||||
@@ -2471,6 +2496,18 @@
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/registry.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",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser QA for the Street Level layer against a running dev
|
||||
* server: the panel's place in the right rail, the keyless gate, coverage,
|
||||
* the embedded viewer and its expanded dialog. Run with `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
* server: the panel's place in the right rail, the provider chips, the
|
||||
* keyless gate, coverage and its credit, switching a provider off and on,
|
||||
* the imagery filter, the embedded viewer and its expanded dialog. Run with `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
* Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -14,6 +15,9 @@ export const VIEWPORTS = Object.freeze([
|
||||
{ width: 1280, height: 800 },
|
||||
]);
|
||||
|
||||
/** Provider chips the panel must show, in order (one per registered provider). */
|
||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
|
||||
|
||||
/** Expected right-rail order once the layout controller has run. */
|
||||
export const RAIL_ORDER = Object.freeze([
|
||||
'pp-toggles',
|
||||
@@ -119,11 +123,19 @@ async function main() {
|
||||
);
|
||||
await sleep(500);
|
||||
await step(
|
||||
'expanding the strip shows the body and the four-swatch legend',
|
||||
'expanding the strip shows the body, one chip per provider and the legend',
|
||||
async () => {
|
||||
const info = await panel();
|
||||
assert.ok(!isCollapsed(info.classes));
|
||||
assert.equal(info.bodyDisplay, 'flex');
|
||||
const chips = await page.evaluate(() =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
'#sl-provider-chips .data-toggle-chip',
|
||||
),
|
||||
].map((chip) => chip.dataset.chipId),
|
||||
);
|
||||
assert.deepEqual(chips, EXPECTED_PROVIDERS);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.querySelectorAll('#sl-legend li').length,
|
||||
@@ -145,6 +157,15 @@ async function main() {
|
||||
const info = await panel();
|
||||
assert.equal(info.controlsDisabled, true);
|
||||
assert.equal(info.status, 'KEY REQUIRED');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelector('#sl-provider-chips [data-chip-id="mapillary"]')
|
||||
.classList.contains('chip-error'),
|
||||
),
|
||||
true,
|
||||
'the keyless provider chip reads as an error',
|
||||
);
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
@@ -188,7 +209,7 @@ async function main() {
|
||||
const u = window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState();
|
||||
return u.coverage.sequences > 0 && !u.coverage.loading;
|
||||
return u.coverage.count > 0 && !u.coverage.loading;
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
@@ -201,14 +222,76 @@ async function main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
const ui = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState(),
|
||||
);
|
||||
await step(
|
||||
'switching the provider chip off clears its coverage and credit',
|
||||
async () => {
|
||||
await page.click('#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState().coverage.count === 0,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const state = await ui();
|
||||
assert.equal(state.providers[0].on, false);
|
||||
assert.deepEqual(state.legend, [], 'no active provider, no legend');
|
||||
await page.waitForFunction(
|
||||
() => !document.body.innerHTML.includes('Mapillary</a> contributors'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams(),
|
||||
),
|
||||
{ mapillary: false, pano: 'all', sinceDays: 0 },
|
||||
);
|
||||
},
|
||||
);
|
||||
await step('switching it back on restores coverage', async () => {
|
||||
await page.click('#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const u = window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState();
|
||||
return u.coverage.count > 0 && !u.coverage.loading;
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
assert.equal((await ui()).providers[0].on, true);
|
||||
});
|
||||
await step(
|
||||
'the 360° filter keeps at most the unfiltered sequence count',
|
||||
async () => {
|
||||
const before = (await ui()).coverage.count;
|
||||
await page.click('[data-sl-pano="pano"]');
|
||||
await sleep(600);
|
||||
const after = (await ui()).coverage.count;
|
||||
assert.ok(after <= before, `${after} ≤ ${before}`);
|
||||
assert.equal((await ui()).filter.pano, 'pano');
|
||||
await page.click('[data-sl-pano="all"]');
|
||||
await sleep(600);
|
||||
assert.equal((await ui()).coverage.count, before);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'opening the nearest image shows the viewer with a caption',
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
// Fire and forget: the open can outlive one CDP call, so poll instead.
|
||||
await page.evaluate(() => {
|
||||
void window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.openNearest(),
|
||||
);
|
||||
.module.openNearest();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
@@ -254,6 +337,8 @@ async function main() {
|
||||
const view = await page.evaluate(() => ({
|
||||
hidden: document.getElementById('sl-viewer-wrap').hidden,
|
||||
when: document.getElementById('sl-image-when').textContent.trim(),
|
||||
link: document.getElementById('sl-image-link').textContent.trim(),
|
||||
href: document.getElementById('sl-image-link').href,
|
||||
width: Math.round(
|
||||
document.getElementById('sl-viewer').getBoundingClientRect().width,
|
||||
),
|
||||
@@ -261,6 +346,9 @@ async function main() {
|
||||
assert.equal(view.hidden, false);
|
||||
assert.ok(view.width > 200);
|
||||
assert.ok(view.when.length > 0, 'caption shows the capture date');
|
||||
assert.equal(street.providerId, 'mapillary');
|
||||
assert.equal(view.link, 'MAPILLARY ↗');
|
||||
assert.match(view.href, /mapillary\.com\/app\/\?pKey=/);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
|
||||
@@ -156,7 +156,10 @@ export function createApplicationCatalog({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({ surface, source: sources.mapillary }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
sources: { mapillary: sources.mapillary },
|
||||
}),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** Construct the Street Level layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationStreetLevel({ surface, source }) {
|
||||
/**
|
||||
* Construct the Street Level layer with every imagery provider this build
|
||||
* ships, using the application scene owners and the supplied sources. A new
|
||||
* provider registers here; its chip, credit and share bit follow.
|
||||
*/
|
||||
export function createApplicationStreetLevel({ surface, sources }) {
|
||||
return createStreetLevelLayer({
|
||||
source,
|
||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
|
||||
+13
-1
@@ -398,6 +398,17 @@ const OPTION_GROUPS = Object.freeze({
|
||||
),
|
||||
booleanOption('viirs', 'v', false),
|
||||
]),
|
||||
'street-level': Object.freeze([
|
||||
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
|
||||
booleanOption('mapillary', 'm', true),
|
||||
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||
all: 'a',
|
||||
pano: 'p',
|
||||
flat: 'f',
|
||||
}),
|
||||
// "Captured since" as relative days, so a link means the same next year.
|
||||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||
]),
|
||||
radio: Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'filter',
|
||||
@@ -540,7 +551,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
token: '3',
|
||||
disposition: 'enabled-only',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'street-level',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'telegeography-submarine-cables',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
|
||||
/**
|
||||
* Drive the globe camera from the street-level pose: either continuously
|
||||
* ("camera follows view") or once, framing the image from behind.
|
||||
*/
|
||||
export function createCameraFollow({ state }) {
|
||||
const { render } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('street-level-follow');
|
||||
}
|
||||
|
||||
function groundHeightAt(lon, lat, fallback) {
|
||||
const scene = state.viewer?.scene;
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
let height = null;
|
||||
try {
|
||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||
} catch {
|
||||
/* not sampleable yet */
|
||||
}
|
||||
if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto);
|
||||
if (!Number.isFinite(height)) height = fallback;
|
||||
return Number.isFinite(height) ? height : 0;
|
||||
}
|
||||
|
||||
/** Put the Cesium camera where the street-level camera is. */
|
||||
function followCamera() {
|
||||
const { position, bearing, tilt, follow, altitude } = state.street;
|
||||
if (!follow || !position || !state.viewer) return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
state.viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
ground + FOLLOW_EYE_HEIGHT_M,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(bearing || 0),
|
||||
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Frame the current image from a short distance behind it. */
|
||||
function lookAtPosition() {
|
||||
const { position, bearing, altitude } = state.street;
|
||||
if (!position || !state.viewer) return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setFollow(enabled) {
|
||||
state.street.follow = enabled === true;
|
||||
if (state.street.follow) followCamera();
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
return { followCamera, lookAtPosition, setFollow };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* One on-screen Cesium credit per active provider. Street-level imagery is
|
||||
* typically CC BY-SA, which needs visible attribution that goes away with
|
||||
* the imagery, so these are static credits added and removed per provider.
|
||||
*/
|
||||
export function createCredits() {
|
||||
/** @type {Map<string, object>} provider id → Cesium.Credit */
|
||||
const shown = new Map();
|
||||
|
||||
function show(viewer, def) {
|
||||
if (shown.has(def.id) || !viewer?.creditDisplay) return;
|
||||
try {
|
||||
const credit = new Cesium.Credit(def.credit.html, true);
|
||||
viewer.creditDisplay.addStaticCredit(credit);
|
||||
shown.set(def.id, credit);
|
||||
} catch {
|
||||
/* credit display unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function hide(viewer, def) {
|
||||
const credit = shown.get(def.id);
|
||||
if (!credit) return;
|
||||
try {
|
||||
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
||||
} catch {
|
||||
/* credit display already torn down */
|
||||
}
|
||||
shown.delete(def.id);
|
||||
}
|
||||
|
||||
function hideAll(viewer) {
|
||||
for (const id of [...shown.keys()]) hide(viewer, { id });
|
||||
}
|
||||
|
||||
return { show, hide, hideAll, has: (id) => shown.has(id) };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Merge a partial filter change into the current one. Unknown panorama modes
|
||||
* and negative or non-integer day counts leave the current value alone.
|
||||
* @param {{pano?: string, sinceDays?: number}|null} next
|
||||
* @param {{pano: string, sinceDays: number}} [current]
|
||||
* @returns {{pano: string, sinceDays: number}}
|
||||
*/
|
||||
export function normalizeFilter(next, current = FILTER_DEFAULT) {
|
||||
const pano = PANO_MODES.includes(next?.pano) ? next.pano : current.pano;
|
||||
let sinceDays = current.sinceDays;
|
||||
if (next && 'sinceDays' in next) {
|
||||
const days = Number(next.sinceDays);
|
||||
if (Number.isInteger(days) && days >= 0)
|
||||
sinceDays = Math.min(days, MAX_SINCE_DAYS);
|
||||
}
|
||||
return { pano, sinceDays };
|
||||
}
|
||||
|
||||
/** True when two filters would show the same imagery. */
|
||||
export function sameFilter(a, b) {
|
||||
return a?.pano === b?.pano && a?.sinceDays === b?.sinceDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the stored filter (relative days, stable in share links) into the
|
||||
* absolute form providers compare capture times against.
|
||||
* @returns {{pano: string, sinceMs: number|null}}
|
||||
*/
|
||||
export function resolveFilter(filter, now = Date.now()) {
|
||||
const days = Number(filter?.sinceDays) || 0;
|
||||
return {
|
||||
pano: PANO_MODES.includes(filter?.pano) ? filter.pano : 'all',
|
||||
sinceMs: days > 0 ? now - days * DAY_MS : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one capture passes the resolved imagery filter: panorama mode
|
||||
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
|
||||
* @param {{isPano?: boolean, capturedAt?: number}} record
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter
|
||||
*/
|
||||
export function passesImageryFilter(record, filter) {
|
||||
if (!filter) return true;
|
||||
if (filter.pano === 'pano' && !record.isPano) return false;
|
||||
if (filter.pano === 'flat' && record.isPano) return false;
|
||||
if (
|
||||
Number.isFinite(filter.sinceMs) &&
|
||||
filter.sinceMs > 0 &&
|
||||
(record.capturedAt || 0) < filter.sinceMs
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
normalizeFilter,
|
||||
passesImageryFilter,
|
||||
resolveFilter,
|
||||
sameFilter,
|
||||
} from './filter.js';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
test('normalizeFilter merges a partial change and ignores junk', () => {
|
||||
const current = { pano: 'all', sinceDays: 0 };
|
||||
assert.deepEqual(normalizeFilter({ pano: 'pano' }, current), {
|
||||
pano: 'pano',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.deepEqual(normalizeFilter({ pano: 'sideways' }, current), current);
|
||||
assert.deepEqual(normalizeFilter({ sinceDays: 365 }, current), {
|
||||
pano: 'all',
|
||||
sinceDays: 365,
|
||||
});
|
||||
assert.deepEqual(normalizeFilter({ sinceDays: -3 }, current), current);
|
||||
assert.deepEqual(normalizeFilter({ sinceDays: 1.5 }, current), current);
|
||||
assert.deepEqual(normalizeFilter({ sinceDays: 99_999_999 }, current), {
|
||||
pano: 'all',
|
||||
sinceDays: 36_500,
|
||||
});
|
||||
assert.deepEqual(normalizeFilter(null, current), current);
|
||||
assert.deepEqual(normalizeFilter({ pano: 'flat' }), {
|
||||
pano: 'flat',
|
||||
sinceDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveFilter turns relative days into an absolute cut-off', () => {
|
||||
const now = Date.UTC(2026, 8, 25);
|
||||
assert.deepEqual(resolveFilter({ pano: 'all', sinceDays: 0 }, now), {
|
||||
pano: 'all',
|
||||
sinceMs: null,
|
||||
});
|
||||
assert.deepEqual(resolveFilter({ pano: 'flat', sinceDays: 10 }, now), {
|
||||
pano: 'flat',
|
||||
sinceMs: now - 10 * DAY,
|
||||
});
|
||||
assert.equal(resolveFilter({ pano: 'nope' }, now).pano, 'all');
|
||||
});
|
||||
|
||||
test('passesImageryFilter applies panorama mode and the cut-off', () => {
|
||||
const pano = { isPano: true, capturedAt: 200 };
|
||||
const flat = { isPano: false, capturedAt: 50 };
|
||||
assert.equal(passesImageryFilter(pano, null), true);
|
||||
assert.equal(
|
||||
passesImageryFilter(pano, { pano: 'pano', sinceMs: null }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
passesImageryFilter(flat, { pano: 'pano', sinceMs: null }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesImageryFilter(flat, { pano: 'flat', sinceMs: null }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
passesImageryFilter(pano, { pano: 'flat', sinceMs: null }),
|
||||
false,
|
||||
);
|
||||
assert.equal(passesImageryFilter(flat, { pano: 'all', sinceMs: 100 }), false);
|
||||
assert.equal(passesImageryFilter(pano, { pano: 'all', sinceMs: 100 }), true);
|
||||
assert.equal(passesImageryFilter({}, { pano: 'all', sinceMs: 1 }), false);
|
||||
});
|
||||
|
||||
test('sameFilter compares the stored form', () => {
|
||||
assert.equal(
|
||||
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 1 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+241
-183
@@ -1,58 +1,55 @@
|
||||
import { createState } from './state.js';
|
||||
import { createCoverage, visibleBbox } from './providers/mapillary/coverage.js';
|
||||
import { createSequences } from './providers/mapillary/sequences.js';
|
||||
import { createViewerBridge } from './providers/mapillary/viewer.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
import { createCredits } from './credits.js';
|
||||
import { createPickRouter } from './pickRouter.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState } from './uiState.js';
|
||||
import { viewCentre } from './view.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_RECENT_DAYS,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
} from './providers/mapillary/policy.js';
|
||||
POSITION_PICK_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
} from './policy.js';
|
||||
|
||||
export { STREET_LEVEL_LAYER_ID } from './providers/mapillary/policy.js';
|
||||
|
||||
const SOURCE_METHODS = [
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getImage',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
];
|
||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/**
|
||||
* Construct the Mapillary street-level layer: camera-driven coverage,
|
||||
* per-sequence image cones and the embedded viewer. Application services
|
||||
* are injected; nothing here reads the DOM except the viewer host the UI
|
||||
* hands over.
|
||||
* Construct the Street Level layer from a list of imagery providers (see
|
||||
* registry.js for the contract). The core owns what every provider shares:
|
||||
* the enable state and per-provider switches, the imagery filter, one click
|
||||
* handler, one viewer host, the position marker, camera follow, credits and
|
||||
* share-link parameters. Providers own their coverage, sequences and viewer.
|
||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||
*/
|
||||
export function createStreetLevelLayer({ source, services = {} }) {
|
||||
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
|
||||
throw new TypeError('A Mapillary source is required');
|
||||
export function createStreetLevelLayer({
|
||||
providers: definitions,
|
||||
services = {},
|
||||
}) {
|
||||
const definitionsFrozen = validateProviders(definitions);
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, source, parts };
|
||||
parts.coverage = createCoverage(context);
|
||||
parts.sequences = createSequences(context);
|
||||
parts.viewer = createViewerBridge(context);
|
||||
parts.selection = createSelection(context);
|
||||
|
||||
// Street-level actions shared by clicks and the UI.
|
||||
parts.street = {
|
||||
async openImage(imageId) {
|
||||
const host = state.street.host;
|
||||
if (!host) {
|
||||
state.street.error = 'Open the Street Level panel to view imagery';
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await parts.viewer.open(imageId, host);
|
||||
},
|
||||
const context = { state, parts };
|
||||
parts.credits = createCredits();
|
||||
parts.marker = createMarker(context);
|
||||
parts.follow = createCameraFollow(context);
|
||||
parts.router = createPickRouter(() => state.providers.values(), {
|
||||
positionId: POSITION_PICK_ID,
|
||||
});
|
||||
parts.viewerHost = createViewerHost(context);
|
||||
parts.hasSelectedSequence = () =>
|
||||
[...state.providers.values()].some(
|
||||
(entry) => entry.instance.sequenceStats?.()?.selectedId,
|
||||
);
|
||||
parts.clearSequences = () => {
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.clearSequence?.();
|
||||
};
|
||||
parts.selection = createSelection(context);
|
||||
|
||||
/** Run low-priority work when the browser is idle (or soon, headless). */
|
||||
function scheduleIdle(task) {
|
||||
@@ -79,140 +76,175 @@ export function createStreetLevelLayer({ source, services = {} }) {
|
||||
}
|
||||
state.notify = notify;
|
||||
|
||||
async function refreshStatus() {
|
||||
function providerContext(entry) {
|
||||
return Object.freeze({
|
||||
services: state.services,
|
||||
getViewer: () => state.viewer,
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
isActive: () => state.enabled && entry.on,
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
reportError: (message) => {
|
||||
state.street.error = message || null;
|
||||
notify();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const def of definitionsFrozen) {
|
||||
const entry = { def, instance: null, on: true, status: null };
|
||||
entry.instance = def.create(providerContext(entry));
|
||||
state.providers.set(def.id, entry);
|
||||
}
|
||||
|
||||
const activeEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => entry.on);
|
||||
|
||||
async function refreshStatus(entry) {
|
||||
try {
|
||||
state.status = await source.getStatus();
|
||||
state.statusError = null;
|
||||
state.keyRequired = state.status?.configured !== true;
|
||||
} catch (error) {
|
||||
state.statusError = error?.message || 'status unavailable';
|
||||
state.keyRequired = !source.hasToken();
|
||||
entry.status = await entry.instance.status();
|
||||
} catch {
|
||||
entry.status = null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Colour key for the coverage web, in the order the panel lists it. */
|
||||
function coverageLegend() {
|
||||
const years = Math.round(COVERAGE_RECENT_DAYS / 365);
|
||||
return [
|
||||
{ key: 'recent', label: `Recent (≤${years} yr)`, color: COLORS.coverage },
|
||||
{ key: 'older', label: 'Older', color: COLORS.coverageOld },
|
||||
{ key: 'pano', label: '360°', color: COLORS.pano },
|
||||
{ key: 'selected', label: 'Selected', color: COLORS.selected },
|
||||
];
|
||||
function activate(entry) {
|
||||
if (!state.viewer) return;
|
||||
entry.instance.activate(state.viewer);
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
if (!entry.status) refreshStatus(entry);
|
||||
scheduleIdle(() => parts.viewerHost.prewarm([entry]));
|
||||
}
|
||||
|
||||
/** CC BY-SA attribution shown on the globe while the layer is on. */
|
||||
function presentCredit(viewer) {
|
||||
if (state.credit || !viewer?.creditDisplay) return;
|
||||
try {
|
||||
state.credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
|
||||
viewer.creditDisplay.addStaticCredit(state.credit);
|
||||
} catch {
|
||||
state.credit = null;
|
||||
function deactivate(entry) {
|
||||
if (state.street.providerId === entry.def.id) parts.viewerHost.unmount();
|
||||
entry.instance.deactivate();
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
}
|
||||
|
||||
function openImage(providerId, imageId) {
|
||||
return parts.viewerHost.open(providerId, imageId);
|
||||
}
|
||||
|
||||
function setProviderEnabled(providerId, on) {
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry) return false;
|
||||
const next = on !== false;
|
||||
if (entry.on === next) return true;
|
||||
entry.on = next;
|
||||
if (state.enabled) {
|
||||
if (next) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
function hideCredit(viewer) {
|
||||
if (!state.credit) return;
|
||||
try {
|
||||
viewer?.creditDisplay?.removeStaticCredit?.(state.credit);
|
||||
} catch {
|
||||
/* credit display already torn down */
|
||||
function setCoverageFilter(next) {
|
||||
const filter = normalizeFilter(next, state.filter);
|
||||
if (sameFilter(filter, state.filter)) return;
|
||||
state.filter = filter;
|
||||
const resolved = resolveFilter(filter);
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.setFilter(resolved);
|
||||
notify();
|
||||
}
|
||||
|
||||
function providerSnapshots() {
|
||||
return [...state.providers.values()].map((entry) => {
|
||||
const stats = entry.instance.coverageStats();
|
||||
return {
|
||||
id: entry.def.id,
|
||||
name: entry.def.name,
|
||||
label: entry.def.label,
|
||||
on: entry.on,
|
||||
configured: entry.status ? entry.status.configured === true : null,
|
||||
keyRequired: stats.keyRequired === true,
|
||||
requiresKeyId: entry.def.requiresKeyId || null,
|
||||
loading: stats.loading === true,
|
||||
count: stats.count || 0,
|
||||
hint: stats.hint || '',
|
||||
error: stats.error || null,
|
||||
legend: entry.def.legend,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sequenceSnapshot() {
|
||||
const owner = state.providers.get(state.street.providerId);
|
||||
const candidates = owner
|
||||
? [owner, ...state.providers.values()]
|
||||
: [...state.providers.values()];
|
||||
for (const entry of candidates) {
|
||||
const stats = entry.instance.sequenceStats?.();
|
||||
if (stats?.selectedId || stats?.loading)
|
||||
return {
|
||||
providerId: entry.def.id,
|
||||
selectedId: stats.selectedId || null,
|
||||
images: stats.images || 0,
|
||||
loading: stats.loading === true,
|
||||
};
|
||||
}
|
||||
state.credit = null;
|
||||
}
|
||||
|
||||
/** Centre of the visible ground, or the camera's own footprint. */
|
||||
function viewCentre() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox)
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 };
|
||||
const carto = viewer.camera.positionCartographic;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
return { providerId: null, selectedId: null, images: 0, loading: false };
|
||||
}
|
||||
|
||||
function getUIState() {
|
||||
return {
|
||||
const { host, ...street } = state.street;
|
||||
return composeUIState({
|
||||
enabled: state.enabled,
|
||||
keyRequired: state.keyRequired,
|
||||
coverage: {
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
filter: { ...state.coverage.filter },
|
||||
legend: coverageLegend(),
|
||||
loading: state.coverage.loading > 0,
|
||||
sequences: parts.coverage.sequenceCount(),
|
||||
hint: state.coverage.hint,
|
||||
error: state.coverage.lastError,
|
||||
},
|
||||
sequence: {
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
},
|
||||
street: {
|
||||
open: state.street.open,
|
||||
follow: state.street.follow,
|
||||
loading: state.street.loading,
|
||||
error: state.street.error,
|
||||
imageId: state.street.imageId,
|
||||
position: state.street.position ? { ...state.street.position } : null,
|
||||
bearing: state.street.bearing,
|
||||
isPano: state.street.isPano,
|
||||
capturedAt: state.street.capturedAt,
|
||||
sequenceId: state.street.sequenceId,
|
||||
creator: state.street.creator || null,
|
||||
renderMode: state.street.renderMode,
|
||||
},
|
||||
};
|
||||
filter: state.filter,
|
||||
providers: providerSnapshots(),
|
||||
street,
|
||||
sequence: sequenceSnapshot(),
|
||||
});
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: STREET_LEVEL_LAYER_ID,
|
||||
name: 'Street Level',
|
||||
icon: '📷',
|
||||
source: 'Mapillary',
|
||||
source: definitionsFrozen.map((def) => def.name).join(' · '),
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||
/** Registered providers, in chip order. */
|
||||
providerIds: definitionsFrozen.map((def) => def.id),
|
||||
|
||||
init(viewer) {
|
||||
if (state.initialized)
|
||||
throw new Error('Street Level layer is already initialized');
|
||||
state.viewer = viewer;
|
||||
state.initialized = true;
|
||||
parts.sequences.ensureCollections(viewer);
|
||||
parts.sequences.setVisible(false);
|
||||
refreshStatus();
|
||||
parts.marker.ensure(viewer);
|
||||
parts.marker.setVisible(false);
|
||||
for (const entry of state.providers.values()) {
|
||||
entry.instance.init(viewer);
|
||||
refreshStatus(entry);
|
||||
}
|
||||
console.log('[Data:StreetLevel] Initialized');
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
state.enabled = true;
|
||||
presentCredit(viewer);
|
||||
parts.sequences.setVisible(true);
|
||||
state.viewer = viewer;
|
||||
parts.marker.setVisible(true);
|
||||
parts.selection.install(viewer);
|
||||
parts.coverage.attach(viewer);
|
||||
if (!state.status) refreshStatus();
|
||||
scheduleIdle(() => parts.viewer.prewarm());
|
||||
for (const entry of activeEntries()) activate(entry);
|
||||
notify();
|
||||
},
|
||||
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
hideCredit(state.viewer);
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
parts.sequences.clearSelection();
|
||||
parts.viewerHost.unmount();
|
||||
for (const entry of state.providers.values()) {
|
||||
entry.instance.deactivate();
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
}
|
||||
parts.selection.uninstall();
|
||||
parts.viewer.destroy();
|
||||
parts.sequences.setVisible(false);
|
||||
parts.marker.setVisible(false);
|
||||
notify();
|
||||
},
|
||||
|
||||
@@ -222,7 +254,9 @@ export function createStreetLevelLayer({ source, services = {} }) {
|
||||
|
||||
destroy(viewer = state.viewer) {
|
||||
layer.disable();
|
||||
parts.sequences.destroy(viewer);
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.destroy(viewer);
|
||||
parts.marker.destroy(viewer);
|
||||
state.listeners.clear();
|
||||
state.viewer = null;
|
||||
state.initialized = false;
|
||||
@@ -230,21 +264,40 @@ export function createStreetLevelLayer({ source, services = {} }) {
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const ui = getUIState();
|
||||
let loadingLabel = '';
|
||||
if (state.keyRequired) loadingLabel = 'KEY REQUIRED';
|
||||
else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...';
|
||||
else if (state.coverage.hint && state.enabled)
|
||||
loadingLabel = state.coverage.hint;
|
||||
if (ui.keyRequired) loadingLabel = 'KEY REQUIRED';
|
||||
else if (ui.coverage.loading) loadingLabel = 'loading coverage...';
|
||||
else if (ui.coverage.hint && state.enabled)
|
||||
loadingLabel = ui.coverage.hint;
|
||||
return {
|
||||
count: parts.coverage.sequenceCount(),
|
||||
sequences: parts.coverage.sequenceCount(),
|
||||
loading: state.coverage.loading > 0,
|
||||
keyRequired: state.keyRequired,
|
||||
error: state.keyRequired ? 'KEY REQUIRED' : state.coverage.lastError,
|
||||
count: ui.coverage.count,
|
||||
sequences: ui.coverage.count,
|
||||
loading: ui.coverage.loading,
|
||||
keyRequired: ui.keyRequired,
|
||||
error: ui.keyRequired ? 'KEY REQUIRED' : ui.coverage.error,
|
||||
loadingLabel,
|
||||
};
|
||||
},
|
||||
|
||||
/** Share-link and stored state: provider switches plus the filter. */
|
||||
getParams() {
|
||||
return encodeParams({
|
||||
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
|
||||
filter: state.filter,
|
||||
});
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
const decoded = decodeParams(params, {
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
||||
setCoverageFilter(decoded.filter);
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── Public surface used by the panel ────────────────────────────────
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
@@ -252,40 +305,39 @@ export function createStreetLevelLayer({ source, services = {} }) {
|
||||
return () => state.listeners.delete(listener);
|
||||
},
|
||||
getUIState,
|
||||
/** The DOM element the MapillaryJS viewer renders into. */
|
||||
/** The DOM element provider viewers render into. */
|
||||
attachViewerHost(element) {
|
||||
state.street.host = element || null;
|
||||
if (element && state.street.open) parts.viewer.resize();
|
||||
parts.viewerHost.attach(element);
|
||||
if (element && state.enabled)
|
||||
scheduleIdle(() => parts.viewer.prewarm(element));
|
||||
scheduleIdle(() => parts.viewerHost.prewarm(activeEntries()));
|
||||
},
|
||||
setProviderEnabled,
|
||||
isProviderEnabled: (id) => state.providers.get(id)?.on === true,
|
||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||
setCoverageFilter(next) {
|
||||
parts.coverage.setFilter(next);
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
getCoverageFilter: () => ({ ...state.coverage.filter }),
|
||||
openImage: (imageId) => parts.street.openImage(imageId),
|
||||
setCoverageFilter,
|
||||
getCoverageFilter: () => ({ ...state.filter }),
|
||||
openImage,
|
||||
/** Open the nearest image any active provider has around a point. */
|
||||
async openNearest(point) {
|
||||
const view = point || viewCentre();
|
||||
const view = point || viewCentre(state.viewer);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
state.street.loading = true;
|
||||
state.street.error = null;
|
||||
notify();
|
||||
try {
|
||||
const images = await source.nearestImages({
|
||||
lat: view.lat,
|
||||
lon: view.lon,
|
||||
radius: NEAREST_RADIUS_M,
|
||||
limit: NEAREST_LIMIT,
|
||||
});
|
||||
if (!images.length)
|
||||
throw new Error(
|
||||
`No Mapillary imagery within ${NEAREST_RADIUS_M} m of the view centre`,
|
||||
);
|
||||
await parts.street.openImage(images[0].id);
|
||||
return true;
|
||||
for (const entry of activeEntries()) {
|
||||
const imageId = await entry.instance.nearestImage({
|
||||
lat: view.lat,
|
||||
lon: view.lon,
|
||||
});
|
||||
if (!imageId) continue;
|
||||
await openImage(entry.def.id, imageId);
|
||||
return true;
|
||||
}
|
||||
throw new Error(
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`,
|
||||
);
|
||||
} catch (error) {
|
||||
state.street.error = error?.message || 'Nearest image unavailable';
|
||||
state.street.loading = false;
|
||||
@@ -293,21 +345,27 @@ export function createStreetLevelLayer({ source, services = {} }) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Close the image and deselect it everywhere on the map: position
|
||||
* marker, highlighted sequence and its cones.
|
||||
*/
|
||||
/** Close the image and deselect it everywhere on the map. */
|
||||
closeViewer() {
|
||||
parts.viewer.close();
|
||||
parts.sequences.clearSelection();
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
},
|
||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||
setFollow: (enabled) => parts.follow.setFollow(enabled),
|
||||
lookAtImage: () => parts.follow.lookAtPosition(),
|
||||
resizeViewer: () => parts.viewerHost.resize(),
|
||||
selectSequence(
|
||||
sequenceId,
|
||||
providerId = state.providers.keys().next().value,
|
||||
) {
|
||||
return state.providers
|
||||
.get(providerId)
|
||||
?.instance.selectSequence?.(sequenceId);
|
||||
},
|
||||
clearSequence: () => parts.clearSequences(),
|
||||
refreshCoverage() {
|
||||
for (const entry of activeEntries()) entry.instance.refreshCoverage();
|
||||
},
|
||||
setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode),
|
||||
setFollow: (enabled) => parts.viewer.setFollow(enabled),
|
||||
lookAtImage: () => parts.viewer.lookAtPosition(),
|
||||
resizeViewer: () => parts.viewer.resize(),
|
||||
selectSequence: (id) => parts.sequences.select(id),
|
||||
clearSequence: () => parts.sequences.clearSelection(),
|
||||
refreshCoverage: () => parts.coverage.refresh(),
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { positionMarkerGlyph } from './glyphs.js';
|
||||
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
|
||||
/** The on-globe marker for the image the viewer currently shows. */
|
||||
export function createMarker({ state }) {
|
||||
const { render, sprites } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('street-level-marker');
|
||||
}
|
||||
|
||||
function ensure(viewer) {
|
||||
if (state.marker.collection) return;
|
||||
state.marker.collection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.marker.collection);
|
||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
||||
}
|
||||
|
||||
/** Move (or create) the marker; a null position removes it. */
|
||||
function set(position, bearing) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.marker.billboard = null;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||
if (!state.marker.billboard) {
|
||||
state.marker.billboard = collection.add({
|
||||
id: POSITION_PICK_ID,
|
||||
position: cartesian,
|
||||
image: positionMarkerGlyph({ color: COLORS.position }),
|
||||
imageId: 'sl-position',
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
});
|
||||
} else state.marker.billboard.position = cartesian;
|
||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
if (state.marker.collection) state.marker.collection.show = visible;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function destroy(viewer) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||
viewer?.scene?.primitives?.remove(collection);
|
||||
state.marker.collection = null;
|
||||
state.marker.billboard = null;
|
||||
}
|
||||
|
||||
return { ensure, set, clear: () => set(null), setVisible, destroy };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { normalizeFilter } from './filter.js';
|
||||
|
||||
/**
|
||||
* Share-link and stored parameters: one boolean per registered provider plus
|
||||
* the shared imagery filter. The codec in src/data/layerState.js names the
|
||||
* same keys, so a provider added later also needs an option there.
|
||||
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||
*/
|
||||
export function encodeParams({ providers, filter }) {
|
||||
const params = {};
|
||||
for (const [id, on] of providers) params[id] = on === true;
|
||||
params.pano = filter.pano;
|
||||
params.sinceDays = filter.sinceDays;
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read parameters back. Unknown providers and malformed values are ignored,
|
||||
* so a link written for a build with more providers still applies cleanly.
|
||||
* @param {object} params
|
||||
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
|
||||
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
|
||||
*/
|
||||
export function decodeParams(params, { providerIds, filter }) {
|
||||
const providers = new Map();
|
||||
const source = params && typeof params === 'object' ? params : {};
|
||||
for (const id of providerIds)
|
||||
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
|
||||
const next = {};
|
||||
if ('pano' in source) next.pano = source.pano;
|
||||
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||
return { providers, filter: normalizeFilter(next, filter) };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
|
||||
test('encodeParams writes one boolean per provider plus the filter', () => {
|
||||
assert.deepEqual(
|
||||
encodeParams({
|
||||
providers: [
|
||||
['mapillary', true],
|
||||
['panoramax', false],
|
||||
],
|
||||
filter: { pano: 'flat', sinceDays: 365 },
|
||||
}),
|
||||
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
|
||||
);
|
||||
});
|
||||
|
||||
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
||||
const current = { pano: 'all', sinceDays: 0 };
|
||||
const decoded = decodeParams(
|
||||
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
|
||||
{ providerIds: ['mapillary'], filter: current },
|
||||
);
|
||||
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
|
||||
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
|
||||
|
||||
const partial = decodeParams(
|
||||
{ mapillary: '1', pano: 'weird' },
|
||||
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
|
||||
);
|
||||
assert.equal(partial.providers.size, 0, 'a string is not a switch');
|
||||
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
|
||||
|
||||
const empty = decodeParams(null, {
|
||||
providerIds: ['mapillary'],
|
||||
filter: current,
|
||||
});
|
||||
assert.equal(empty.providers.size, 0);
|
||||
assert.deepEqual(empty.filter, current);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Route picked primitive ids to the provider whose prefix they carry. The
|
||||
* registry guarantees prefixes never overlap, so the first match is the only
|
||||
* match.
|
||||
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
|
||||
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
|
||||
*/
|
||||
export function createPickRouter(getProviders, { positionId = null } = {}) {
|
||||
function resolve(id) {
|
||||
if (typeof id !== 'string' || !id) return null;
|
||||
if (positionId && id === positionId)
|
||||
return { providerId: null, instance: null, id };
|
||||
for (const entry of getProviders())
|
||||
if (id.startsWith(entry.def.pickPrefix))
|
||||
return { providerId: entry.def.id, instance: entry.instance, id };
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ownsPick: (id) => resolve(id) !== null,
|
||||
resolve,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPickRouter } from './pickRouter.js';
|
||||
|
||||
const providers = [
|
||||
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
|
||||
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
|
||||
];
|
||||
|
||||
test('ids are routed by prefix to their provider instance', () => {
|
||||
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||
assert.deepEqual(router.resolve('mly:seq:1'), {
|
||||
providerId: 'mapillary',
|
||||
instance: providers[0].instance,
|
||||
id: 'mly:seq:1',
|
||||
});
|
||||
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
|
||||
assert.equal(router.ownsPick('mly:img:2'), true);
|
||||
});
|
||||
|
||||
test('the core-owned marker is owned but routes to no provider', () => {
|
||||
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||
assert.deepEqual(router.resolve('sl:pos'), {
|
||||
providerId: null,
|
||||
instance: null,
|
||||
id: 'sl:pos',
|
||||
});
|
||||
assert.equal(router.ownsPick('sl:pos'), true);
|
||||
});
|
||||
|
||||
test('foreign and malformed ids are not ours', () => {
|
||||
const router = createPickRouter(() => providers);
|
||||
for (const id of ['cctv:1', '', null, undefined, 42, {}])
|
||||
assert.equal(router.ownsPick(id), false, String(id));
|
||||
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
|
||||
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||
export const POSITION_PICK_ID = 'sl:pos';
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
export const COLORS = Object.freeze({
|
||||
selected: '#00d4ff',
|
||||
position: '#ffb300',
|
||||
});
|
||||
|
||||
/** Panorama modes the imagery filter understands. */
|
||||
export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']);
|
||||
|
||||
/** Longest "captured since" window the filter accepts, in days (~100 years). */
|
||||
export const MAX_SINCE_DAYS = 36_500;
|
||||
|
||||
/** Filter every provider starts with: all imagery, any date. */
|
||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||
|
||||
/** Nearest-image search when the user asks to look at a place. */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
|
||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
||||
export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { cameraHeightAboveGround, visibleBbox } from '../../view.js';
|
||||
import {
|
||||
coverageZoomForHeight,
|
||||
overviewZoomForHeight,
|
||||
@@ -41,92 +43,6 @@ function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one capture passes the imagery filter: panorama mode
|
||||
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
|
||||
*/
|
||||
export function passesImageryFilter(record, filter) {
|
||||
if (!filter) return true;
|
||||
if (filter.pano === 'pano' && !record.isPano) return false;
|
||||
if (filter.pano === 'flat' && record.isPano) return false;
|
||||
if (
|
||||
Number.isFinite(filter.sinceMs) &&
|
||||
filter.sinceMs > 0 &&
|
||||
(record.capturedAt || 0) < filter.sinceMs
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera height above the surface under the camera, in metres. Falls back to
|
||||
* the ellipsoidal height when the globe has no height sample yet.
|
||||
*/
|
||||
function cameraHeightAboveGround(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const ground = viewer.scene?.globe?.getHeight?.(carto);
|
||||
return carto.height - (Number.isFinite(ground) ? ground : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible bbox as [west, south, east, north] degrees, or null when the camera
|
||||
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
|
||||
* and only the rays that hit count, so a view that includes the horizon (or a
|
||||
* canvas whose frustum is stale) cannot inflate the box to the whole world;
|
||||
* `computeViewRectangle` is the fallback when too few rays land.
|
||||
*/
|
||||
export function visibleBbox(viewer, { grid = 5 } = {}) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!scene || !camera) return null;
|
||||
const width = scene.canvas?.clientWidth || scene.canvas?.width || 0;
|
||||
const height = scene.canvas?.clientHeight || scene.canvas?.height || 0;
|
||||
const hits = [];
|
||||
if (width > 0 && height > 0) {
|
||||
const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84;
|
||||
const point = new Cesium.Cartesian2();
|
||||
for (let i = 0; i <= grid; i++) {
|
||||
for (let j = 0; j <= grid; j++) {
|
||||
point.x = (width * i) / grid;
|
||||
point.y = (height * j) / grid;
|
||||
let cartesian = null;
|
||||
try {
|
||||
cartesian = camera.pickEllipsoid(point, ellipsoid);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
if (!cartesian) continue;
|
||||
const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid);
|
||||
if (carto) hits.push(carto);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hits.length >= 4) {
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
for (const carto of hits) {
|
||||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||||
west = Math.min(west, lon);
|
||||
east = Math.max(east, lon);
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
}
|
||||
if (east - west > 0 && north - south > 0) return [west, south, east, north];
|
||||
}
|
||||
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
||||
if (!rectangle) return null;
|
||||
return [
|
||||
Cesium.Math.toDegrees(rectangle.west),
|
||||
Cesium.Math.toDegrees(rectangle.south),
|
||||
Cesium.Math.toDegrees(rectangle.east),
|
||||
Cesium.Math.toDegrees(rectangle.north),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
|
||||
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
|
||||
@@ -149,7 +65,7 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
function filter() {
|
||||
return state.coverage.filter;
|
||||
return state.filter;
|
||||
}
|
||||
|
||||
/** One or more ground primitives for a tile's sequences, in draw batches. */
|
||||
@@ -264,7 +180,7 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.tiles.delete(key);
|
||||
}
|
||||
|
||||
async function loadTile(tile, generation, kind) {
|
||||
async function loadTile(tile, kind) {
|
||||
const key = tileKey(tile);
|
||||
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
||||
return;
|
||||
@@ -348,13 +264,13 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.notify?.();
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || !state.enabled || state.keyRequired) return;
|
||||
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
||||
const height = cameraHeightAboveGround(viewer);
|
||||
const sequenceZoom = coverageZoomForHeight(height);
|
||||
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
|
||||
@@ -365,7 +281,7 @@ export function createCoverage({ state, source }) {
|
||||
bbox = [-180, -85, 180, 85];
|
||||
if (!zoom || !bbox) {
|
||||
state.coverage.hint =
|
||||
'Point the camera at the globe for Mapillary coverage';
|
||||
'Point the camera at the globe for street-level coverage';
|
||||
clear();
|
||||
notify();
|
||||
return;
|
||||
@@ -376,8 +292,6 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.zoom = zoom;
|
||||
state.coverage.kind = kind;
|
||||
}
|
||||
state.coverage.generation++;
|
||||
const generation = state.coverage.generation;
|
||||
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||
limit:
|
||||
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
|
||||
@@ -390,7 +304,7 @@ export function createCoverage({ state, source }) {
|
||||
controller.abort();
|
||||
state.coverage.pending.delete(key);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile, generation, kind);
|
||||
for (const tile of tiles) loadTile(tile, kind);
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -421,7 +335,6 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
function clear() {
|
||||
state.coverage.generation++;
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
state.coverage.pending.clear();
|
||||
@@ -433,20 +346,8 @@ export function createCoverage({ state, source }) {
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Apply a new imagery filter and rebuild every loaded tile from its cache. */
|
||||
function setFilter(next) {
|
||||
const current = state.coverage.filter;
|
||||
const pano = ['all', 'pano', 'flat'].includes(next?.pano)
|
||||
? next.pano
|
||||
: current.pano;
|
||||
const sinceMs =
|
||||
next && 'sinceMs' in next
|
||||
? Number.isFinite(next.sinceMs) && next.sinceMs > 0
|
||||
? next.sinceMs
|
||||
: null
|
||||
: current.sinceMs;
|
||||
if (pano === current.pano && sinceMs === current.sinceMs) return;
|
||||
state.coverage.filter = { pano, sinceMs };
|
||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
detachPrimitive(entry);
|
||||
@@ -500,9 +401,9 @@ export function createCoverage({ state, source }) {
|
||||
detach,
|
||||
refresh,
|
||||
clear,
|
||||
rebuild,
|
||||
findSequence,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
setFilter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
|
||||
function sampleTile() {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'sequence');
|
||||
layer.writeVarintField(5, 4096);
|
||||
for (const key of ['id', 'captured_at', 'is_pano'])
|
||||
layer.writeStringField(3, key);
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(__, value) => value.writeStringField(1, 'seq-42'),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(__, value) => value.writeVarintField(5, 1_700_000_000),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(__, value) => value.writeBooleanField(7, true),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(__, feature) => {
|
||||
feature.writeVarintField(1, 7);
|
||||
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||
feature.writeVarintField(3, 2); // LineString
|
||||
feature.writePackedVarint(4, [
|
||||
9,
|
||||
zigzag(100),
|
||||
zigzag(100), // MoveTo
|
||||
10,
|
||||
zigzag(200),
|
||||
zigzag(200), // LineTo ×1
|
||||
]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
},
|
||||
null,
|
||||
);
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'overview');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, 'is_pano');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(__, value) => value.writeBooleanField(7, false),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(__, feature) => {
|
||||
feature.writeVarintField(1, 3);
|
||||
feature.writePackedVarint(2, [0, 0]);
|
||||
feature.writeVarintField(3, 1); // Point
|
||||
feature.writePackedVarint(4, [9, zigzag(2048), zigzag(2048)]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
},
|
||||
null,
|
||||
);
|
||||
return new Uint8Array(writer.finish());
|
||||
}
|
||||
|
||||
test('sequences and overview points decode into lon/lat records', () => {
|
||||
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
|
||||
assert.equal(decoded.sequences.length, 1);
|
||||
const [sequence] = decoded.sequences;
|
||||
assert.equal(sequence.id, 'seq-42');
|
||||
assert.equal(sequence.capturedAt, 1_700_000_000);
|
||||
assert.equal(sequence.isPano, true);
|
||||
assert.equal(sequence.coordinates.length, 2);
|
||||
const [[lon0, lat0], [lon1, lat1]] = sequence.coordinates;
|
||||
assert.ok(
|
||||
lon0 > -180 && lon0 < 0 && lon1 > lon0,
|
||||
'west half of tile 0/0 at z1',
|
||||
);
|
||||
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
|
||||
assert.equal(decoded.overview.length, 1);
|
||||
assert.equal(decoded.overview[0].id, '3');
|
||||
assert.equal(decoded.overview[0].isPano, false);
|
||||
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
|
||||
assert.equal(decoded.images.length, 0, 'image layer skipped by default');
|
||||
});
|
||||
|
||||
test('an empty tile decodes to empty lists', () => {
|
||||
assert.deepEqual(
|
||||
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
||||
{
|
||||
sequences: [],
|
||||
images: [],
|
||||
overview: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { createCoverage } from './coverage.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
COLORS,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
MAPILLARY_LABEL,
|
||||
MAPILLARY_LEGEND,
|
||||
MAPILLARY_NAME,
|
||||
MAPILLARY_PROVIDER_ID,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PICK_PREFIX,
|
||||
mapillaryImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
|
||||
const SOURCE_METHODS = [
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getImage',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
];
|
||||
|
||||
/** Per-provider mutable state, created once per `create()`. */
|
||||
function createProviderState(context) {
|
||||
return {
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
status: null,
|
||||
filter: context.getFilter(),
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
|
||||
tiles: new Map(),
|
||||
/** Tiles from the previous zoom, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
loading: 0,
|
||||
lastError: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary as a Street Level provider: coverage from cached vector tiles,
|
||||
* per-sequence image cones from the Graph API and the MapillaryJS viewer.
|
||||
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createMapillaryProvider({ source }) {
|
||||
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
|
||||
throw new TypeError('A Mapillary source is required');
|
||||
|
||||
return Object.freeze({
|
||||
id: MAPILLARY_PROVIDER_ID,
|
||||
name: MAPILLARY_NAME,
|
||||
label: MAPILLARY_LABEL,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({
|
||||
key: MAPILLARY_PROVIDER_ID,
|
||||
html: MAPILLARY_CREDIT_HTML,
|
||||
}),
|
||||
capabilities: Object.freeze({
|
||||
coverage: 'tiles',
|
||||
sequences: true,
|
||||
pano: true,
|
||||
capturedAt: true,
|
||||
creator: true,
|
||||
follow: true,
|
||||
}),
|
||||
legend: MAPILLARY_LEGEND,
|
||||
externalUrl: mapillaryImageUrl,
|
||||
|
||||
create(context) {
|
||||
const state = createProviderState(context);
|
||||
const parts = {};
|
||||
parts.coverage = createCoverage({ state, source, parts });
|
||||
parts.sequences = createSequences({ state, source, parts });
|
||||
const viewer = createMapillaryViewer({
|
||||
source,
|
||||
render: context.services?.render,
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
try {
|
||||
state.status = await source.getStatus();
|
||||
state.keyRequired = state.status?.configured !== true;
|
||||
} catch {
|
||||
state.status = null;
|
||||
state.keyRequired = !source.hasToken();
|
||||
}
|
||||
context.notify();
|
||||
return { configured: !state.keyRequired };
|
||||
},
|
||||
|
||||
init(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
parts.sequences.ensureCollections(cesiumViewer);
|
||||
parts.sequences.setVisible(false);
|
||||
},
|
||||
|
||||
activate(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
parts.sequences.setVisible(true);
|
||||
parts.coverage.attach(cesiumViewer);
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
parts.sequences.clearSelection();
|
||||
parts.sequences.setVisible(false);
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
parts.sequences.destroy(cesiumViewer);
|
||||
},
|
||||
|
||||
refreshCoverage: () => parts.coverage.refresh(),
|
||||
|
||||
setFilter(filter) {
|
||||
state.filter = filter;
|
||||
parts.coverage.rebuild();
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
return {
|
||||
count: parts.coverage.sequenceCount(),
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading: state.coverage.loading > 0,
|
||||
hint: state.coverage.hint,
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: state.keyRequired,
|
||||
};
|
||||
},
|
||||
|
||||
handlePick(pickId) {
|
||||
if (pickId.startsWith(PICK_PREFIX.sequence)) {
|
||||
parts.sequences.select(pickId.slice(PICK_PREFIX.sequence.length));
|
||||
return true;
|
||||
}
|
||||
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
|
||||
clearSequence: () => parts.sequences.clearSelection(),
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/** Nearest image that passes the imagery filter, or null. */
|
||||
async nearestImage({ lat, lon }) {
|
||||
const images = await source.nearestImages({
|
||||
lat,
|
||||
lon,
|
||||
radius: NEAREST_RADIUS_M,
|
||||
limit: NEAREST_LIMIT,
|
||||
});
|
||||
const hit = images.find((record) =>
|
||||
passesImageryFilter(
|
||||
{
|
||||
isPano: record.is_pano === true,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
},
|
||||
state.filter,
|
||||
),
|
||||
);
|
||||
return hit ? String(hit.id) : null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
/** Identity and tuning for the Mapillary street-level layer. */
|
||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
import { COLORS as SHARED_COLORS } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Mapillary street-level provider. */
|
||||
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||
export const MAPILLARY_NAME = 'Mapillary';
|
||||
export const MAPILLARY_LABEL = 'MAPILLARY';
|
||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||
|
||||
/** Stable id prefixes for picked primitives. */
|
||||
/** 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:',
|
||||
position: 'mly:pos',
|
||||
});
|
||||
|
||||
/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */
|
||||
@@ -15,9 +19,8 @@ export const COLORS = Object.freeze({
|
||||
coverage: '#05cb63',
|
||||
coverageOld: '#2e7d5b',
|
||||
pano: '#ff4fd8',
|
||||
selected: '#00d4ff',
|
||||
selected: SHARED_COLORS.selected,
|
||||
image: '#e8eaed',
|
||||
position: '#ffb300',
|
||||
});
|
||||
|
||||
/** Camera-driven coverage refresh. */
|
||||
@@ -41,12 +44,30 @@ export const NEAREST_RADIUS_M = 50;
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* On-globe credit shown while the layer is enabled. Mapillary imagery and
|
||||
* On-globe credit shown while the provider is active. Mapillary imagery and
|
||||
* derived data are CC BY-SA 4.0 and require visible 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';
|
||||
|
||||
/** Coverage colour key, in the order the panel lists it. */
|
||||
export const MAPILLARY_LEGEND = Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'recent',
|
||||
label: `Recent (≤${Math.round(COVERAGE_RECENT_DAYS / 365)} yr)`,
|
||||
color: COLORS.coverage,
|
||||
}),
|
||||
Object.freeze({ key: 'older', label: 'Older', color: COLORS.coverageOld }),
|
||||
Object.freeze({ key: 'pano', label: '360°', color: COLORS.pano }),
|
||||
]);
|
||||
|
||||
/** 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 from the graph API. */
|
||||
export const IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
|
||||
function fakeSource({ nearest = [] } = {}) {
|
||||
return {
|
||||
hasToken: () => true,
|
||||
getStatus: async () => ({ configured: true }),
|
||||
getTile: async () => new Uint8Array(0),
|
||||
getImage: async () => ({}),
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => nearest,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
|
||||
const opened = [];
|
||||
return {
|
||||
opened,
|
||||
services: {},
|
||||
getViewer: () => null,
|
||||
getFilter: () => filter,
|
||||
isActive: () => true,
|
||||
notify: () => {},
|
||||
actions: {
|
||||
openImage: (id) => {
|
||||
opened.push(id);
|
||||
},
|
||||
reportError: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the definition satisfies the Street Level provider contract', () => {
|
||||
const def = createMapillaryProvider({ source: fakeSource() });
|
||||
assert.doesNotThrow(() => validateProviders([def]));
|
||||
assert.equal(def.id, 'mapillary');
|
||||
assert.equal(def.label, 'MAPILLARY');
|
||||
assert.equal(def.requiresKeyId, 'mapillary');
|
||||
assert.equal(def.pickPrefix, 'mly:');
|
||||
assert.equal(def.capabilities.coverage, 'tiles');
|
||||
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||
assert.deepEqual(
|
||||
def.legend.map((entry) => entry.key),
|
||||
['recent', 'older', 'pano'],
|
||||
);
|
||||
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
|
||||
});
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
const def = createMapillaryProvider({ source: fakeSource() });
|
||||
assert.equal(
|
||||
def.externalUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
});
|
||||
|
||||
test('picks route sequences to selection and images to the core opener', () => {
|
||||
const context = fakeContext();
|
||||
const instance = createMapillaryProvider({ source: fakeSource() }).create(
|
||||
context,
|
||||
);
|
||||
assert.equal(instance.handlePick('mly:img:77'), true);
|
||||
assert.deepEqual(context.opened, ['77']);
|
||||
assert.equal(instance.handlePick('cctv:1'), false);
|
||||
assert.deepEqual(instance.sequenceStats(), {
|
||||
selectedId: null,
|
||||
images: 0,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('nearestImage honours the imagery filter and reports key status', async () => {
|
||||
const nearest = [
|
||||
{ id: 1, is_pano: false, captured_at: 10 },
|
||||
{ id: 2, is_pano: true, captured_at: 20 },
|
||||
];
|
||||
const source = fakeSource({ nearest });
|
||||
const flat = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
|
||||
const pano = createMapillaryProvider({ source }).create(
|
||||
fakeContext({ pano: 'pano', sinceMs: null }),
|
||||
);
|
||||
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
|
||||
const none = createMapillaryProvider({ source }).create(
|
||||
fakeContext({ pano: 'all', sinceMs: 100 }),
|
||||
);
|
||||
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
|
||||
assert.deepEqual(await flat.status(), { configured: true });
|
||||
assert.equal(flat.coverageStats().keyRequired, false);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from '../../glyphs.js';
|
||||
import { passesImageryFilter } from './coverage.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
COLORS,
|
||||
IMAGE_CONE_MIN_SPACING_M,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:mapillary-cones';
|
||||
|
||||
/** How many recently viewed sequences keep their image list in memory. */
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
|
||||
@@ -20,7 +22,7 @@ function metresBetween(a, b) {
|
||||
}
|
||||
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
function normalizeSequenceImage(record) {
|
||||
export function normalizeSequenceImage(record) {
|
||||
const coordinates = record?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
return {
|
||||
@@ -37,7 +39,7 @@ function normalizeSequenceImage(record) {
|
||||
}
|
||||
|
||||
/** Drop images closer than the spacing to the previous kept one. */
|
||||
function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
const kept = [];
|
||||
let last = null;
|
||||
for (const image of images) {
|
||||
@@ -49,7 +51,7 @@ function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Image cones for one selected sequence, plus the viewer position marker. */
|
||||
/** Image cones for one selected sequence. */
|
||||
export function createSequences({ state, source, parts }) {
|
||||
const { render, sprites } = state.services;
|
||||
|
||||
@@ -57,27 +59,17 @@ export function createSequences({ state, source, parts }) {
|
||||
render?.governorRequestRender?.('mapillary-sequence');
|
||||
}
|
||||
|
||||
function notify() {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
function ensureCollections(viewer) {
|
||||
if (!state.sequence.collection) {
|
||||
state.sequence.collection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.sequence.collection);
|
||||
sprites?.registerSpriteCollection?.(
|
||||
'street-level:mapillary-cones',
|
||||
state.sequence.collection,
|
||||
);
|
||||
}
|
||||
if (!state.street.markerCollection) {
|
||||
state.street.markerCollection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.street.markerCollection);
|
||||
sprites?.registerSpriteCollection?.(
|
||||
'street-level:marker',
|
||||
state.street.markerCollection,
|
||||
);
|
||||
}
|
||||
if (state.sequence.collection) return;
|
||||
state.sequence.collection = new Cesium.BillboardCollection({
|
||||
scene: viewer.scene,
|
||||
});
|
||||
viewer.scene.primitives.add(state.sequence.collection);
|
||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
|
||||
}
|
||||
|
||||
function clearCones() {
|
||||
@@ -92,7 +84,7 @@ export function createSequences({ state, source, parts }) {
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.image });
|
||||
const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true });
|
||||
for (const image of images) {
|
||||
if (!passesImageryFilter(image, state.coverage.filter)) continue;
|
||||
if (!passesImageryFilter(image, state.filter)) continue;
|
||||
collection.add({
|
||||
id: `${PICK_PREFIX.image}${image.id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
|
||||
@@ -138,13 +130,13 @@ export function createSequences({ state, source, parts }) {
|
||||
state.sequence.loading = false;
|
||||
state.sequence.images = cached;
|
||||
renderCones(cached);
|
||||
state.notify?.();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
state.sequence.abort = controller;
|
||||
state.sequence.loading = true;
|
||||
state.notify?.();
|
||||
notify();
|
||||
try {
|
||||
const records = await source.getSequenceImages(sequenceId, {
|
||||
signal: controller.signal,
|
||||
@@ -161,13 +153,15 @@ export function createSequences({ state, source, parts }) {
|
||||
renderCones(images);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
state.street.error = error?.message || 'Sequence images unavailable';
|
||||
state.context.actions.reportError(
|
||||
error?.message || 'Sequence images unavailable',
|
||||
);
|
||||
} finally {
|
||||
if (state.sequence.abort === controller) {
|
||||
state.sequence.loading = false;
|
||||
state.sequence.abort = null;
|
||||
}
|
||||
state.notify?.();
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,39 +174,7 @@ export function createSequences({ state, source, parts }) {
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
requestRender();
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
/** Move (or create) the viewer position marker. */
|
||||
function setMarker(position, bearing) {
|
||||
const collection = state.street.markerCollection;
|
||||
if (!collection) return;
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.street.marker = null;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||
if (!state.street.marker) {
|
||||
state.street.marker = collection.add({
|
||||
id: PICK_PREFIX.position,
|
||||
position: cartesian,
|
||||
image: (id) =>
|
||||
import('../../glyphs.js').then((m) =>
|
||||
m.positionMarkerGlyph({ color: COLORS.position }),
|
||||
),
|
||||
imageId: 'mly-position',
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
});
|
||||
} else state.street.marker.position = cartesian;
|
||||
state.street.marker.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Re-draw the current sequence's cones (after an imagery filter change). */
|
||||
@@ -220,34 +182,18 @@ export function createSequences({ state, source, parts }) {
|
||||
if (state.sequence.images.length) renderCones(state.sequence.images);
|
||||
}
|
||||
|
||||
function hide(visible) {
|
||||
function setVisible(visible) {
|
||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||
if (state.street.markerCollection)
|
||||
state.street.markerCollection.show = visible;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function destroy(viewer) {
|
||||
state.sequence.abort?.abort();
|
||||
for (const key of ['collection']) {
|
||||
const collection = state.sequence[key];
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(
|
||||
'street-level:mapillary-cones',
|
||||
collection,
|
||||
);
|
||||
viewer?.scene?.primitives?.remove(collection);
|
||||
state.sequence[key] = null;
|
||||
}
|
||||
}
|
||||
if (state.street.markerCollection) {
|
||||
sprites?.unregisterSpriteCollection?.(
|
||||
'street-level:marker',
|
||||
state.street.markerCollection,
|
||||
);
|
||||
viewer?.scene?.primitives?.remove(state.street.markerCollection);
|
||||
state.street.markerCollection = null;
|
||||
state.street.marker = null;
|
||||
const collection = state.sequence.collection;
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||
viewer?.scene?.primitives?.remove(collection);
|
||||
state.sequence.collection = null;
|
||||
}
|
||||
state.sequence.images = [];
|
||||
state.sequence.selectedId = null;
|
||||
@@ -257,9 +203,8 @@ export function createSequences({ state, source, parts }) {
|
||||
ensureCollections,
|
||||
select,
|
||||
clearSelection,
|
||||
setMarker,
|
||||
rerender,
|
||||
setVisible: hide,
|
||||
setVisible,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
||||
const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
|
||||
/**
|
||||
* Own the embedded MapillaryJS viewer: lazy-load the library, open images,
|
||||
* mirror its pose onto the globe, and optionally drive the Cesium camera
|
||||
* from it ("street cockpit").
|
||||
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host
|
||||
* the core hands over, open images, and emit a provider-neutral pose for
|
||||
* every image, point-of-view or position change.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createViewerBridge({ state, source, parts }) {
|
||||
const { render } = state.services;
|
||||
export function createMapillaryViewer({ source, render } = {}) {
|
||||
let viewer = null;
|
||||
let Library = null;
|
||||
let container = null;
|
||||
let pendingOpen = null;
|
||||
let prewarming = false;
|
||||
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||
let creating = null;
|
||||
let renderMode = 'letterbox';
|
||||
/** Metadata of the image on screen; pov/position events reuse it. */
|
||||
let current = null;
|
||||
const listeners = new Set();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('mapillary-viewer');
|
||||
@@ -34,67 +36,30 @@ export function createViewerBridge({ state, source, parts }) {
|
||||
return Library;
|
||||
}
|
||||
|
||||
function groundHeightAt(lon, lat, fallback) {
|
||||
const scene = state.viewer?.scene;
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
let height = null;
|
||||
try {
|
||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||
} catch {
|
||||
/* not sampleable yet */
|
||||
function emit(pose) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto);
|
||||
if (!Number.isFinite(height)) height = fallback;
|
||||
return Number.isFinite(height) ? height : 0;
|
||||
}
|
||||
|
||||
/** Put the Cesium camera where the street-level camera is. */
|
||||
function followCamera() {
|
||||
const { position, bearing, tilt } = state.street;
|
||||
if (!state.street.follow || !position || !state.viewer) return;
|
||||
const ground = groundHeightAt(
|
||||
position.lon,
|
||||
position.lat,
|
||||
state.street.altitude,
|
||||
);
|
||||
state.viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
ground + FOLLOW_EYE_HEIGHT_M,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(bearing || 0),
|
||||
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Frame the current image from a short distance behind it. */
|
||||
function lookAtPosition() {
|
||||
const { position, bearing } = state.street;
|
||||
if (!position || !state.viewer) return;
|
||||
const ground = groundHeightAt(
|
||||
position.lon,
|
||||
position.lat,
|
||||
state.street.altitude,
|
||||
);
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
},
|
||||
);
|
||||
/** Read MapillaryJS image metadata into the neutral pose shape. */
|
||||
function describe(image) {
|
||||
return {
|
||||
imageId: String(image.id),
|
||||
isPano: image.cameraType === 'spherical',
|
||||
capturedAt: image.capturedAt ?? null,
|
||||
sequenceId: image.sequenceId ?? null,
|
||||
altitude: Number.isFinite(image.computedAltitude)
|
||||
? image.computedAltitude
|
||||
: Number.isFinite(image.originalAltitude)
|
||||
? image.originalAltitude
|
||||
: null,
|
||||
creator: image.creatorUsername || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function publishPose(image) {
|
||||
@@ -104,74 +69,38 @@ export function createViewerBridge({ state, source, parts }) {
|
||||
viewer.getPosition(),
|
||||
viewer.getPointOfView(),
|
||||
]);
|
||||
state.street.position = { lon: lngLat.lng, lat: lngLat.lat };
|
||||
state.street.bearing = pov?.bearing ?? state.street.bearing;
|
||||
state.street.tilt = pov?.tilt ?? 0;
|
||||
if (image) {
|
||||
state.street.imageId = image.id;
|
||||
state.street.isPano = image.merged
|
||||
? image.cameraType === 'spherical'
|
||||
: image.cameraType === 'spherical';
|
||||
state.street.capturedAt = image.capturedAt ?? null;
|
||||
state.street.sequenceId = image.sequenceId ?? null;
|
||||
state.street.altitude = Number.isFinite(image.computedAltitude)
|
||||
? image.computedAltitude
|
||||
: Number.isFinite(image.originalAltitude)
|
||||
? image.originalAltitude
|
||||
: null;
|
||||
state.street.creator = image.creatorUsername || null;
|
||||
// mapillary.com highlights the selected image's sequence on the map;
|
||||
// mirror that once the image itself is on screen, so the sequence
|
||||
// lookup never competes with the image download.
|
||||
if (!state.street.loading) selectCurrentSequence();
|
||||
}
|
||||
parts.sequences.setMarker(state.street.position, state.street.bearing);
|
||||
followCamera();
|
||||
state.notify?.();
|
||||
if (image) current = describe(image);
|
||||
if (!current || !viewer) return;
|
||||
emit({
|
||||
providerId: MAPILLARY_PROVIDER_ID,
|
||||
...current,
|
||||
position: { lon: lngLat.lng, lat: lngLat.lat },
|
||||
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
|
||||
tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0,
|
||||
externalUrl: mapillaryImageUrl(current.imageId),
|
||||
});
|
||||
requestRender();
|
||||
} catch {
|
||||
/* viewer torn down mid-flight */
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrentSequence() {
|
||||
const { sequenceId } = state.street;
|
||||
if (sequenceId && state.enabled && sequenceId !== state.sequence.selectedId)
|
||||
parts.sequences.select(sequenceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the library and stand the viewer up ahead of the first image, so
|
||||
* opening one only costs the image download. Safe to call repeatedly.
|
||||
*/
|
||||
async function prewarm(container = state.street.host) {
|
||||
if (prewarming || !state.enabled) return;
|
||||
prewarming = true;
|
||||
try {
|
||||
await ensureLibrary();
|
||||
if (container && state.enabled && !viewer) await ensureViewer(container);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
} finally {
|
||||
prewarming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureViewer(container) {
|
||||
if (viewer && state.street.container === container) return viewer;
|
||||
if (creating?.container === container) return creating.promise;
|
||||
const promise = createViewer(container).finally(() => {
|
||||
function ensureViewer(host) {
|
||||
if (viewer && container === host) return viewer;
|
||||
if (creating?.container === host) return creating.promise;
|
||||
const promise = createViewer(host).finally(() => {
|
||||
if (creating?.promise === promise) creating = null;
|
||||
});
|
||||
creating = { container, promise };
|
||||
creating = { container: host, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function createViewer(container) {
|
||||
async function createViewer(host) {
|
||||
destroyViewer();
|
||||
const { Viewer } = await ensureLibrary();
|
||||
viewer = new Viewer({
|
||||
accessToken: source.token,
|
||||
container,
|
||||
container: host,
|
||||
component: {
|
||||
cover: false,
|
||||
bearing: true,
|
||||
@@ -179,70 +108,15 @@ export function createViewerBridge({ state, source, parts }) {
|
||||
attribution: true,
|
||||
},
|
||||
trackResize: true,
|
||||
renderMode: libraryRenderMode(state.street.renderMode),
|
||||
renderMode: libraryRenderMode(renderMode),
|
||||
});
|
||||
state.street.container = container;
|
||||
container = host;
|
||||
viewer.on('image', (event) => publishPose(event.image));
|
||||
viewer.on('pov', () => publishPose(null));
|
||||
viewer.on('position', () => publishPose(null));
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an image in the viewer inside `container`. Returns once the first
|
||||
* image has loaded; the pose then streams through `state.street`.
|
||||
*/
|
||||
async function open(imageId, container, { frame = true } = {}) {
|
||||
if (!imageId || !container) return;
|
||||
const id = String(imageId);
|
||||
state.street.loading = true;
|
||||
state.street.error = null;
|
||||
state.street.open = true;
|
||||
state.notify?.();
|
||||
pendingOpen = id;
|
||||
try {
|
||||
const instance = await ensureViewer(container);
|
||||
if (pendingOpen !== id) return;
|
||||
const image = await instance.moveTo(id);
|
||||
if (pendingOpen !== id) return;
|
||||
await publishPose(image);
|
||||
if (frame && !state.street.follow) lookAtPosition();
|
||||
} catch (error) {
|
||||
if (pendingOpen === id)
|
||||
state.street.error = error?.message || 'Image could not be opened';
|
||||
} finally {
|
||||
if (pendingOpen === id) state.street.loading = false;
|
||||
state.notify?.();
|
||||
}
|
||||
if (pendingOpen === id && !state.street.error) selectCurrentSequence();
|
||||
}
|
||||
|
||||
/** Switch between showing the whole image ('letterbox') and cropping ('fill'). */
|
||||
function setRenderMode(mode) {
|
||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
try {
|
||||
const value = libraryRenderMode(state.street.renderMode);
|
||||
if (viewer && value !== undefined) viewer.setRenderMode(value);
|
||||
} catch {
|
||||
/* viewer not ready */
|
||||
}
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function setFollow(enabled) {
|
||||
state.street.follow = enabled === true;
|
||||
if (state.street.follow) followCamera();
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function resize() {
|
||||
try {
|
||||
viewer?.resize();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
function destroyViewer() {
|
||||
if (viewer) {
|
||||
try {
|
||||
@@ -252,38 +126,77 @@ export function createViewerBridge({ state, source, parts }) {
|
||||
}
|
||||
}
|
||||
viewer = null;
|
||||
state.street.container = null;
|
||||
}
|
||||
|
||||
/** Close the image. The viewer instance is kept warm for the next open. */
|
||||
function close() {
|
||||
pendingOpen = null;
|
||||
Object.assign(state.street, {
|
||||
open: false,
|
||||
follow: false,
|
||||
imageId: null,
|
||||
sequenceId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
tilt: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
parts.sequences.setMarker(null);
|
||||
state.notify?.();
|
||||
container = null;
|
||||
current = null;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
setFollow,
|
||||
setRenderMode,
|
||||
resize,
|
||||
prewarm,
|
||||
lookAtPosition,
|
||||
destroy() {
|
||||
close();
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
await ensureViewer(host);
|
||||
},
|
||||
|
||||
/** Resolves once the image is on screen and its first pose was emitted. */
|
||||
async open(imageId) {
|
||||
const id = String(imageId);
|
||||
if (!container) throw new Error('Mapillary viewer is not mounted');
|
||||
pendingOpen = id;
|
||||
const instance = await ensureViewer(container);
|
||||
if (pendingOpen !== id) return;
|
||||
const image = await instance.moveTo(id);
|
||||
if (pendingOpen !== id) return;
|
||||
await publishPose(image);
|
||||
},
|
||||
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
current = null;
|
||||
},
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
destroyViewer();
|
||||
},
|
||||
|
||||
resize() {
|
||||
try {
|
||||
viewer?.resize();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the library and stand the viewer up ahead of the first image, so
|
||||
* opening one only costs the image download. Safe to call repeatedly.
|
||||
*/
|
||||
async prewarm(host) {
|
||||
if (prewarming || !host) return;
|
||||
prewarming = true;
|
||||
try {
|
||||
await ensureLibrary();
|
||||
if (!viewer) await ensureViewer(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
} finally {
|
||||
prewarming = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
try {
|
||||
const value = libraryRenderMode(renderMode);
|
||||
if (viewer && value !== undefined) viewer.setRenderMode(value);
|
||||
} catch {
|
||||
/* viewer not ready */
|
||||
}
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The contract an imagery provider registers with the Street Level layer.
|
||||
*
|
||||
* @typedef {object} StreetLevelProvider
|
||||
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
|
||||
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
|
||||
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string, coverageOld?: string, pano?: string}} colors
|
||||
* @property {{key: string, html: string}} credit On-globe attribution while the provider is active.
|
||||
* @property {{coverage: 'tiles'|'bbox'|'none', sequences: boolean, pano: boolean, capturedAt: boolean, creator: boolean, follow: boolean}} capabilities
|
||||
* @property {Array<{key: string, label: string, color: string}>} legend Coverage colour key.
|
||||
* @property {(imageId: string) => string} externalUrl Deep link to the image on the provider's site.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
* @property {object} services Scene services: picking, input, render, sprites, ground.
|
||||
* @property {() => object|null} getViewer The Cesium viewer once the layer is initialised.
|
||||
* @property {() => {pano: string, sinceMs: number|null}} getFilter The resolved imagery filter.
|
||||
* @property {() => boolean} isActive Layer enabled and this provider switched on.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
* @typedef {object} ProviderInstance
|
||||
* @property {() => Promise<{configured: boolean}>} status
|
||||
* @property {(viewer: object) => void} init Create collections, hidden.
|
||||
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
|
||||
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
|
||||
* @property {(viewer: object) => void} destroy
|
||||
* @property {() => void} refreshCoverage
|
||||
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter
|
||||
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
|
||||
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||
* @property {() => void} [clearSequence]
|
||||
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
|
||||
* @property {(point: {lat: number, lon: number}) => Promise<string|null>} nearestImage
|
||||
* @property {ViewerAdapter} viewer
|
||||
*
|
||||
* @typedef {object} ViewerAdapter
|
||||
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
||||
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
||||
* @property {() => void} close Drop the image, keep the instance warm.
|
||||
* @property {() => void} unmount Destroy the instance (host handed to another provider).
|
||||
* @property {() => void} resize
|
||||
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
|
||||
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
|
||||
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||
*
|
||||
* @typedef {object} StreetPose
|
||||
* @property {string} providerId
|
||||
* @property {string} imageId
|
||||
* @property {{lon: number, lat: number}} position
|
||||
* @property {number|null} bearing
|
||||
* @property {number|null} tilt
|
||||
* @property {number|null} altitude
|
||||
* @property {boolean} isPano
|
||||
* @property {number|null} capturedAt Epoch milliseconds.
|
||||
* @property {string|null} creator
|
||||
* @property {string|null} sequenceId
|
||||
* @property {string} externalUrl
|
||||
*/
|
||||
|
||||
const REQUIRED = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'label',
|
||||
'pickPrefix',
|
||||
'colors',
|
||||
'credit',
|
||||
'capabilities',
|
||||
'legend',
|
||||
'externalUrl',
|
||||
'create',
|
||||
]);
|
||||
|
||||
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* Check a provider list once at construction and freeze its order, which is
|
||||
* also the chip order in the panel.
|
||||
* @param {Array<StreetLevelProvider>} providers
|
||||
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||
*/
|
||||
export function validateProviders(providers) {
|
||||
if (!Array.isArray(providers) || providers.length === 0)
|
||||
throw new TypeError('Street Level needs at least one imagery provider');
|
||||
const ids = new Set();
|
||||
const prefixes = [];
|
||||
for (const provider of providers) {
|
||||
const label = provider?.id ?? '(unnamed)';
|
||||
for (const key of REQUIRED)
|
||||
if (provider?.[key] === undefined || provider?.[key] === null)
|
||||
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
|
||||
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
|
||||
throw new TypeError(`Street Level provider id ${label} is not a slug`);
|
||||
if (ids.has(provider.id))
|
||||
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
|
||||
if (typeof provider.create !== 'function')
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: create is not a function`,
|
||||
);
|
||||
if (typeof provider.externalUrl !== 'function')
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: externalUrl is not a function`,
|
||||
);
|
||||
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
|
||||
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
|
||||
for (const other of prefixes)
|
||||
if (
|
||||
other.startsWith(provider.pickPrefix) ||
|
||||
provider.pickPrefix.startsWith(other)
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
|
||||
);
|
||||
if (!provider.credit?.key || !provider.credit?.html)
|
||||
throw new TypeError(`Street Level provider ${label} needs a credit`);
|
||||
if (!Array.isArray(provider.legend))
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: legend is not a list`,
|
||||
);
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
}
|
||||
return Object.freeze([...providers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer-level key requirement: the one key id every provider shares, or
|
||||
* null as soon as any provider is keyless or they differ. Per-provider needs
|
||||
* are then reported chip by chip instead of gating the whole layer.
|
||||
* @param {ReadonlyArray<StreetLevelProvider>} providers
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function requiresKeyIdFor(providers) {
|
||||
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
|
||||
if (keys.size !== 1) return null;
|
||||
return [...keys][0];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
|
||||
const def = (overrides = {}) => ({
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
requiresKeyId: 'mapillary',
|
||||
pickPrefix: 'mly:',
|
||||
colors: { coverage: '#05cb63' },
|
||||
credit: { key: 'mapillary', html: 'Mapillary' },
|
||||
capabilities: { coverage: 'tiles', sequences: true },
|
||||
legend: [],
|
||||
externalUrl: (id) => `https://example.test/${id}`,
|
||||
create: () => ({}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a valid list is frozen in registration order', () => {
|
||||
const list = validateProviders([
|
||||
def(),
|
||||
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
|
||||
]);
|
||||
assert.ok(Object.isFrozen(list));
|
||||
assert.deepEqual(
|
||||
list.map((p) => p.id),
|
||||
['mapillary', 'panoramax'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty list, duplicate ids and malformed ids are rejected', () => {
|
||||
assert.throws(() => validateProviders([]), /at least one/);
|
||||
assert.throws(() => validateProviders(null), /at least one/);
|
||||
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
||||
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
||||
});
|
||||
|
||||
test('every required field is checked by name', () => {
|
||||
for (const key of [
|
||||
'name',
|
||||
'label',
|
||||
'pickPrefix',
|
||||
'colors',
|
||||
'credit',
|
||||
'capabilities',
|
||||
'legend',
|
||||
'externalUrl',
|
||||
'create',
|
||||
])
|
||||
assert.throws(
|
||||
() => validateProviders([def({ [key]: undefined })]),
|
||||
new RegExp(`lacks ${key}`),
|
||||
key,
|
||||
);
|
||||
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ credit: { key: 'x' } })]),
|
||||
/credit/,
|
||||
);
|
||||
assert.throws(() => validateProviders([def({ legend: 'no' })]), /legend/);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ pickPrefix: '' })]),
|
||||
/pick prefix/,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlapping pick prefixes are rejected either way round', () => {
|
||||
assert.throws(
|
||||
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
|
||||
/overlaps/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
|
||||
/overlaps/,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
|
||||
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
|
||||
assert.equal(
|
||||
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
|
||||
'mapillary',
|
||||
);
|
||||
assert.equal(
|
||||
requiresKeyIdFor([
|
||||
def(),
|
||||
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
requiresKeyIdFor([
|
||||
def(),
|
||||
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
|
||||
});
|
||||
@@ -1,18 +1,12 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
PICK_PREFIX,
|
||||
} from './providers/mapillary/policy.js';
|
||||
import { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/** Click handling for coverage lines and image cones. */
|
||||
/** One click/hover handler for every provider's coverage and image cones. */
|
||||
export function createSelection({ state, parts }) {
|
||||
const { picking, input } = state.services;
|
||||
|
||||
function ownsPick(pickedId) {
|
||||
return (
|
||||
typeof pickedId === 'string' &&
|
||||
Object.values(PICK_PREFIX).some((prefix) => pickedId.startsWith(prefix))
|
||||
);
|
||||
return parts.router.ownsPick(pickedId);
|
||||
}
|
||||
|
||||
function onClick(click) {
|
||||
@@ -23,12 +17,11 @@ export function createSelection({ state, parts }) {
|
||||
const id = picking?.resolvePickId
|
||||
? picking.resolvePickId(picked)
|
||||
: picked?.id;
|
||||
if (!ownsPick(id)) return;
|
||||
if (id.startsWith(PICK_PREFIX.sequence)) {
|
||||
parts.sequences.select(id.slice(PICK_PREFIX.sequence.length));
|
||||
} else if (id.startsWith(PICK_PREFIX.image)) {
|
||||
parts.street.openImage(id.slice(PICK_PREFIX.image.length));
|
||||
}
|
||||
const route = parts.router.resolve(id);
|
||||
if (!route?.instance) return;
|
||||
const entry = state.providers.get(route.providerId);
|
||||
if (!entry?.on) return;
|
||||
route.instance.handlePick(route.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,9 +36,9 @@ export function createSelection({ state, parts }) {
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (!state.sequence.selectedId) return;
|
||||
if (!parts.hasSelectedSequence()) return;
|
||||
event.preventDefault();
|
||||
parts.sequences.clearSelection();
|
||||
parts.clearSequences();
|
||||
}
|
||||
|
||||
let hoverQueued = false;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Mutable per-layer state, created once per layer instance. */
|
||||
import { FILTER_DEFAULT } from './policy.js';
|
||||
|
||||
/** Mutable core state, created once per layer instance. */
|
||||
export function createState({ services }) {
|
||||
return {
|
||||
services,
|
||||
@@ -6,63 +8,37 @@ export function createState({ services }) {
|
||||
enabled: false,
|
||||
initialized: false,
|
||||
destroyed: false,
|
||||
status: null,
|
||||
statusError: null,
|
||||
keyRequired: false,
|
||||
/** Cesium.Credit shown while enabled (CC BY-SA attribution). */
|
||||
credit: null,
|
||||
listeners: new Set(),
|
||||
notifyScheduled: false,
|
||||
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
|
||||
tiles: new Map(),
|
||||
/** Tiles from the previous zoom, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
generation: 0,
|
||||
loading: 0,
|
||||
lastError: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
},
|
||||
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
notify: null,
|
||||
/** Shared imagery filter, in the stored (relative-days) form. */
|
||||
filter: { ...FILTER_DEFAULT },
|
||||
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
|
||||
providers: new Map(),
|
||||
|
||||
street: {
|
||||
bridge: null,
|
||||
container: null,
|
||||
host: null,
|
||||
open: false,
|
||||
follow: false,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerLabel: null,
|
||||
imageId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
tilt: null,
|
||||
altitude: null,
|
||||
isPano: false,
|
||||
capturedAt: null,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
externalUrl: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
marker: null,
|
||||
markerCollection: null,
|
||||
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
|
||||
renderMode: 'letterbox',
|
||||
},
|
||||
|
||||
marker: { collection: null, billboard: null },
|
||||
clickHandler: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { COLORS } from './policy.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} ProviderSnapshot
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {string} label
|
||||
* @property {boolean} on
|
||||
* @property {boolean|null} configured null until the status call answers
|
||||
* @property {boolean} keyRequired
|
||||
* @property {string|null} requiresKeyId
|
||||
* @property {boolean} loading
|
||||
* @property {number} count
|
||||
* @property {string} hint
|
||||
* @property {string|null} error
|
||||
* @property {Array<{key: string, label: string, color: string}>} legend
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compose the snapshot the panel renders from the core state and one
|
||||
* snapshot per registered provider. Pure, so the merge rules are testable:
|
||||
* counts add up across active providers, the layer is key-gated only when
|
||||
* every switched-on provider lacks its key, and the legend lists each active
|
||||
* provider's colours (prefixed by name once more than one is registered)
|
||||
* followed by the shared selection colour.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
enabled,
|
||||
filter,
|
||||
providers,
|
||||
street,
|
||||
sequence,
|
||||
}) {
|
||||
const active = providers.filter((p) => p.on);
|
||||
const keyRequired =
|
||||
active.length > 0 && active.every((p) => p.keyRequired === true);
|
||||
const legend = [];
|
||||
for (const provider of active)
|
||||
for (const entry of provider.legend)
|
||||
legend.push({
|
||||
key: `${provider.id}:${entry.key}`,
|
||||
label:
|
||||
providers.length > 1
|
||||
? `${provider.name} ${entry.label}`
|
||||
: entry.label,
|
||||
color: entry.color,
|
||||
});
|
||||
if (active.length)
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
keyRequired,
|
||||
filter: { ...filter },
|
||||
providers: providers.map((p) => ({ ...p, legend: [...p.legend] })),
|
||||
coverage: {
|
||||
loading: active.some((p) => p.loading),
|
||||
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||
hint: active.find((p) => p.hint)?.hint || '',
|
||||
error: active.find((p) => p.error)?.error || null,
|
||||
},
|
||||
legend,
|
||||
sequence: { ...sequence },
|
||||
street: { ...street },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeUIState } from './uiState.js';
|
||||
|
||||
const provider = (overrides = {}) => ({
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
on: true,
|
||||
configured: true,
|
||||
keyRequired: false,
|
||||
requiresKeyId: 'mapillary',
|
||||
loading: false,
|
||||
count: 10,
|
||||
hint: '',
|
||||
error: null,
|
||||
legend: [{ key: 'recent', label: 'Recent', color: '#0f0' }],
|
||||
...overrides,
|
||||
});
|
||||
const base = {
|
||||
enabled: true,
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
street: { open: false },
|
||||
sequence: { selectedId: null, images: 0, loading: false },
|
||||
};
|
||||
|
||||
test('counts add up across active providers only', () => {
|
||||
const ui = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
|
||||
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
|
||||
],
|
||||
});
|
||||
assert.equal(ui.coverage.count, 17);
|
||||
assert.equal(ui.coverage.loading, true);
|
||||
});
|
||||
|
||||
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
|
||||
const gated = composeUIState({
|
||||
...base,
|
||||
providers: [provider({ keyRequired: true })],
|
||||
});
|
||||
assert.equal(gated.keyRequired, true);
|
||||
const mixed = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider({ keyRequired: true }),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
|
||||
],
|
||||
});
|
||||
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
|
||||
const offOnly = composeUIState({
|
||||
...base,
|
||||
providers: [provider({ keyRequired: true, on: false })],
|
||||
});
|
||||
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
|
||||
});
|
||||
|
||||
test('the legend lists active providers, prefixed once more than one is registered', () => {
|
||||
const single = composeUIState({ ...base, providers: [provider()] });
|
||||
assert.deepEqual(
|
||||
single.legend.map((entry) => [entry.key, entry.label]),
|
||||
[
|
||||
['mapillary:recent', 'Recent'],
|
||||
['selected', 'Selected'],
|
||||
],
|
||||
);
|
||||
const two = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({
|
||||
id: 'panoramax',
|
||||
name: 'Panoramax',
|
||||
on: false,
|
||||
legend: [{ key: 'coverage', label: 'Coverage', color: '#00f' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
two.legend.map((entry) => entry.label),
|
||||
['Mapillary Recent', 'Selected'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('hint and error come from the first active provider that has one', () => {
|
||||
const ui = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider({ hint: '', error: null }),
|
||||
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
|
||||
],
|
||||
});
|
||||
assert.equal(ui.coverage.hint, 'Look down');
|
||||
assert.equal(ui.coverage.error, 'boom');
|
||||
assert.deepEqual(ui.filter, base.filter);
|
||||
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Camera height above the surface under the camera, in metres. Falls back to
|
||||
* the ellipsoidal height when the globe has no height sample yet.
|
||||
*/
|
||||
export function cameraHeightAboveGround(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const ground = viewer.scene?.globe?.getHeight?.(carto);
|
||||
return carto.height - (Number.isFinite(ground) ? ground : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible bbox as [west, south, east, north] degrees, or null when the camera
|
||||
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
|
||||
* and only the rays that hit count, so a view that includes the horizon (or a
|
||||
* canvas whose frustum is stale) cannot inflate the box to the whole world;
|
||||
* `computeViewRectangle` is the fallback when too few rays land.
|
||||
*/
|
||||
export function visibleBbox(viewer, { grid = 5 } = {}) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!scene || !camera) return null;
|
||||
const width = scene.canvas?.clientWidth || scene.canvas?.width || 0;
|
||||
const height = scene.canvas?.clientHeight || scene.canvas?.height || 0;
|
||||
const hits = [];
|
||||
if (width > 0 && height > 0) {
|
||||
const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84;
|
||||
const point = new Cesium.Cartesian2();
|
||||
for (let i = 0; i <= grid; i++) {
|
||||
for (let j = 0; j <= grid; j++) {
|
||||
point.x = (width * i) / grid;
|
||||
point.y = (height * j) / grid;
|
||||
let cartesian = null;
|
||||
try {
|
||||
cartesian = camera.pickEllipsoid(point, ellipsoid);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
if (!cartesian) continue;
|
||||
const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid);
|
||||
if (carto) hits.push(carto);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hits.length >= 4) {
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
for (const carto of hits) {
|
||||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||||
west = Math.min(west, lon);
|
||||
east = Math.max(east, lon);
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
}
|
||||
if (east - west > 0 && north - south > 0) return [west, south, east, north];
|
||||
}
|
||||
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
||||
if (!rectangle) return null;
|
||||
return [
|
||||
Cesium.Math.toDegrees(rectangle.west),
|
||||
Cesium.Math.toDegrees(rectangle.south),
|
||||
Cesium.Math.toDegrees(rectangle.east),
|
||||
Cesium.Math.toDegrees(rectangle.north),
|
||||
];
|
||||
}
|
||||
|
||||
/** Centre of the visible ground, or the camera's own footprint. */
|
||||
export function viewCentre(viewer) {
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox)
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 };
|
||||
const carto = viewer.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Own the one viewer element in the panel and whichever provider's viewer
|
||||
* adapter is mounted in it. Poses the adapter emits become `state.street`,
|
||||
* move the marker and (optionally) the globe camera.
|
||||
*/
|
||||
export function createViewerHost({ state, parts }) {
|
||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||
let active = null;
|
||||
let openSeq = 0;
|
||||
|
||||
function notify() {
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function providerEntry(providerId) {
|
||||
return state.providers.get(providerId) || null;
|
||||
}
|
||||
|
||||
function applyPose(pose) {
|
||||
if (!active || pose.providerId !== active.id) return;
|
||||
const previousSequence = state.street.sequenceId;
|
||||
Object.assign(state.street, {
|
||||
providerId: pose.providerId,
|
||||
imageId: pose.imageId,
|
||||
position: pose.position ? { ...pose.position } : null,
|
||||
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
|
||||
tilt: Number.isFinite(pose.tilt) ? pose.tilt : 0,
|
||||
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
|
||||
isPano: pose.isPano === true,
|
||||
capturedAt: pose.capturedAt ?? null,
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
});
|
||||
parts.marker.set(state.street.position, state.street.bearing);
|
||||
parts.follow.followCamera();
|
||||
// Providers highlight the selected image's sequence on the map; mirror
|
||||
// that once the image itself is on screen, so the sequence lookup never
|
||||
// competes with the image download.
|
||||
if (!state.street.loading && state.street.sequenceId !== previousSequence)
|
||||
selectCurrentSequence();
|
||||
notify();
|
||||
}
|
||||
|
||||
function selectCurrentSequence() {
|
||||
const { sequenceId, providerId } = state.street;
|
||||
const entry = providerEntry(providerId);
|
||||
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
|
||||
return;
|
||||
const current = entry.instance.sequenceStats?.()?.selectedId;
|
||||
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
||||
}
|
||||
|
||||
async function mount(entry) {
|
||||
if (active?.id === entry.def.id) return active.adapter;
|
||||
if (active) {
|
||||
active.adapter.close();
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
const { adapter } = { adapter: entry.instance.viewer };
|
||||
const unsubscribe = adapter.onPose(applyPose);
|
||||
active = { id: entry.def.id, adapter, unsubscribe };
|
||||
await adapter.mount(state.street.host);
|
||||
adapter.setRenderMode?.(state.street.renderMode);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an image from one provider. Returns once the first pose is in;
|
||||
* later poses stream through `state.street` as the user navigates.
|
||||
*/
|
||||
async function open(providerId, imageId, { frame = true } = {}) {
|
||||
const entry = providerEntry(providerId);
|
||||
if (!entry || !imageId) return;
|
||||
if (!state.street.host) {
|
||||
state.street.error = 'Open the Street Level panel to view imagery';
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const seq = ++openSeq;
|
||||
const current = () => seq === openSeq;
|
||||
Object.assign(state.street, {
|
||||
loading: true,
|
||||
error: null,
|
||||
open: true,
|
||||
providerId,
|
||||
providerName: entry.def.name,
|
||||
providerLabel: entry.def.label,
|
||||
});
|
||||
notify();
|
||||
try {
|
||||
const adapter = await mount(entry);
|
||||
if (!current()) return;
|
||||
await adapter.open(String(imageId));
|
||||
if (!current()) return;
|
||||
if (frame && !state.street.follow) parts.follow.lookAtPosition();
|
||||
} catch (error) {
|
||||
if (current())
|
||||
state.street.error = error?.message || 'Image could not be opened';
|
||||
} finally {
|
||||
if (current()) state.street.loading = false;
|
||||
notify();
|
||||
}
|
||||
if (current() && !state.street.error) selectCurrentSequence();
|
||||
}
|
||||
|
||||
/** Close the image; the mounted adapter stays warm for the next open. */
|
||||
function close() {
|
||||
openSeq++;
|
||||
active?.adapter.close();
|
||||
Object.assign(state.street, {
|
||||
open: false,
|
||||
follow: false,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerLabel: null,
|
||||
imageId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
tilt: null,
|
||||
altitude: null,
|
||||
isPano: false,
|
||||
capturedAt: null,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
externalUrl: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
parts.marker.clear();
|
||||
notify();
|
||||
}
|
||||
|
||||
function attach(element) {
|
||||
state.street.host = element || null;
|
||||
if (element && state.street.open) resize();
|
||||
}
|
||||
|
||||
function resize() {
|
||||
try {
|
||||
active?.adapter.resize();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
function setRenderMode(mode) {
|
||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
try {
|
||||
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||
} catch {
|
||||
/* adapter not ready */
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Stand active providers' viewers up ahead of the first image. */
|
||||
async function prewarm(entries) {
|
||||
const host = state.street.host;
|
||||
if (!host || !state.enabled) return;
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await entry.instance.viewer.prewarm?.(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear the mounted adapter down (layer disabled or destroyed). */
|
||||
function unmount() {
|
||||
close();
|
||||
if (!active) return;
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
open,
|
||||
close,
|
||||
resize,
|
||||
setRenderMode,
|
||||
prewarm,
|
||||
unmount,
|
||||
activeProviderId: () => active?.id || null,
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,12 @@ test('Street Level is an ordinary collapsible GEV panel that starts collapsed',
|
||||
/<button class="panel-collapse-btn" data-collapse-target="street-level-panel"/,
|
||||
);
|
||||
assert.match(html, /<span class="panel-title">STREET LEVEL<\/span>/);
|
||||
assert.match(html, /<svg class="sl-mark"/);
|
||||
// Provider-neutral header: no vendor mark; the inner restores its scroll.
|
||||
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="street-level-panel-inner" data-rail-scroller>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/mapillary-dock|sl-minimized|sl-3d-btn|sl-photoreal-btn/,
|
||||
@@ -73,6 +78,9 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
|
||||
/sl-keyless|sl-query|sl-results|data-sl-suggestion/,
|
||||
);
|
||||
assert.match(html, /<fieldset id="sl-controls"/);
|
||||
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||
assert.match(html, /<select id="sl-since"[\s\S]*?value="3652"/);
|
||||
assert.doesNotMatch(html, /value="year:/);
|
||||
assert.match(html, /<ul id="sl-legend"/);
|
||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||
assert.match(html, /id="sl-status"[^>]*role="status"/);
|
||||
@@ -85,7 +93,11 @@ test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
});
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
for (const label of ['OPEN NEAREST PHOTO', 'CAMERA FOLLOWS VIEW'])
|
||||
for (const label of [
|
||||
'OPEN NEAREST PHOTO',
|
||||
'CAMERA FOLLOWS VIEW',
|
||||
'PROVIDERS',
|
||||
])
|
||||
assert.ok(html.includes(label), label);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
EXPECTED_PROVIDERS,
|
||||
isCollapsed,
|
||||
RAIL_ORDER,
|
||||
VIEWPORTS,
|
||||
@@ -14,6 +15,10 @@ test('the QA harness exercises both review viewports', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the harness expects exactly the registered providers', () => {
|
||||
assert.deepEqual(EXPECTED_PROVIDERS, ['mapillary']);
|
||||
});
|
||||
|
||||
test('rail order puts Street Level between CCTV and Context', () => {
|
||||
assert.deepEqual(RAIL_ORDER, [
|
||||
'pp-toggles',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
|
||||
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
@@ -7,8 +8,8 @@ const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Own the Street Level panel: the imagery filters, the legend and the
|
||||
* embedded street-level viewer. The panel itself is
|
||||
* Own the Street Level panel: the provider chips, the imagery filters, the
|
||||
* legend and the embedded street-level viewer. The panel itself is
|
||||
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
|
||||
* the application shell; this class only fills the body and asks the shell
|
||||
* to open the panel when something worth seeing arrives.
|
||||
@@ -38,6 +39,7 @@ export class StreetLevelControls {
|
||||
controls: byId('sl-controls'),
|
||||
enableBtn: byId('sl-enable-btn'),
|
||||
lookBtn: byId('sl-look-btn'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
sinceSelect: byId('sl-since'),
|
||||
@@ -72,19 +74,27 @@ export class StreetLevelControls {
|
||||
if (!(await this._ensureEnabled())) return;
|
||||
this.layer.openNearest?.();
|
||||
});
|
||||
// One delegated listener; chips are re-synced in place on every render.
|
||||
this.listen(el.providerChips, 'click', async (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
const chip = this._view?.providers.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (!chip) return;
|
||||
const next = !chip.active;
|
||||
if (next && !(await this._ensureEnabled())) return;
|
||||
this.layer.setProviderEnabled?.(chip.id, next);
|
||||
});
|
||||
for (const button of this.root.querySelectorAll('[data-sl-pano]')) {
|
||||
this.listen(button, 'click', () =>
|
||||
this.layer.setCoverageFilter?.({ pano: button.dataset.slPano }),
|
||||
);
|
||||
}
|
||||
this.listen(el.sinceSelect, 'change', () => {
|
||||
const value = String(el.sinceSelect.value || '0');
|
||||
let sinceMs = null;
|
||||
if (value.startsWith('year:'))
|
||||
sinceMs = Date.UTC(Number(value.slice(5)), 0, 1);
|
||||
else if (Number(value) > 0)
|
||||
sinceMs = Date.now() - Number(value) * 86_400_000;
|
||||
this.layer.setCoverageFilter?.({ sinceMs });
|
||||
this.layer.setCoverageFilter?.({
|
||||
sinceDays: Number(el.sinceSelect.value) || 0,
|
||||
});
|
||||
});
|
||||
this.listen(el.followBtn, 'click', () => {
|
||||
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
||||
@@ -254,6 +264,7 @@ export class StreetLevelControls {
|
||||
this._view = view;
|
||||
this._renderHeader(view);
|
||||
this._renderGate(view);
|
||||
this._renderProviders(view);
|
||||
this._renderError(view);
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view, state);
|
||||
@@ -282,6 +293,10 @@ export class StreetLevelControls {
|
||||
if (el.controls) el.controls.disabled = view.controlsDisabled;
|
||||
}
|
||||
|
||||
_renderProviders(view) {
|
||||
syncChipGroup(this._elements.providerChips, view.providers);
|
||||
}
|
||||
|
||||
_renderError(view) {
|
||||
const el = this._elements;
|
||||
if (!el.error) return;
|
||||
@@ -296,6 +311,14 @@ export class StreetLevelControls {
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
if (el.sinceSelect) {
|
||||
const value = String(view.filter.sinceDays);
|
||||
if (
|
||||
el.sinceSelect.value !== value &&
|
||||
[...el.sinceSelect.options].some((option) => option.value === value)
|
||||
)
|
||||
el.sinceSelect.value = value;
|
||||
}
|
||||
if (el.legend && el.legend.childElementCount !== view.legend.length) {
|
||||
el.legend.replaceChildren(
|
||||
...view.legend.map((entry) => {
|
||||
@@ -333,6 +356,7 @@ export class StreetLevelControls {
|
||||
if (el.imageLink) {
|
||||
el.imageLink.hidden = !viewer.link;
|
||||
if (viewer.link) el.imageLink.href = viewer.link;
|
||||
if (viewer.linkLabel) el.imageLink.textContent = viewer.linkLabel;
|
||||
}
|
||||
if (state.street.open) this.layer.resizeViewer?.();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
|
||||
/**
|
||||
* Turn the Street Level layer's UI state into the strings and flags the panel
|
||||
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
|
||||
*/
|
||||
|
||||
/** Relative "captured since" windows the panel offers, in days. */
|
||||
export const SINCE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ days: 0, label: 'any date' }),
|
||||
Object.freeze({ days: 365, label: 'last year' }),
|
||||
Object.freeze({ days: 730, label: '2 years' }),
|
||||
Object.freeze({ days: 1826, label: '5 years' }),
|
||||
Object.freeze({ days: 3652, label: '10 years' }),
|
||||
]);
|
||||
|
||||
function formatDate(ms) {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
try {
|
||||
@@ -12,19 +23,38 @@ function formatDate(ms) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`;
|
||||
}
|
||||
|
||||
function presentStatus(state) {
|
||||
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
|
||||
if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' };
|
||||
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
|
||||
}
|
||||
|
||||
/** One chip per registered provider; a keyless provider reads as an error chip. */
|
||||
function presentProviders(state) {
|
||||
return (state.providers || []).map((provider) => {
|
||||
const keyRequired = provider.keyRequired === true;
|
||||
let title = `${provider.name} imagery ${provider.on ? 'on' : 'off'}`;
|
||||
if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
title,
|
||||
active: provider.on === true,
|
||||
disabled: false,
|
||||
state: keyRequired
|
||||
? 'error'
|
||||
: provider.loading
|
||||
? 'loading'
|
||||
: provider.on
|
||||
? 'active'
|
||||
: 'idle',
|
||||
busy: provider.loading === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function presentViewer(state) {
|
||||
const { street } = state;
|
||||
const right = [];
|
||||
@@ -38,7 +68,8 @@ function presentViewer(state) {
|
||||
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
captionRight: right.join(' · '),
|
||||
link: street.imageId ? mapillaryImageUrl(street.imageId) : null,
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
follow: {
|
||||
pressed: street.follow === true,
|
||||
disabled: street.open !== true,
|
||||
@@ -52,8 +83,8 @@ function presentMeta(state) {
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||
if (state.coverage.zoom && state.coverage.sequences > 0)
|
||||
return `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
return state.coverage.hint || '';
|
||||
}
|
||||
|
||||
@@ -64,6 +95,7 @@ function presentMeta(state) {
|
||||
export function presentStreetLevelPanel(state) {
|
||||
const enabled = state.enabled === true;
|
||||
const keyRequired = state.keyRequired === true;
|
||||
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
||||
return {
|
||||
enabled,
|
||||
keyRequired,
|
||||
@@ -73,9 +105,10 @@ export function presentStreetLevelPanel(state) {
|
||||
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
|
||||
pressed: enabled,
|
||||
},
|
||||
providers: presentProviders(state),
|
||||
error: state.street.error || state.coverage.error || null,
|
||||
filter: state.coverage.filter || { pano: 'all', sinceMs: null },
|
||||
legend: state.coverage.legend || [],
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
mapillaryImageUrl,
|
||||
presentStreetLevelPanel,
|
||||
SINCE_OPTIONS,
|
||||
} from './streetLevelPresentation.js';
|
||||
|
||||
const provider = (overrides = {}) => ({
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
on: true,
|
||||
configured: true,
|
||||
keyRequired: false,
|
||||
requiresKeyId: 'mapillary',
|
||||
loading: false,
|
||||
count: 0,
|
||||
hint: '',
|
||||
error: null,
|
||||
legend: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
const base = {
|
||||
enabled: false,
|
||||
keyRequired: false,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
legend: [
|
||||
{ key: 'recent', label: 'Recent (≤2 yr)', color: '#05cb63' },
|
||||
{ key: 'older', label: 'Older', color: '#2e7d5b' },
|
||||
{ key: 'pano', label: '360°', color: '#ff4fd8' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
loading: false,
|
||||
sequences: 0,
|
||||
hint: '',
|
||||
error: null,
|
||||
},
|
||||
sequence: { selectedId: null, images: 0, loading: false },
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
providers: [provider()],
|
||||
coverage: { loading: false, count: 0, hint: '', error: null },
|
||||
legend: [
|
||||
{ key: 'mapillary:recent', label: 'Recent (≤2 yr)', color: '#05cb63' },
|
||||
{ key: 'mapillary:older', label: 'Older', color: '#2e7d5b' },
|
||||
{ key: 'mapillary:pano', label: '360°', color: '#ff4fd8' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
||||
street: {
|
||||
open: false,
|
||||
follow: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerLabel: null,
|
||||
imageId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
@@ -37,6 +50,7 @@ function snapshot(overrides = {}) {
|
||||
capturedAt: null,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
externalUrl: null,
|
||||
renderMode: 'letterbox',
|
||||
},
|
||||
};
|
||||
@@ -58,7 +72,7 @@ function deepMerge(target, source) {
|
||||
return out;
|
||||
}
|
||||
|
||||
test('a missing Mapillary token gates every control and flags KEY REQUIRED', () => {
|
||||
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
|
||||
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' });
|
||||
@@ -77,13 +91,64 @@ test('status reads LOADING while coverage streams, then ON or OFF', () => {
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentStreetLevelPanel(snapshot({ enabled: true })).status,
|
||||
{
|
||||
text: 'ON',
|
||||
tone: 'on',
|
||||
},
|
||||
{ text: 'ON', tone: 'on' },
|
||||
);
|
||||
});
|
||||
|
||||
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
providers: [
|
||||
provider(),
|
||||
provider({
|
||||
id: 'panoramax',
|
||||
name: 'Panoramax',
|
||||
label: 'PANORAMAX',
|
||||
on: false,
|
||||
requiresKeyId: null,
|
||||
}),
|
||||
provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
label: 'KARTAVIEW',
|
||||
loading: true,
|
||||
requiresKeyId: null,
|
||||
}),
|
||||
provider({
|
||||
id: 'google-street-view',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
keyRequired: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]),
|
||||
[
|
||||
['mapillary', true, 'active', false],
|
||||
['panoramax', false, 'idle', false],
|
||||
['kartaview', true, 'loading', true],
|
||||
['google-street-view', true, 'error', false],
|
||||
],
|
||||
);
|
||||
assert.equal(view.providers[0].label, 'MAPILLARY');
|
||||
assert.equal(view.providers[1].title, 'Panoramax imagery off');
|
||||
assert.match(
|
||||
view.providers[3].title,
|
||||
/^Google Street View: Needs GOOGLE_MAPS_API_KEY/,
|
||||
);
|
||||
assert.ok(view.providers.every((chip) => chip.disabled === false));
|
||||
});
|
||||
|
||||
test('a provider error is explained on its chip', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }),
|
||||
);
|
||||
assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502');
|
||||
});
|
||||
|
||||
test('errors from the viewer or the coverage web surface in one alert', () => {
|
||||
assert.equal(presentStreetLevelPanel(snapshot()).error, null);
|
||||
assert.equal(
|
||||
@@ -99,24 +164,35 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the filter passes through as the select and segment values', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({ filter: { pano: 'pano', sinceDays: 730 } }),
|
||||
);
|
||||
assert.deepEqual(view.filter, { pano: 'pano', sinceDays: 730 });
|
||||
assert.deepEqual(
|
||||
SINCE_OPTIONS.map((option) => option.days),
|
||||
[0, 365, 730, 1826, 3652],
|
||||
);
|
||||
});
|
||||
|
||||
test('legend passes through in the layer’s order', () => {
|
||||
const view = presentStreetLevelPanel(snapshot());
|
||||
assert.deepEqual(
|
||||
view.legend.map((entry) => entry.key),
|
||||
['recent', 'older', 'pano', 'selected'],
|
||||
['mapillary:recent', 'mapillary:older', 'mapillary:pano', 'selected'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
|
||||
assert.equal(presentStreetLevelPanel(snapshot()).meta, '');
|
||||
const browsing = presentStreetLevelPanel(
|
||||
snapshot({ enabled: true, coverage: { zoom: 14, sequences: 812 } }),
|
||||
snapshot({ enabled: true, coverage: { count: 812 } }),
|
||||
);
|
||||
assert.match(browsing.meta, /^812 sequences in view · click a line/);
|
||||
const selected = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
coverage: { zoom: 14, sequences: 812 },
|
||||
coverage: { count: 812 },
|
||||
sequence: { selectedId: 'abc', images: 33 },
|
||||
}),
|
||||
);
|
||||
@@ -131,17 +207,22 @@ test('the meta line never mixes the visible-sequence count with the selected seq
|
||||
assert.equal(hinted.meta, 'Point the camera at the globe');
|
||||
});
|
||||
|
||||
test('viewer caption reads "Image by" left and date right, with a deep link', () => {
|
||||
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
providerId: 'mapillary',
|
||||
providerName: 'Mapillary',
|
||||
providerLabel: 'MAPILLARY',
|
||||
imageId: '1814275685699406',
|
||||
creator: 'mapfool',
|
||||
capturedAt: Date.UTC(2023, 9, 8),
|
||||
bearing: 93.4,
|
||||
isPano: true,
|
||||
externalUrl:
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -151,6 +232,7 @@ test('viewer caption reads "Image by" left and date right, with a deep link', ()
|
||||
view.viewer.link,
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗');
|
||||
assert.equal(view.viewer.follow.disabled, false);
|
||||
assert.equal(view.wantsOpen, true);
|
||||
});
|
||||
@@ -163,12 +245,6 @@ test('an image without a creator name leaves the left caption empty', () => {
|
||||
);
|
||||
assert.equal(view.viewer.captionLeft, '');
|
||||
assert.equal(view.viewer.captionRight, '2024-01-02');
|
||||
});
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(
|
||||
mapillaryImageUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
assert.equal(view.viewer.link, null);
|
||||
assert.equal(view.viewer.linkLabel, '');
|
||||
});
|
||||
@@ -339,6 +339,9 @@
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .cctv-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .street-level-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .weather-panel-inner,
|
||||
|
||||
@@ -43,19 +43,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-mark {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-dim);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
#street-level-panel[data-sl-enabled='true'] .sl-mark {
|
||||
color: var(--sl-green);
|
||||
filter: drop-shadow(0 0 6px rgba(5, 203, 99, 0.7));
|
||||
}
|
||||
|
||||
.sl-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
@@ -110,6 +97,25 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Providers ───────────────────────────────────────────────────────────── */
|
||||
.sl-providers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sl-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.chip-error {
|
||||
color: #ffb300;
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -75,12 +75,8 @@
|
||||
runtime next to CCTV (see PanelLayoutController). -->
|
||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="street-level-panel-inner">
|
||||
<div class="street-level-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<!-- Mapillary mark: path from the MapillaryJS attribution component
|
||||
(mapillary-js, MIT, © Mapillary). Mapillary is a trademark of
|
||||
Meta Platforms, Inc.; it is used here to identify the data source. -->
|
||||
<svg class="sl-mark" viewBox="0 0 13.5 13.8" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.38154274 9.6505067C6.14007412 9.52786404 6.08124006 9.31709453 6.21044037 9.08865565 6.27614941 8.97247561 6.41859234 8.74074422 6.53468168 8.5539822 6.69432724 8.29714786 6.92701434 8.29637588 7.05835866 8.37253362 7.18970298 8.44869135 9.04853604 9.42087063 9.16874871 9.48670656 9.4525926 9.64215711 9.87118629 9.28623741 9.68342251 8.97441326 9.59375332 8.82549731 8.8103938 7.4044783 8.5614978 6.93610438 8.44825156 6.72299696 8.47921005 6.53132251 8.731647 6.40377463 8.85656436 6.3406581 9.08004239 6.20890798 9.27394762 6.09190586 9.47189219 5.9724664 9.7353488 6.06223885 9.84245555 6.24022621 9.94956229 6.41821357 13.1209182 12.2294019 13.2221517 12.4098169 13.4231294 12.7679928 13.0322853 13.1511137 12.662949 12.953094 12.4781436 12.8540105 6.62301136 9.77314936 6.38154274 9.6505067zM.200146062 6.40081205C-.111554282 6.23274106-.0545442328 5.79604052.338782648 5.68466036.672030083 5.59029317 3.37820647 4.6882634 4.19715115 4.4149007 4.34479443 4.36561757 4.46005809 4.25228232 4.50965757 4.1075867 4.78858853 3.29386671 5.72024556.575961334 5.82468911.27126979 5.9477048-.087601979 6.39304077-.0889184867 6.57454671.258502251 6.63901566.381902346 9.08940484 4.79813543 9.17465946 4.98547051 9.25991408 5.17280559 9.20498731 5.38031434 9.01176406 5.50000515 8.8185408 5.61969596 8.50343522 5.79872065 8.3936507 5.86654323 8.19489463 5.98933057 8.00439135 5.90384324 7.91365501 5.71364816 7.82291866 5.52345307 7.12387828 4.29626886 6.83882952 3.77527795 6.69634941 3.51486341 6.2671132 3.41492796 6.12283721 3.83582159 6.01848488 4.14024669 5.77526264 4.84979439 5.64864557 5.219172 5.60020375 5.3604904 5.47741944 5.48212554 5.33398124 5.52937001 4.9780163 5.64661478 4.30162944 5.86939744 3.90672375 5.99946818 3.62491469 6.0922881 3.48793523 6.52150257 3.86841776 6.70224674 3.94789525 6.74000167 5.66097153 7.65404391 5.83772054 7.73764558 6.01446954 7.82124724 6.11275281 8.06257945 6.00925316 8.23355004 5.8680804 8.46675268 5.68734796 8.76272052 5.6217862 8.85880815 5.50815838 9.02534163 5.28495077 9.08080286 5.09748996 8.98251076 4.91002915 8.88421866.311522289 6.46086689.200146062 6.40081205z"/></svg>
|
||||
<span class="panel-title">STREET LEVEL</span>
|
||||
<span class="panel-divider"></span>
|
||||
<span id="sl-status" class="sl-status" role="status" aria-live="polite">OFF</span>
|
||||
@@ -90,7 +86,11 @@
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div class="cctv-controls">
|
||||
<button id="sl-enable-btn" class="scene-btn" type="button" aria-pressed="false" title="Turn the Street Level layer on or off">STREET LEVEL OFF</button>
|
||||
<button id="sl-look-btn" class="scene-btn" type="button" title="Open the nearest Mapillary photo to the view centre">OPEN NEAREST PHOTO</button>
|
||||
<button id="sl-look-btn" class="scene-btn" type="button" title="Open the nearest street-level photo to the view centre">OPEN NEAREST PHOTO</button>
|
||||
</div>
|
||||
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
||||
<span class="cctv-summary-label">PROVIDERS</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
@@ -106,9 +106,7 @@
|
||||
<option value="365">last year</option>
|
||||
<option value="730">2 years</option>
|
||||
<option value="1826">5 years</option>
|
||||
<option value="year:2024">2024</option>
|
||||
<option value="year:2022">2022</option>
|
||||
<option value="year:2020">2020</option>
|
||||
<option value="3652">10 years</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
|
||||
@@ -127,12 +125,12 @@
|
||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
|
||||
</div>
|
||||
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
|
||||
<div id="sl-viewer" class="sl-viewer" aria-label="Mapillary street-level viewer"></div>
|
||||
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer"></div>
|
||||
<div id="sl-image-meta" class="sl-image-meta">
|
||||
<span id="sl-image-by" class="sl-image-by"></span>
|
||||
<span class="sl-image-right">
|
||||
<span id="sl-image-when" class="sl-image-when"></span>
|
||||
<a id="sl-image-link" class="sl-image-link" href="https://www.mapillary.com/app/" target="_blank" rel="noopener noreferrer" title="Open this image on mapillary.com">MAPILLARY ↗</a>
|
||||
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" hidden></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user