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 open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link 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 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 - 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 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. **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`. 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 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`) 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 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 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 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 again when the panorama's month, photographer and address arrive (one
successful lookup per panorama per session, seeded by the nearest lookup; 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 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 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 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.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month'); 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.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google'); assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true); 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. */ /** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => { 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 { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.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 { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js'; import { decodeParams, encodeParams } from './params.js';
import { import {
@@ -392,6 +392,7 @@ export function createStreetLevelLayer({
color: entry.def.colors.coverage, color: entry.def.colors.coverage,
/** No coverage to click: it opens where the user points. */ /** No coverage to click: it opens where the user points. */
groundClick: entry.def.groundClick === true, 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(); const stats = layer.getStats();
assert.equal(stats.keyRequired, true); assert.equal(stats.keyRequired, true);
assert.deepEqual(stats.keyAlternatives, [ 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); assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
stack.switchTo('esri-imagery'); stack.switchTo('esri-imagery');
+41 -11
View File
@@ -1,11 +1,28 @@
import * as Cesium from 'cesium'; 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 { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js'; import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js'; import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker'; 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. */ /** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) { export function createMarker({ state, parts }) {
const { render, sprites } = state.services; const { render, sprites } = state.services;
@@ -42,7 +59,8 @@ export function createMarker({ state, parts }) {
const dem = ground.groundAt(lon, lat); const dem = ground.groundAt(lon, lat);
if (dem === null) { if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => { 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; 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. // A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => { 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; const collection = state.marker.collection;
if (!collection) return; if (!collection) return;
last = position ? { position, bearing } : null; last = position ? { position, bearing, style } : null;
if (!position) { if (!position) {
collection.removeAll(); collection.removeAll();
state.marker.billboard = null; state.marker.billboard = null;
@@ -78,14 +101,21 @@ export function createMarker({ state, parts }) {
height === null height === null
? Cesium.HeightReference.CLAMP_TO_GROUND ? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE; : 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) { if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({ state.marker.billboard = collection.add({
id: POSITION_PICK_ID, id: POSITION_PICK_ID,
position: cartesian, position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }), image: look.image,
imageId: 'sl-position', imageId: `sl-position:${look.key}`,
width: 40, width: look.size,
height: 40, height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z, alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference, heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
@@ -117,7 +147,7 @@ export function createMarker({ state, parts }) {
/** Re-place the marker after the surface mode changes. */ /** Re-place the marker after the surface mode changes. */
function setSurface() { function setSurface() {
if (last) set(last.position, last.bearing); if (last) set(last.position, last.bearing, last.style);
} }
return { return {
+26
View File
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
collection: { collection: {
show: true, show: true,
add: (options) => ({ ...options }), add: (options) => ({ ...options }),
remove() {},
removeAll() {}, removeAll() {},
}, },
billboard: null, 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); 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 { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js'; import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js'; import { createGoogleViewer } from './viewer.js';
@@ -9,14 +8,12 @@ import {
GOOGLE_LABEL, GOOGLE_LABEL,
GOOGLE_NAME, GOOGLE_NAME,
GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_ID,
FLAT_FILTER_HINT,
GROUND_CLICK_HINT, GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE, KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M, NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS, NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE, NO_ANSWER_MESSAGE,
PICK_PREFIX, PICK_PREFIX,
imageMonthEndMs,
} from './policy.js'; } from './policy.js';
/** /**
@@ -75,6 +72,11 @@ export function createGoogleProvider({
requiresMapStack: FOLLOW_MAP_STACK_ID, requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key: the user lights the chip. // Every panorama is billed to the Maps key: the user lights the chip.
defaultOn: false, 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) { create(context) {
let keyRequired = !getApiKey(); let keyRequired = !getApiKey();
@@ -122,7 +124,7 @@ export function createGoogleProvider({
refreshCoverage() {}, refreshCoverage() {},
/** Nothing drawn to redraw: lookups read the filter when they run. */ /** No filter applies to Street View (see `filters`). */
setFilter() {}, setFilter() {},
coverageStats() { coverageStats() {
@@ -133,11 +135,7 @@ export function createGoogleProvider({
kind: null, kind: null,
loading: false, loading: false,
hint: hint:
!active || keyRequired || keyRejected !active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
? ''
: context.getFilter().pano === 'flat'
? FLAT_FILTER_HINT
: GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null, error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired, keyRequired,
keyRejected, keyRejected,
@@ -147,14 +145,12 @@ export function createGoogleProvider({
handlePick: () => false, handlePick: () => false,
/** /**
* Nearest Google-collected outdoor panorama that passes the imagery * Nearest Google-collected outdoor panorama, or null. The imagery
* filter, or null. Every Street View panorama is 360°. * filters do not apply (see `filters`).
*/ */
async nearestImage({ lat, lon }, { signal } = {}) { async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE); if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted(); signal?.throwIfAborted();
const filter = context.getFilter();
if (filter.pano === 'flat') return null;
// Set once the caller has its answer (aborted, timed out, refused): // Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing. // a lookup still waiting on Google's script then asks nothing.
let settled = false; let settled = false;
@@ -186,11 +182,6 @@ export function createGoogleProvider({
}); });
const panoId = data?.location?.pano; const panoId = data?.location?.pano;
if (!panoId) return null; if (!panoId) return null;
const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 },
filter,
);
if (!passes) return null;
viewer.remember(panoId, data); viewer.remember(panoId, data);
return panoId; return panoId;
}, },
@@ -5,7 +5,7 @@ export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity of the Google Street View street-level provider. */ /** Identity of the Google Street View street-level provider. */
export const GOOGLE_PROVIDER_ID = 'google'; export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View'; 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. */ /** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps'; export const GOOGLE_KEY_ID = 'google-maps';
@@ -40,9 +40,6 @@ export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE = 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'; '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. */ /** 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'; 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); 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'). */ /** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) { export function creatorFrom(copyright) {
const text = String(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', () => { test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
const { def } = setup(); const { def } = setup();
assert.equal(def.id, 'google'); 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.requiresKeyId, 'google-maps');
assert.equal(def.defaultOn, false, 'every panorama is billed'); assert.equal(def.defaultOn, false, 'every panorama is billed');
assert.equal(def.groundClick, true); 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'); 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 }); const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null); assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, fake, setFilter } = setup({ nearest: () => 'old' }); const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
setFilter({ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) }); for (const filter of [
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null); { pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
// Every Street View panorama is 360°: FLAT asks Google nothing. { 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 }); setFilter({ pano: 'flat', sinceMs: null });
const asked = fake.lookups.length; assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
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');
}); });
test('an aborted lookup gives up before asking Google', async () => { 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 () => { test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup(); const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 }); await instance.nearestImage({ lat: 38.58, lon: -121.49 });
+30
View File
@@ -1,5 +1,13 @@
import { encodeIdSwitches } from '../../data/idSwitches.js'; 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. * 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 {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 {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 {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 * @property {(context: ProviderContext) => ProviderInstance} create
* *
* @typedef {object} ProviderContext Handed to `create()` once by the core. * @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -129,6 +139,26 @@ export function validateProviders(providers) {
throw new TypeError( throw new TypeError(
`Street Level provider ${label} needs a coverage colour`, `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); ids.add(provider.id);
prefixes.push(provider.pickPrefix); prefixes.push(provider.pickPrefix);
} }
+18 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({ const def = (overrides = {}) => ({
id: 'mapillary', id: 'mapillary',
@@ -101,3 +101,20 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
); );
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null); 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'. */ /** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped', surface: 'draped',
marker: { collection: null, billboard: null }, marker: { collection: null, billboard: null, look: null },
clickHandler: null, clickHandler: null,
}; };
} }
+26 -7
View File
@@ -15,6 +15,8 @@ import { COLORS } from './policy.js';
* @property {string} hint * @property {string} hint
* @property {string|null} error * @property {string|null} error
* @property {string} color The source's one colour. * @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 })); .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 * 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 * @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/ */
export function composeUIState({ export function composeUIState({
@@ -72,12 +88,14 @@ export function composeUIState({
const active = providers.filter((p) => p.on); const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } = const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers); summarizeCoverage(providers);
const legend = active.map((provider) => ({ const legend = active
key: provider.id, .filter((provider) => provider.groundClick !== true)
label: provider.name, .map((provider) => ({
color: provider.color, key: provider.id,
})); label: provider.name,
if (active.length) color: provider.color,
}));
if (legend.length)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return { return {
enabled, enabled,
@@ -87,6 +105,7 @@ export function composeUIState({
keyAlternatives: keyAlternatives(providers), keyAlternatives: keyAlternatives(providers),
keyRejected, keyRejected,
filter: { ...filter }, filter: { ...filter },
filterControls: filterControls(providers),
providers: providers.map((p) => ({ ...p })), providers: providers.map((p) => ({ ...p })),
coverage, coverage,
legend, 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', () => { test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({ const ui = composeUIState({
...base, ...base,
+5 -1
View File
@@ -40,7 +40,11 @@ export function createViewerHost({ state, parts }) {
creator: pose.creator || null, creator: pose.creator || null,
externalUrl: pose.externalUrl || 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(); parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup // Select the sequence only once the image is on screen, so its lookup
// never competes with the image download. // never competes with the image download.
+7
View File
@@ -69,6 +69,8 @@ export class StreetLevelControls {
nearestBtn: byId('sl-nearest-btn'), nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'), error: byId('sl-error'),
errorText: byId('sl-error-text'), errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'), sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'), sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'), legend: byId('sl-legend'),
@@ -532,6 +534,11 @@ export class StreetLevelControls {
_renderFilters(view) { _renderFilters(view) {
const el = this._elements; 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( this._renderRadios(
el.panoButtons, el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano, (button) => button.dataset.slPano === view.filter.pano,
+41 -3
View File
@@ -307,10 +307,12 @@ function panelDom() {
add(settings, 'div', { id }); add(settings, 'div', { id });
// A missing key gates the filters only; the chips stay outside the gate. // A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' }); const controls = add(settings, 'fieldset', { id: 'sl-controls' });
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
for (const pano of ['all', 'pano', 'flat']) for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } }); add(panoRow, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' }); const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
add(controls, 'output', { id: 'sl-since-label' }); add(sinceRow, 'input', { id: 'sl-since' });
add(sinceRow, 'output', { id: 'sl-since-label' });
add(settings, 'ul', { id: 'sl-legend' }); add(settings, 'ul', { id: 'sl-legend' });
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' }); 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); 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 ? null
: state.street.error || state.coverage.error || null, : state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, 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), since: presentSince(filter.sinceDays, now),
legend: state.legend || [], legend: state.legend || [],
viewer: presentViewer(state), viewer: presentViewer(state),
+5 -1
View File
@@ -229,7 +229,11 @@ button.sl-status:focus-visible {
border-radius: 999px; border-radius: 999px;
} }
.sl-seg[hidden] { .sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
.sl-since[hidden],
.sl-legend[hidden] {
display: none; display: none;
} }
+2 -2
View File
@@ -118,7 +118,7 @@
<!-- A missing key gates the filters only: the chips above stay <!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. --> focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls"> <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> <span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter"> <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> <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> <button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
</div> </div>
</div> </div>
<div class="sl-since"> <div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label> <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" /> <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> <output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>