fix(street-level): /revmain cleanup after the slim rebuild

- Google 3D: the OPEN button re-measures a ground whose tiles are still
  loading (a few times, a second apart) and on a map change, not only
  after the next camera move.
- Mapillary: two mounts on one host during the first download share one
  build instead of leaking a second WebGL viewer.
- Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are
  dropped from localStorage; dead inline-position resets, the drag JSDoc
  and the dead qa-ui-disposal observer branch are gone.
- Docs: z ladder, panel chrome, position storage and the fixture gate's
  actual coverage; changelog fragment for #974 (old .gev-cache/mapillary
  can be deleted).
- ru/es: the layer is named as everywhere else in the UI.
- Fixtures and test fakes: unused exports removed; a fake credit removal
  of an absent credit no longer drops another one; the tile test reset
  frees upstream slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-20 14:02:09 +04:00
1 parent 00e8e3b997
commit 28e0ba7104
22 files changed
+220 -74

No files matched your search

+1 -1
View File
@@ -2882,7 +2882,7 @@
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
"Switch Street Level on to draw its coverage.": "Activa Street Level para dibujar su cobertura.",
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
"No coverage in view": "No hay cobertura a la vista",
+1 -1
View File
@@ -2949,7 +2949,7 @@
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
"Sequence images unavailable": "Снимки последовательности недоступны",
"Switch Street Level on to draw its coverage.": "Включите Street Level, чтобы показать покрытие.",
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
"No coverage in view": "В кадре нет покрытия",
+36 -4
View File
@@ -14,7 +14,7 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { viewCentre } from './view.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
import {
@@ -29,6 +29,9 @@ export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
@@ -163,22 +166,50 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount(entry.def.id);
}
}
remeasureGround();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
let groundRetry = null;
let groundRetries = 0;
function cancelGroundRetry() {
clearTimeout(groundRetry);
groundRetry = null;
}
/**
* Whether a ground click can open an image: the camera is at street zoom.
* Under Google 3D the ground is unknown until its tiles load, so an unknown
* ground is measured again a few times instead of waiting for the next move.
*/
function syncGroundClickReady() {
cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer);
if (
state.enabled &&
!ready &&
groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null
) {
groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
}
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/** A camera move or a map change measures afresh. */
function remeasureGround() {
groundRetries = 0;
syncGroundClickReady();
}
let removeCameraListener = null;
function watchCamera(viewer) {
removeCameraListener?.();
removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(syncGroundClickReady) || null;
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
}
const activeEntries = () =>
@@ -463,7 +494,7 @@ export function createStreetLevelLayer({
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchCamera(viewer);
syncGroundClickReady();
remeasureGround();
notify();
},
@@ -472,6 +503,7 @@ export function createStreetLevelLayer({
abortNearest();
removeCameraListener?.();
removeCameraListener = null;
cancelGroundRetry();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncGroundClickReady();
+61 -2
View File
@@ -352,8 +352,11 @@ function fakeViewer() {
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
@@ -786,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, credit, source and im
assert.deepEqual(listed(), ['mapillary']);
});
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => ground;
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
assert.equal(layer.getUIState().groundClickReady, false);
ground = 2000;
t.mock.timers.tick(1000);
assert.equal(layer.getUIState().groundClickReady, true);
});
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
samples++;
return undefined;
};
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
t.mock.timers.tick(60_000);
const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent();
layer.disable();
const atDisable = samples;
t.mock.timers.tick(60_000);
assert.equal(samples, atDisable, 'no measuring once the layer is off');
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
@@ -21,6 +21,8 @@ export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
/** The viewer being built for a host: a second mount joins it, not a twin. */
let building = null;
let pendingOpen = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
@@ -100,8 +102,17 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
async function ensureViewer(host) {
if (viewer && container === host) return viewer;
function ensureViewer(host) {
if (viewer && container === host) return Promise.resolve(viewer);
if (building?.host === host) return building.promise;
const promise = buildViewer(host).finally(() => {
if (building?.promise === promise) building = null;
});
building = { host, promise };
return promise;
}
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
@@ -177,6 +188,7 @@ export function createMapillaryViewer({ source, render } = {}) {
pendingOpen = null;
// A build still waiting for the library gives up when it arrives.
generation++;
building = null;
destroyViewer();
},
+13
View File
@@ -180,6 +180,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
}
});
test('a click on the position marker neither selects nor opens at the ground', () => {
const h = harness();
try {
h.scene.under = 'sl:pos';
h.click();
assert.deepEqual(h.groundClicks, []);
assert.deepEqual(h.opened, []);
assert.deepEqual(h.sequences.selects, []);
} finally {
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
@@ -385,6 +385,19 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('two mounts on one host during the first download build one Mapillary viewer', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const first = adapter.mount(element);
const second = adapter.mount(element);
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1);
adapter.unmount();
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('closing the photo stops sequence playback in the hidden viewer', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
+5 -2
View File
@@ -205,8 +205,11 @@ export function fakeCesiumViewer({ view = false } = {}) {
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
+1 -15
View File
@@ -53,8 +53,7 @@ import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
* Responsibilities:
* - Visual controls and presets backed by the VisualEffects controller.
* - Bloom and sharpen post-processing toggle/intensity control.
* - Draggable/collapsible panel system with localStorage persistence,
* z-order stacking, and viewport-clamped positioning.
* - Collapsible rail panels with their collapsed state kept in localStorage.
* - CCTV panel: camera selection, coverage toggle, projection, calibration
* sliders, auto-hop, and summary typewriter effect.
* - Location bar with city/POI preset pills, QWERTY key navigation,
@@ -1204,19 +1203,6 @@ export class StyleManager extends ShellFacade {
return true;
}
/**
* Makes a panel draggable via its handle element. Implements:
* - Z-order promotion: each pointerdown increments the global z-counter
* so the clicked panel floats above siblings.
* - Viewport clamping: drag moves are clamped to a 6px inset from all edges.
* - Right-rail pinning: pp-toggles panel is re-anchored right after drag.
* - CCTV viewport sync: cctv-panel recalculates scroll height after drag.
* @param {string} panelId - DOM id of the panel.
* @param {HTMLElement} panelEl - The panel DOM element.
* @param {HTMLElement} handleEl - The drag handle element within the panel.
* @returns {void}
*/
/**
* Programmatically collapses or expands a panel, persists the state,
* and triggers layout recalculation for dependent panels.
-8
View File
@@ -189,12 +189,6 @@ export class PanelLayoutController {
const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return;
this._ppToggles.style.removeProperty('top');
this._ppToggles.style.removeProperty('right');
this._ppToggles.style.removeProperty('bottom');
this._ppToggles.style.removeProperty('left');
this._ppToggles.style.removeProperty('z-index');
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [
@@ -204,8 +198,6 @@ export class PanelLayoutController {
this._streetLevelPanel,
]) {
if (!panel) continue;
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
+16
View File
@@ -8,6 +8,11 @@ const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
* the layout-reset notice and the documented key.
*/
const PANEL_POSITION_STORAGE_VERSION = 'v8';
/** Floating panels' stored positions and hint flag: nothing reads them now. */
const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
export class PanelPositionControls {
constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
@@ -15,6 +20,7 @@ export class PanelPositionControls {
}
_maybeNotifyLayoutReset() {
this._dropRetiredPanelKeys();
try {
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
if (localStorage.getItem(marker)) return;
@@ -32,6 +38,16 @@ export class PanelPositionControls {
}
}
_dropRetiredPanelKeys() {
try {
for (const key of Object.keys(localStorage))
if (RETIRED_PANEL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)))
localStorage.removeItem(key);
} catch {
// storage unavailable
}
}
_panelCollapseStorageKey(panelId) {
return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** A localStorage stand-in whose keys enumerate like the real one. */
function fakeStorage(entries) {
const storage = {};
Object.defineProperties(storage, {
getItem: { value: (key) => (key in storage ? storage[key] : null) },
setItem: { value: (key, value) => (storage[key] = String(value)) },
removeItem: { value: (key) => delete storage[key] },
});
Object.assign(storage, entries);
return storage;
}
test('the floating panels’ stored positions and hint flag are dropped; collapse state stays', (t) => {
const saved = globalThis.localStorage;
globalThis.localStorage = fakeStorage({
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
'godsEyeView.v8.panelFloatHintShown': '1',
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
'godsEyeView.v8.layoutResetNotified': '1',
});
t.after(() => {
globalThis.localStorage = saved;
});
const controls = new PanelPositionControls({ showToast() {} });
controls._maybeNotifyLayoutReset();
assert.deepEqual(Object.keys(globalThis.localStorage).sort(), [
'godsEyeView.v6.panelCollapsed.cctv-panel',
'godsEyeView.v8.layoutResetNotified',
]);
});
+2 -12
View File
@@ -228,7 +228,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
test('weather rail orders between CCTV and Context, resets positions, observes and syncs chrome', (t) => {
test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -263,16 +263,7 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
]) {
const panel = f.nodes.get(id) || f.element();
panel.id = id;
const positions = new Map(
['top', 'right', 'bottom', 'left', 'z-index'].map((key) => [
key,
'20px',
]),
);
panel.style = {
removeProperty: (key) => positions.delete(key),
positions,
};
panel.style = {};
panel.classList.add('collapsed');
const button = f.element();
button.closest = () => panel;
@@ -307,7 +298,6 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.style.positions.size, 0);
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
+1 -1
View File
@@ -381,7 +381,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
position: fixed;
right: 36px;
bottom: 36px;
/* Z ladder: panels 100-139, voice pill 150, toast 200, clean-view-exit 300 */
/* Z ladder: panels ~100, voice pill 150, toast 200, clean-view-exit 300 */
z-index: 150;
display: flex;
align-items: center;