mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
00e8e3b997
commit
28e0ba7104
22 files changed
+220
-74
No files matched your search
@@ -0,0 +1,6 @@
|
||||
- Street Level is rebuilt around one small provider registry: Mapillary,
|
||||
KartaView, Panoramax and Google Street View share one coverage drawer and
|
||||
one photo viewer, one source lit at a time. Floating and docked panels,
|
||||
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
|
||||
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
|
||||
deleted (gekh, #974).
|
||||
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
|
||||
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
||||
|
||||
Keep persistence with panel chrome: collapse uses
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
|
||||
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
|
||||
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
|
||||
Do not rename existing
|
||||
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
||||
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
||||
|
||||
@@ -651,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
|
||||
visual settings and panel chrome. Navigation owns request generations and follow
|
||||
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
||||
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
|
||||
owns disclosure and Cockpit panel snapshots. Each takes explicit
|
||||
operations and reads instead of the complete shell. Existing controls, share
|
||||
formats and one-application-per-page behavior are preserved.
|
||||
|
||||
@@ -977,10 +977,9 @@ start with current state, isolate listener failures and stop on disposal.
|
||||
|
||||
The standalone entry composes the UI with the application's existing layer,
|
||||
terrain, navigation and rendering operations. The shell receives those instances
|
||||
and assembles the controls. Panel layout scheduling, position preferences and
|
||||
active drags, loading notices, recording presentation and DOM lookup have focused
|
||||
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
|
||||
owners. Disposal revokes queued presentation and listeners before asynchronous
|
||||
Context restoration, cancels an unfinished drag without saving it, restores the
|
||||
Context restoration, restores the
|
||||
recording HUD and releases status decoration without replacing accessible text.
|
||||
The ordinary control snapshot includes the current 3D model toggle and mode.
|
||||
|
||||
@@ -3771,7 +3770,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
||||
needs no network and covers the whole photo flow. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
|
||||
needs no network. It opens and closes a photo from Mapillary, KartaView and
|
||||
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
|
||||
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
|
||||
|
||||
Directions is a keyless front end to the routing the voice agent already
|
||||
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||
@@ -5064,7 +5065,7 @@ pins and share links already supply complete boxes.
|
||||
Pixel-mode pinch deltas receive bounded amplification before reaching the
|
||||
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
|
||||
unchanged. The application scene owns and removes the relay listener.
|
||||
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
|
||||
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
|
||||
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
||||
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
||||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||
|
||||
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
|
||||
nearestLookups = 0;
|
||||
}
|
||||
|
||||
/** Nearest lookups answered since the last reset (the first got a 429). */
|
||||
export function kartaViewLookups() {
|
||||
return nearestLookups;
|
||||
}
|
||||
|
||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||
export function kartaViewPhotos() {
|
||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
|
||||
height: 700,
|
||||
pitch: -1.4,
|
||||
});
|
||||
export const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_SPACING_M = 10;
|
||||
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||
const API = 'https://api.panoramax.xyz/api';
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelPositionControls.test.mjs",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
|
||||
@@ -43,11 +43,7 @@ try {
|
||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||
const watchObserver = (name) => {
|
||||
const owner =
|
||||
name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout;
|
||||
const observer = owner[name];
|
||||
const observer = ui._panelLayout[name];
|
||||
if (!observer) return false;
|
||||
const disconnect = observer.disconnect.bind(observer);
|
||||
counts[name] = 0;
|
||||
@@ -59,7 +55,6 @@ try {
|
||||
};
|
||||
const observed = [
|
||||
'_commandDockTrayObserver',
|
||||
'_draggableResizeObserver',
|
||||
'_leftStackResizeObserver',
|
||||
'_rightStackResizeObserver',
|
||||
'_leftStackMutationObserver',
|
||||
@@ -193,11 +188,7 @@ try {
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) =>
|
||||
counts[name] === 1 &&
|
||||
(name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout)[name] === null,
|
||||
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
|
||||
),
|
||||
subscriptionReleased:
|
||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||
|
||||
@@ -11,7 +11,7 @@ const MAX_ZOOM = 14;
|
||||
* (positions come from the Graph API), so it is dropped in transit.
|
||||
*/
|
||||
const DROP_LAYERS = ['image'];
|
||||
/** A z14 tile with its image layer is ~11 MB; anything past this is wrong. */
|
||||
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
|
||||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
/** Upstream fetches at once; more wait their turn. */
|
||||
@@ -260,4 +260,6 @@ export function _resetTileCacheForTest() {
|
||||
memory.clear();
|
||||
inFlight.clear();
|
||||
refusal = null;
|
||||
upstreamActive = 0;
|
||||
upstreamWaiting.length = 0;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "В кадре нет покрытия",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user