From 64d939f3d2b25ccd3f8fd5c1176b2c3c70d3a89a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 14:05:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(street-level):=20Street=20View=20polish=20?= =?UTF-8?q?=E2=80=94=20name,=20filters,=20legend,=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 4 +- README.md | 2 +- docs/CURRENT-STATE.md | 11 +++- scripts/qa-street-level.mjs | 2 +- src/layers/streetLevel/glyphs.js | 12 +++++ src/layers/streetLevel/index.js | 3 +- src/layers/streetLevel/index.test.mjs | 2 +- src/layers/streetLevel/marker.js | 52 +++++++++++++++---- src/layers/streetLevel/marker.test.mjs | 26 ++++++++++ .../streetLevel/providers/google/index.js | 27 ++++------ .../streetLevel/providers/google/policy.js | 16 +----- .../providers/google/provider.test.mjs | 35 ++++++------- src/layers/streetLevel/registry.js | 30 +++++++++++ src/layers/streetLevel/registry.test.mjs | 19 ++++++- src/layers/streetLevel/state.js | 2 +- src/layers/streetLevel/uiState.js | 33 +++++++++--- src/layers/streetLevel/uiState.test.mjs | 43 +++++++++++++++ src/layers/streetLevel/viewerHost.js | 6 ++- src/ui/streetLevelControls.js | 7 +++ src/ui/streetLevelControls.test.mjs | 44 ++++++++++++++-- src/ui/streetLevelPresentation.js | 5 ++ src/ui/styles/street-level.css | 6 ++- src/ui/templates/layer-panels.html | 4 +- 23 files changed, 305 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54380bdc2..6157732b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 420953f1f..8d410fe39 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520b04848..ad7de6969 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 76c9891e8..6f9117fc4 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -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); diff --git a/src/layers/streetLevel/glyphs.js b/src/layers/streetLevel/glyphs.js index 962b1e47c..d25c22016 100644 --- a/src/layers/streetLevel/glyphs.js +++ b/src/layers/streetLevel/glyphs.js @@ -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) => { diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 8d908dfff..817b2db5c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -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)], }; }); } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 042aee012..38ee76498 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -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'); diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index a8818e164..b2fdaa6f9 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -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 { diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs index ae90b6fd3..e9e565bb6 100644 --- a/src/layers/streetLevel/marker.test.mjs +++ b/src/layers/streetLevel/marker.test.mjs @@ -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]); +}); diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index b76a30be7..5beaaed60 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -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; }, diff --git a/src/layers/streetLevel/providers/google/policy.js b/src/layers/streetLevel/providers/google/policy.js index ea4ba8078..580c01bbc 100644 --- a/src/layers/streetLevel/providers/google/policy.js +++ b/src/layers/streetLevel/providers/google/policy.js @@ -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 ?? '') diff --git a/src/layers/streetLevel/providers/google/provider.test.mjs b/src/layers/streetLevel/providers/google/provider.test.mjs index 71d45072a..3314f0048 100644 --- a/src/layers/streetLevel/providers/google/provider.test.mjs +++ b/src/layers/streetLevel/providers/google/provider.test.mjs @@ -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 }); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index cf9ee67e5..a057c3a82 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -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); } diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 74d9991dc..7981d9acf 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -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/, + ); +}); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index 99abeae06..fd92e05ec 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -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, }; } diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index d4a07b5e6..b81226b30 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -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} 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, 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, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index e1e0fd4eb..894075f6f 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -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, diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 913dae78a..4d8966e11 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -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. diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 9e4bf40bc..5bc323286 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -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, diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 18c72b8b2..5332310ac 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -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, + }); + })); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 527c07e97..650f045bf 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -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), diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index fc702d71c..7f5b83cdc 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -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; } diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index f78a07589..4a44c03eb 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -118,7 +118,7 @@
-
+
IMAGERY
@@ -126,7 +126,7 @@
-
+
ANY DATE