mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
eb07b9d5ad
commit
64d939f3d2
23 files changed
+305
-86
No files matched your search
+3
-1
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}));
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user