feat(street-level): Street View polish — name, filters, legend, marker

- The chip reads GOOGLE STREET VIEW (and its caption link likewise).
- Providers declare the imagery filters they honour (`filters`, default
  pano + since). Street View declares none: every panorama is 360° and it
  draws no coverage to thin, so its lookups ignore the filters and the
  panel hides IMAGERY and SINCE while it is the only lit provider.
- The legend lists only providers that draw coverage lines, with
  Selected only beside one: Street View alone shows no legend.
- Providers may pick their on-globe marker (`marker`); Street View's is a
  Google-blue field-of-view cone from a white-ringed dot, in place of
  GEV's amber pin, and switches back for a Mapillary photo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-13 14:05:57 +01:00
1 parent eb07b9d5ad
commit 64d939f3d2
23 files changed
+305 -86

No files matched your search

+3 -1
View File
@@ -102,7 +102,9 @@
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps and FOLLOW. Share links carry it in the provider switches
(`r.google-1`).
(`r.google-1`). Its panoramas are all 360°, so the IMAGERY and SINCE
filters and the legend apply to Mapillary only and hide when GOOGLE STREET
VIEW is the only lit provider.
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
Settings. API-key voice remains the default. Local sign-in can start from
+1 -1
View File
@@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google").
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with only Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google").
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.r.google-1` Street View on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
+9 -2
View File
@@ -3157,12 +3157,19 @@ below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
and leaves the camera where it is (`groundClick.js`). Higher up the hint
says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`)
does the same at the view centre, so Street View needs no pointer; it is
disabled until the camera is at street zoom. The viewer is Google's `StreetViewPanorama` in its own
disabled until the camera is at street zoom (and aimed at the sky, it opens
nothing and says to aim at a street). The chip reads GOOGLE STREET VIEW. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
successful lookup per panorama per session, seeded by the nearest lookup;
failed details lookups wait five seconds before a view event can retry). A key
a details lookup that fails or takes over ten seconds waits five seconds
before a view event can retry, and an unmount drops lookups in flight). A key
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
not allow, billing off) reads KEY REJECTED and fails a pending open at
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
+1 -1
View File
@@ -1960,7 +1960,7 @@ async function main() {
}));
assert.match(shown.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
assert.equal(shown.label, 'STREET VIEW ↗');
assert.equal(shown.label, 'GOOGLE STREET VIEW ↗');
assert.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true);
+12
View File
@@ -65,6 +65,18 @@ export function imageConeGlyph({
});
}
/**
* Street View-style marker: a soft field-of-view cone in `color` from a
* white-ringed dot, as Google Maps shows where its panorama looks.
*/
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
+2 -1
View File
@@ -6,7 +6,7 @@ import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import {
@@ -392,6 +392,7 @@ export function createStreetLevelLayer({
color: entry.def.colors.coverage,
/** No coverage to click: it opens where the user points. */
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
};
});
}
+1 -1
View File
@@ -696,7 +696,7 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.deepEqual(stats.keyAlternatives, [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
]);
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
stack.switchTo('esri-imagery');
+41 -11
View File
@@ -1,11 +1,28 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
/** The billboard look for a marker style: GEV's pin, or a provider's view cone. */
function markerLook(style) {
if (style?.kind === 'view-cone') {
const color = style.color || COLORS.position;
return {
key: `view-cone:${color}`,
image: viewConeMarkerGlyph({ color }),
size: 48,
};
}
return {
key: 'pin',
image: positionMarkerGlyph({ color: COLORS.position }),
size: 40,
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
@@ -42,7 +59,8 @@ export function createMarker({ state, parts }) {
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
@@ -53,14 +71,19 @@ export function createMarker({ state, parts }) {
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
/**
* Move (or create) the marker; a null position removes it. `style` is the
* open image's provider marker ({kind: 'view-cone', color}); GEV's pin
* without one.
*/
function set(position, bearing, style = null) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -78,14 +101,21 @@ export function createMarker({ state, parts }) {
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const look = markerLook(style);
// Another provider's photo: the marker changes its look.
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
state.marker.billboard = null;
}
if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }),
imageId: 'sl-position',
width: 40,
height: 40,
image: look.image,
imageId: `sl-position:${look.key}`,
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
@@ -117,7 +147,7 @@ export function createMarker({ state, parts }) {
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
if (last) set(last.position, last.bearing, last.style);
}
return {
+26
View File
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
@@ -187,3 +188,28 @@ test('draped mode clamps the marker to the globe whatever the ground says', () =
);
assert.equal(ground.prepares.length, 0);
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
const { state, marker } = setup();
const removed = [];
state.marker.collection.remove = (billboard) => removed.push(billboard);
const streetView = { kind: 'view-cone', color: '#4285f4' };
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
const cone = state.marker.billboard;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
assert.equal(
cone.rotation,
-Cesium.Math.toRadians(45),
'it turns with the view',
);
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
assert.deepEqual(removed, [cone]);
});
@@ -1,4 +1,3 @@
import { passesImageryFilter } from '../../filter.js';
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
@@ -9,14 +8,12 @@ import {
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
FLAT_FILTER_HINT,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
imageMonthEndMs,
} from './policy.js';
/**
@@ -75,6 +72,11 @@ export function createGoogleProvider({
requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key: the user lights the chip.
defaultOn: false,
// Every panorama is 360°, and with no coverage drawn there is nothing
// for a date filter to thin: the user picks the place by clicking it.
filters: [],
// Google Maps' own look for where a panorama stands and looks.
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
create(context) {
let keyRequired = !getApiKey();
@@ -122,7 +124,7 @@ export function createGoogleProvider({
refreshCoverage() {},
/** Nothing drawn to redraw: lookups read the filter when they run. */
/** No filter applies to Street View (see `filters`). */
setFilter() {},
coverageStats() {
@@ -133,11 +135,7 @@ export function createGoogleProvider({
kind: null,
loading: false,
hint:
!active || keyRequired || keyRejected
? ''
: context.getFilter().pano === 'flat'
? FLAT_FILTER_HINT
: GROUND_CLICK_HINT,
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
@@ -147,14 +145,12 @@ export function createGoogleProvider({
handlePick: () => false,
/**
* Nearest Google-collected outdoor panorama that passes the imagery
* filter, or null. Every Street View panorama is 360°.
* Nearest Google-collected outdoor panorama, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted();
const filter = context.getFilter();
if (filter.pano === 'flat') return null;
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
let settled = false;
@@ -186,11 +182,6 @@ export function createGoogleProvider({
});
const panoId = data?.location?.pano;
if (!panoId) return null;
const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 },
filter,
);
if (!passes) return null;
viewer.remember(panoId, data);
return panoId;
},
@@ -5,7 +5,7 @@ export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity of the Google Street View street-level provider. */
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View';
export const GOOGLE_LABEL = 'STREET VIEW';
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
@@ -40,9 +40,6 @@ export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
/** Under FLAT, in place of the click hint: every Street View photo is 360°. */
export const FLAT_FILTER_HINT = 'Street View has 360° photos only';
/** Shown while the provider is on, in place of coverage lines it does not have. */
export const GROUND_CLICK_HINT = 'click a street to open Street View';
@@ -67,17 +64,6 @@ export function imageDateMs(imageDate) {
return Date.UTC(Number(match[1]), month - 1, 1);
}
/**
* The last millisecond of an image's month: Google dates by month, so a
* "since" window keeps a panorama taken any time in its cut-off month.
*/
export function imageMonthEndMs(imageDate) {
const start = imageDateMs(imageDate);
if (start === null) return null;
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) - 1;
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
@@ -64,7 +64,9 @@ function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
const { def } = setup();
assert.equal(def.id, 'google');
assert.equal(def.label, 'STREET VIEW');
assert.equal(def.label, 'GOOGLE STREET VIEW');
assert.deepEqual(def.filters, [], 'no imagery filter applies');
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
assert.equal(def.requiresKeyId, 'google-maps');
assert.equal(def.defaultOn, false, 'every panorama is billed');
assert.equal(def.groundClick, true);
@@ -105,20 +107,23 @@ test('the nearest lookup asks Google for its own outdoor imagery within 50 m', a
assert.equal(request.preference, 'nearest');
});
test('no panorama nearby, or one the imagery filter excludes, is null', async () => {
test('no panorama nearby is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, fake, setFilter } = setup({ nearest: () => 'old' });
setFilter({ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
// Every Street View panorama is 360°: FLAT asks Google nothing.
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
for (const filter of [
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
{ pano: 'flat', sinceMs: null },
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
]) {
setFilter(filter);
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
}
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
instance.activate();
setFilter({ pano: 'flat', sinceMs: null });
const asked = fake.lookups.length;
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
assert.equal(fake.lookups.length, asked);
setFilter({ pano: 'pano', sinceMs: null });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
});
test('an aborted lookup gives up before asking Google', async () => {
@@ -271,14 +276,6 @@ test('a refusal while the provider is off reports no error over another photo',
);
});
test('a since window keeps a panorama taken any time in its cut-off month', async () => {
const { instance, setFilter } = setup({ nearest: () => 'fresh' }); // 2025-06
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 5, 20) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'fresh');
setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 6, 1) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
});
test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
+30
View File
@@ -1,5 +1,13 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/** The imagery filters a provider can honour. */
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
/** The filters `provider` honours: both, unless it says otherwise. */
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
}
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -14,6 +22,8 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms).
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); a share link without its switch keeps it.
* @property {{kind: 'view-cone', color: string}} [marker] On-globe marker for its open image (default GEV's pin): Street View's blue field-of-view cone.
* @property {Array<'pano'|'since'>} [filters] The imagery filters it honours (default both); the panel hides a filter no lit provider honours.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -129,6 +139,26 @@ export function validateProviders(providers) {
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
if (
provider.marker !== undefined &&
!(
provider.marker?.kind === 'view-cone' &&
typeof provider.marker.color === 'string'
)
)
throw new TypeError(
`Street Level provider ${label}: marker must be {kind: 'view-cone', color}`,
);
if (
provider.filters !== undefined &&
!(
Array.isArray(provider.filters) &&
provider.filters.every((key) => IMAGERY_FILTERS.includes(key))
)
)
throw new TypeError(
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
+18 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
@@ -101,3 +101,20 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
test('a provider may narrow the filters it honours and pick a view-cone marker', () => {
assert.deepEqual(filtersOf(def()), ['pano', 'since'], 'both by default');
const narrowed = def({
filters: [],
marker: { kind: 'view-cone', color: '#4285f4' },
});
assert.deepEqual(filtersOf(validateProviders([narrowed])[0]), []);
assert.throws(
() => validateProviders([def({ filters: ['colour'] })]),
/filters must list pano or since/,
);
assert.throws(
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
/marker must be/,
);
});
+1 -1
View File
@@ -62,7 +62,7 @@ export function createState({ services }) {
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
marker: { collection: null, billboard: null, look: null },
clickHandler: null,
};
}
+26 -7
View File
@@ -15,6 +15,8 @@ import { COLORS } from './policy.js';
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
* @property {boolean} [groundClick] Draws no coverage: opens where the user clicks.
* @property {Array<'pano'|'since'>} [filters] Imagery filters it honours.
*/
/**
@@ -55,9 +57,23 @@ export function keyAlternatives(providers) {
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
}
/**
* Which filter controls mean anything: those a switched-on provider honours.
* With none switched on, both show (they apply once one is).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
active.some((p) => (p.filters ?? ['pano', 'since']).includes(key));
return { pano: honoured('pano'), since: honoured('since') };
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* one swatch per active source that draws coverage lines, then the shared
* selection colour; a source that only opens where the user clicks has none.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
@@ -72,12 +88,14 @@ export function composeUIState({
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (active.length)
const legend = active
.filter((provider) => provider.groundClick !== true)
.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (legend.length)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return {
enabled,
@@ -87,6 +105,7 @@ export function composeUIState({
keyAlternatives: keyAlternatives(providers),
keyRejected,
filter: { ...filter },
filterControls: filterControls(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
+43
View File
@@ -85,6 +85,49 @@ test('the legend is one swatch per active source, in its colour, then Selected',
);
});
test('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
color: '#4285f4',
count: 0,
groundClick: true,
filters: [],
});
assert.deepEqual(
composeUIState({ ...base, providers: [streetView] }).legend,
[],
);
assert.deepEqual(
composeUIState({
...base,
providers: [provider(), streetView],
}).legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('the filter controls show only for a lit provider that honours them', () => {
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
const controls = (providers) =>
composeUIState({ ...base, providers }).filterControls;
assert.deepEqual(controls([streetView]), { pano: false, since: false });
assert.deepEqual(controls([provider(), streetView]), {
pano: true,
since: true,
});
assert.deepEqual(
controls([provider({ on: false }), streetView]),
{ pano: false, since: false },
'Mapillary switched off',
);
assert.deepEqual(
controls([provider({ on: false }), { ...streetView, on: false }]),
{ pano: true, since: true },
'nothing lit: both, ready for the first chip',
);
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
+5 -1
View File
@@ -40,7 +40,11 @@ export function createViewerHost({ state, parts }) {
creator: pose.creator || null,
externalUrl: pose.externalUrl || null,
});
parts.marker.set(state.street.position, state.street.bearing);
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
+7
View File
@@ -69,6 +69,8 @@ export class StreetLevelControls {
nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'),
@@ -532,6 +534,11 @@ export class StreetLevelControls {
_renderFilters(view) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
setProp(el.panoRow, 'hidden', !shows.pano);
setProp(el.sinceRow, 'hidden', !shows.since);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
setProp(el.legend, 'hidden', view.legend.length === 0);
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
+41 -3
View File
@@ -307,10 +307,12 @@ function panelDom() {
add(settings, 'div', { id });
// A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' });
add(panoRow, 'button', { dataset: { slPano: pano } });
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
add(sinceRow, 'input', { id: 'sl-since' });
add(sinceRow, 'output', { id: 'sl-since-label' });
add(settings, 'ul', { id: 'sl-legend' });
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
@@ -1207,3 +1209,39 @@ test('a refused key on one provider leaves the other chip switchable, and the re
]);
assert.equal(calls.toasts.length, 0);
}));
test('Street View alone hides the imagery filters and the legend; Mapillary beside it brings them back', () =>
withDom(async (dom) => {
const state = uiState();
const { layer } = stubPanel(dom, state);
const shown = () => ({
pano: !dom.root.querySelector('#sl-pano-row').hidden,
since: !dom.root.querySelector('#sl-since-row').hidden,
controls: !dom.root.querySelector('#sl-controls').hidden,
legend: !dom.root.querySelector('#sl-legend').hidden,
});
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
layer.publish({
...state,
filterControls: { pano: false, since: false },
legend: [],
});
assert.deepEqual(shown(), {
pano: false,
since: false,
controls: false,
legend: false,
});
layer.publish(state);
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
}));
+5
View File
@@ -222,6 +222,11 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
// Only the filters a lit provider honours (none for Street View alone).
filterControls: {
pano: state.filterControls?.pano !== false,
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
legend: state.legend || [],
viewer: presentViewer(state),
+5 -1
View File
@@ -229,7 +229,11 @@ button.sl-status:focus-visible {
border-radius: 999px;
}
.sl-seg[hidden] {
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
.sl-since[hidden],
.sl-legend[hidden] {
display: none;
}
+2 -2
View File
@@ -118,7 +118,7 @@
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
@@ -126,7 +126,7 @@
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
</div>
</div>
<div class="sl-since">
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>