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:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 18:34:09 +01:00
1 parent 2dbc684fb1
commit 28e6a1faa7
41 files changed
+2460 -744

No files matched your search

+110 -34
View File
@@ -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, '');
});