mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(street-level): camera follow only on the Google 3D map
FOLLOW puts the globe camera at eye height inside the street-level view; on flat imagery or terrain there is nothing to stand among. The layer now takes the application map stack controller (attachMapStackController), subscribes to stack switches, offers FOLLOW only while the stack is 'photoreal', disables the button with a MAP SOURCE hint elsewhere, and stops following when the stack leaves Google 3D. Selection uninstall is a no-op when never installed. The QA gate checks the disabled path and, when Google 3D is available, that leaving it stops following; its caption-timeout dump now forces one render to tell a missed update from a render fault. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f7456f2436
commit
c7dc3146f2
14 files changed
+246
-12
No files matched your search
+1
-1
@@ -6,7 +6,7 @@
|
||||
reloads, and snaps back on a header double-click. A panel can also ask to
|
||||
dock when it is collapsed while floating (Street Level does).
|
||||
|
||||
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider.
|
||||
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider. FOLLOW (the globe camera following the street-level view) is available on the Google 3D map only.
|
||||
|
||||
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
||||
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
|
||||
|
||||
@@ -336,7 +336,7 @@ Setup for Mapillary:
|
||||
|
||||
Without the token the STREET LEVEL panel reads **KEY REQUIRED**, the Mapillary chip turns amber and the controls stay greyed out.
|
||||
|
||||
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider: lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Mapillary's coverage draws as ground-hugging lines (recent captures bright green, older ones dim, 360° panoramas magenta); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
|
||||
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider: lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Mapillary's coverage draws as ground-hugging lines (recent captures bright green, older ones dim, 360° panoramas magenta); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks; available on the Google 3D map only, since flat imagery has nothing to stand among); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
|
||||
|
||||
The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe.
|
||||
|
||||
|
||||
@@ -2792,7 +2792,11 @@ one provider it is the layer switch too, and with several, darkening the last
|
||||
lit chip turns the layer off while leaving that provider switched on. The
|
||||
viewer sits first, under the header, with its tools (EXPAND, FIT/FILL, FOLLOW,
|
||||
close) in a bar above the image so the provider's own overlays stay clear;
|
||||
the settings follow. SINCE is a stepped range input (any date, 10, 5, 3, 2 and
|
||||
the settings follow. FOLLOW is offered only while the map stack is Google 3D
|
||||
(`photoreal`): the layer takes the application map stack controller through
|
||||
`attachMapStackController`, subscribes to stack switches, disables the button
|
||||
with a MAP SOURCE hint on other stacks, and stops following when the stack
|
||||
leaves Google 3D. SINCE is a stepped range input (any date, 10, 5, 3, 2 and
|
||||
1 years, 6, 3 and 1 months) whose readout names the cut-off date. The panel is
|
||||
portable (`street-level-panel` spec in `PanelPositionControls`, minimum
|
||||
320 × 280, `dockOnCollapse`): in a resized window the viewer takes the spare
|
||||
|
||||
@@ -889,5 +889,6 @@
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs"
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/index.test.mjs"
|
||||
]
|
||||
@@ -408,8 +408,16 @@ async function main() {
|
||||
const u = window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState();
|
||||
// Force one more render: a caption that fills now means an update
|
||||
// was missed; one that stays empty means the render path is wrong.
|
||||
u.street.renderMode &&
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.setViewerRenderMode(u.street.renderMode);
|
||||
return {
|
||||
street: u.street,
|
||||
captionNodes: document.querySelectorAll('#sl-image-when').length,
|
||||
captionNow: document.getElementById('sl-image-when').textContent,
|
||||
wrapHidden: document.getElementById('sl-viewer-wrap').hidden,
|
||||
collapsed: document
|
||||
.getElementById('street-level-panel')
|
||||
@@ -460,6 +468,55 @@ async function main() {
|
||||
assert.match(view.href, /mapillary\.com\/app\/\?pKey=/);
|
||||
},
|
||||
);
|
||||
await step('FOLLOW is offered only on the Google 3D map', async () => {
|
||||
const stacks = () => window.__godsEyeView.mapStackController;
|
||||
const follow = () =>
|
||||
page.$eval('#sl-follow-btn', (node) => ({
|
||||
disabled: node.disabled,
|
||||
pressed: node.getAttribute('aria-pressed'),
|
||||
title: node.title,
|
||||
}));
|
||||
const setStack = async (id) => {
|
||||
await page.evaluate(
|
||||
(stackId) =>
|
||||
window.__godsEyeView.mapStackController.setStack(stackId),
|
||||
id,
|
||||
);
|
||||
await sleep(800);
|
||||
};
|
||||
const original = await page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId(),
|
||||
);
|
||||
await setStack('esri-imagery');
|
||||
let state = await follow();
|
||||
assert.equal(state.disabled, true, 'disabled on Esri');
|
||||
assert.match(state.title, /needs the Google 3D map/);
|
||||
const photoreal = await page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.isStackAvailable('photoreal'),
|
||||
);
|
||||
if (photoreal) {
|
||||
await setStack('photoreal');
|
||||
state = await follow();
|
||||
assert.equal(state.disabled, false, 'enabled on Google 3D');
|
||||
await page.click('#sl-follow-btn');
|
||||
await sleep(300);
|
||||
assert.equal((await follow()).pressed, 'true');
|
||||
await setStack('esri-imagery');
|
||||
state = await follow();
|
||||
assert.equal(
|
||||
state.pressed,
|
||||
'false',
|
||||
'leaving Google 3D stops following',
|
||||
);
|
||||
assert.equal(state.disabled, true);
|
||||
} else {
|
||||
console.log(
|
||||
' (Google 3D unavailable here: only the disabled path ran)',
|
||||
);
|
||||
}
|
||||
await setStack(original);
|
||||
void stacks;
|
||||
});
|
||||
await step(
|
||||
'EXPAND opens a modal dialog and Esc returns focus to the button',
|
||||
async () => {
|
||||
@@ -624,12 +681,8 @@ async function main() {
|
||||
await step(
|
||||
'on a phone the whole photo fits in the docked panel',
|
||||
async () => {
|
||||
await page.setViewport({
|
||||
width: 390,
|
||||
height: 844,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await sleep(800);
|
||||
if (
|
||||
await page.$eval('#street-level-panel', (node) =>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createCameraFollow({ state }) {
|
||||
}
|
||||
|
||||
function setFollow(enabled) {
|
||||
state.street.follow = enabled === true;
|
||||
state.street.follow = enabled === true && state.street.followAvailable;
|
||||
if (state.street.follow) followCamera();
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import { viewCentre } from './view.js';
|
||||
import {
|
||||
FOLLOW_MAP_STACK_ID,
|
||||
NEAREST_RADIUS_M,
|
||||
POSITION_PICK_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
@@ -99,6 +100,18 @@ export function createStreetLevelLayer({
|
||||
state.providers.set(def.id, entry);
|
||||
}
|
||||
|
||||
let mapStack = null;
|
||||
let unsubscribeMapStack = null;
|
||||
|
||||
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
|
||||
function syncFollowAvailability() {
|
||||
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
if (available === state.street.followAvailable) return;
|
||||
state.street.followAvailable = available;
|
||||
if (!available && state.street.follow) parts.follow.setFollow(false);
|
||||
notify();
|
||||
}
|
||||
|
||||
const activeEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => entry.on);
|
||||
|
||||
@@ -256,6 +269,9 @@ export function createStreetLevelLayer({
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.destroy(viewer);
|
||||
parts.marker.destroy(viewer);
|
||||
unsubscribeMapStack?.();
|
||||
unsubscribeMapStack = null;
|
||||
mapStack = null;
|
||||
state.listeners.clear();
|
||||
state.viewer = null;
|
||||
state.initialized = false;
|
||||
@@ -279,6 +295,15 @@ export function createStreetLevelLayer({
|
||||
};
|
||||
},
|
||||
|
||||
/** The application map stack; FOLLOW is available only on Google 3D. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMapStack?.();
|
||||
mapStack = controller || null;
|
||||
unsubscribeMapStack =
|
||||
mapStack?.subscribe?.(() => syncFollowAvailability()) || null;
|
||||
syncFollowAvailability();
|
||||
},
|
||||
|
||||
/** Share-link and stored state: provider switches plus the filter. */
|
||||
getParams() {
|
||||
return encodeParams({
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createStreetLevelLayer } from './index.js';
|
||||
|
||||
/** The smallest provider the core accepts; nothing here touches a viewer. */
|
||||
function fakeProvider() {
|
||||
return {
|
||||
id: 'mapillary',
|
||||
name: 'Mapillary',
|
||||
label: 'MAPILLARY',
|
||||
requiresKeyId: null,
|
||||
pickPrefix: 'mly:',
|
||||
colors: { coverage: '#05cb63' },
|
||||
credit: { key: 'mapillary', html: 'Mapillary' },
|
||||
capabilities: {
|
||||
coverage: 'tiles',
|
||||
sequences: false,
|
||||
pano: true,
|
||||
capturedAt: true,
|
||||
creator: true,
|
||||
follow: true,
|
||||
},
|
||||
legend: [],
|
||||
externalUrl: (id) => `https://example.test/${id}`,
|
||||
create: () => ({
|
||||
status: async () => ({ configured: true }),
|
||||
init() {},
|
||||
activate() {},
|
||||
deactivate() {},
|
||||
destroy() {},
|
||||
refreshCoverage() {},
|
||||
setFilter() {},
|
||||
coverageStats: () => ({
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint: '',
|
||||
error: null,
|
||||
keyRequired: false,
|
||||
}),
|
||||
handlePick: () => false,
|
||||
nearestImage: async () => null,
|
||||
viewer: {
|
||||
mount: async () => {},
|
||||
open: async () => {},
|
||||
close() {},
|
||||
unmount() {},
|
||||
resize() {},
|
||||
onPose: () => () => {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A map stack controller that can be switched between stacks. */
|
||||
function fakeMapStack(initial) {
|
||||
let active = initial;
|
||||
const listeners = new Set();
|
||||
return {
|
||||
getActiveId: () => active,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
switchTo(id) {
|
||||
active = id;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
test('FOLLOW is available only while the Google 3D map stack is active', () => {
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
assert.equal(
|
||||
layer.getUIState().street.followAvailable,
|
||||
false,
|
||||
'unknown stack: off',
|
||||
);
|
||||
|
||||
const stack = fakeMapStack('esri-imagery');
|
||||
layer.attachMapStackController(stack);
|
||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
||||
layer.setFollow(true);
|
||||
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
|
||||
|
||||
stack.switchTo('photoreal');
|
||||
assert.equal(layer.getUIState().street.followAvailable, true);
|
||||
layer.setFollow(true);
|
||||
assert.equal(layer.getUIState().street.follow, true);
|
||||
|
||||
stack.switchTo('osm');
|
||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
||||
assert.equal(
|
||||
layer.getUIState().street.follow,
|
||||
false,
|
||||
'leaving Google 3D stops following',
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
const first = fakeMapStack('photoreal');
|
||||
layer.attachMapStackController(first);
|
||||
assert.equal(first.listenerCount(), 1);
|
||||
const second = fakeMapStack('photoreal');
|
||||
layer.attachMapStackController(second);
|
||||
assert.equal(first.listenerCount(), 0);
|
||||
assert.equal(second.listenerCount(), 1);
|
||||
layer.destroy();
|
||||
assert.equal(second.listenerCount(), 0);
|
||||
});
|
||||
@@ -22,5 +22,12 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||
/** Nearest-image search when the user asks to look at a place. */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
|
||||
/**
|
||||
* Map stack the globe camera may follow the street-level view on. Only Google
|
||||
* Photorealistic 3D has buildings to stand among; on flat imagery or terrain
|
||||
* a camera at eye height looks at a smeared texture.
|
||||
*/
|
||||
export const FOLLOW_MAP_STACK_ID = 'photoreal';
|
||||
|
||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
||||
export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
@@ -90,6 +90,8 @@ export function createSelection({ state, parts }) {
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
// Nothing to undo for a layer that was never enabled.
|
||||
if (!state.clickHandler) return;
|
||||
if (hoverCursor && state.viewer?.scene?.canvas) {
|
||||
state.viewer.scene.canvas.style.cursor = '';
|
||||
hoverCursor = false;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
host: null,
|
||||
open: false,
|
||||
follow: false,
|
||||
/** Whether the active map stack allows following (Google 3D only). */
|
||||
followAvailable: false,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerLabel: null,
|
||||
|
||||
@@ -380,6 +380,7 @@ export class StreetLevelControls {
|
||||
if (el.followBtn) {
|
||||
el.followBtn.setAttribute('aria-pressed', String(viewer.follow.pressed));
|
||||
el.followBtn.disabled = viewer.follow.disabled;
|
||||
el.followBtn.title = viewer.follow.title;
|
||||
}
|
||||
for (const button of this.root.querySelectorAll('[data-sl-render]')) {
|
||||
const active = button.dataset.slRender === viewer.renderMode;
|
||||
|
||||
@@ -123,7 +123,11 @@ function presentViewer(state) {
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
follow: {
|
||||
pressed: street.follow === true,
|
||||
disabled: street.open !== true,
|
||||
disabled: street.open !== true || street.followAvailable !== true,
|
||||
title:
|
||||
street.followAvailable === true
|
||||
? 'Camera follows view: move the globe camera wherever the street-level view looks'
|
||||
: 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,6 +286,7 @@ test('viewer caption reads "Image by" left, date right, and links to the provide
|
||||
isPano: true,
|
||||
externalUrl:
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
followAvailable: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -311,3 +312,24 @@ test('an image without a creator name leaves the left caption empty', () => {
|
||||
assert.equal(view.viewer.link, null);
|
||||
assert.equal(view.viewer.linkLabel, '');
|
||||
});
|
||||
|
||||
test('FOLLOW is disabled off Google 3D and says where to switch', () => {
|
||||
const open = { open: true, imageId: '1' };
|
||||
const off3d = presentStreetLevelPanel(
|
||||
snapshot({ street: { ...open, followAvailable: false } }),
|
||||
);
|
||||
assert.equal(off3d.viewer.follow.disabled, true);
|
||||
assert.match(
|
||||
off3d.viewer.follow.title,
|
||||
/needs the Google 3D map.*MAP SOURCE/,
|
||||
);
|
||||
const on3d = presentStreetLevelPanel(
|
||||
snapshot({ street: { ...open, followAvailable: true } }),
|
||||
);
|
||||
assert.equal(on3d.viewer.follow.disabled, false);
|
||||
assert.match(on3d.viewer.follow.title, /^Camera follows view/);
|
||||
const closed = presentStreetLevelPanel(
|
||||
snapshot({ street: { open: false, followAvailable: true } }),
|
||||
);
|
||||
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
|
||||
});
|
||||
Reference in new issue
Block a user