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:
Said TurkseverandClaude Opus 5.5 committed 2026-09-26 00:58:43 +01:00
1 parent f7456f2436
commit c7dc3146f2
14 files changed
+246 -12

No files matched your search

+1 -1
View File
@@ -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?.();
}
+25
View File
@@ -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({
+113
View File
@@ -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);
});
+7
View File
@@ -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;
+2
View File
@@ -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;
+2
View File
@@ -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,