mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(street-level): review round — safer docking, mount retry, provider fallthrough
- Only a user's own collapse docks a floating window; cockpit entry, restores and the Cyber accordion no longer dock it or delete its saved place, and the accordion leaves floating windows alone. - The viewer host records an adapter as active only after its mount succeeds, so a library that failed to load once is retried; concurrent opens share one mount, and unmounting mid-mount leaves nothing active. - openNearest tries every active provider; one provider's error (no key, offline) no longer hides the others' imagery. - The SINCE readout shows its cut-off date as soon as the committed value matches the thumb, even while the slider keeps focus. - A floating window that was only dragged is bounded to the viewport and scrolls instead of running off-screen. - getStats summarises coverage (summarizeCoverage) instead of building a full UI snapshot every second; disable() uses credits.hideAll(); smaller cleanups in the viewer host and sequence snapshot. - Unit tests: panel docking and header double press, viewer-host mount retry/sharing/cancel, coverage summary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5877f24f0b
commit
6207e4144e
12 files changed
+425
-55
No files matched your search
@@ -887,5 +887,7 @@
|
|||||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||||
"src/layers/streetLevel/uiState.test.mjs",
|
"src/layers/streetLevel/uiState.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs"
|
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||||
|
"src/ui/panelDock.test.mjs",
|
||||||
|
"src/layers/streetLevel/viewerHost.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -35,5 +35,5 @@ export function createCredits() {
|
|||||||
for (const id of [...shown.keys()]) hide(viewer, { id });
|
for (const id of [...shown.keys()]) hide(viewer, { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { show, hide, hideAll, has: (id) => shown.has(id) };
|
return { show, hide, hideAll };
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { createViewerHost } from './viewerHost.js';
|
|||||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
import { decodeParams, encodeParams } from './params.js';
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
import { composeUIState } from './uiState.js';
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
import { viewCentre } from './view.js';
|
import { viewCentre } from './view.js';
|
||||||
import {
|
import {
|
||||||
NEAREST_RADIUS_M,
|
NEAREST_RADIUS_M,
|
||||||
@@ -174,9 +174,10 @@ export function createStreetLevelLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sequenceSnapshot() {
|
function sequenceSnapshot() {
|
||||||
|
// The provider showing the open image answers first.
|
||||||
const owner = state.providers.get(state.street.providerId);
|
const owner = state.providers.get(state.street.providerId);
|
||||||
const candidates = owner
|
const candidates = owner
|
||||||
? [owner, ...state.providers.values()]
|
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
||||||
: [...state.providers.values()];
|
: [...state.providers.values()];
|
||||||
for (const entry of candidates) {
|
for (const entry of candidates) {
|
||||||
const stats = entry.instance.sequenceStats?.();
|
const stats = entry.instance.sequenceStats?.();
|
||||||
@@ -239,10 +240,8 @@ export function createStreetLevelLayer({
|
|||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
parts.viewerHost.unmount();
|
parts.viewerHost.unmount();
|
||||||
for (const entry of state.providers.values()) {
|
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||||
entry.instance.deactivate();
|
parts.credits.hideAll(state.viewer);
|
||||||
parts.credits.hide(state.viewer, entry.def);
|
|
||||||
}
|
|
||||||
parts.selection.uninstall();
|
parts.selection.uninstall();
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
notify();
|
notify();
|
||||||
@@ -264,18 +263,18 @@ export function createStreetLevelLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
const ui = getUIState();
|
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||||
|
const coverage = summarizeCoverage(providerSnapshots());
|
||||||
let loadingLabel = '';
|
let loadingLabel = '';
|
||||||
if (ui.keyRequired) loadingLabel = 'KEY REQUIRED';
|
if (coverage.keyRequired) loadingLabel = 'KEY REQUIRED';
|
||||||
else if (ui.coverage.loading) loadingLabel = 'loading coverage...';
|
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||||
else if (ui.coverage.hint && state.enabled)
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||||
loadingLabel = ui.coverage.hint;
|
|
||||||
return {
|
return {
|
||||||
count: ui.coverage.count,
|
count: coverage.count,
|
||||||
sequences: ui.coverage.count,
|
sequences: coverage.count,
|
||||||
loading: ui.coverage.loading,
|
loading: coverage.loading,
|
||||||
keyRequired: ui.keyRequired,
|
keyRequired: coverage.keyRequired,
|
||||||
error: ui.keyRequired ? 'KEY REQUIRED' : ui.coverage.error,
|
error: coverage.keyRequired ? 'KEY REQUIRED' : coverage.error,
|
||||||
loadingLabel,
|
loadingLabel,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -325,25 +324,29 @@ export function createStreetLevelLayer({
|
|||||||
state.street.loading = true;
|
state.street.loading = true;
|
||||||
state.street.error = null;
|
state.street.error = null;
|
||||||
notify();
|
notify();
|
||||||
try {
|
let lastError = null;
|
||||||
for (const entry of activeEntries()) {
|
for (const entry of activeEntries()) {
|
||||||
const imageId = await entry.instance.nearestImage({
|
let imageId = null;
|
||||||
|
try {
|
||||||
|
imageId = await entry.instance.nearestImage({
|
||||||
lat: view.lat,
|
lat: view.lat,
|
||||||
lon: view.lon,
|
lon: view.lon,
|
||||||
});
|
});
|
||||||
if (!imageId) continue;
|
} catch (error) {
|
||||||
await openImage(entry.def.id, imageId);
|
// One provider failing (no key, offline) must not hide the others.
|
||||||
return true;
|
lastError = error;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(
|
if (!imageId) continue;
|
||||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`,
|
await openImage(entry.def.id, imageId);
|
||||||
);
|
return true;
|
||||||
} catch (error) {
|
|
||||||
state.street.error = error?.message || 'Nearest image unavailable';
|
|
||||||
state.street.loading = false;
|
|
||||||
notify();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
state.street.error =
|
||||||
|
lastError?.message ||
|
||||||
|
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
||||||
|
state.street.loading = false;
|
||||||
|
notify();
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
/** Close the image and deselect it everywhere on the map. */
|
/** Close the image and deselect it everywhere on the map. */
|
||||||
closeViewer() {
|
closeViewer() {
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ import { COLORS } from './policy.js';
|
|||||||
* @property {Array<{key: string, label: string, color: string}>} legend
|
* @property {Array<{key: string, label: string, color: string}>} legend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the switched-on providers add up to: summed counts, any loading, the
|
||||||
|
* first hint and error, and whether every one of them lacks its key.
|
||||||
|
* @param {Array<ProviderSnapshot>} providers
|
||||||
|
*/
|
||||||
|
export function summarizeCoverage(providers) {
|
||||||
|
const active = providers.filter((p) => p.on);
|
||||||
|
return {
|
||||||
|
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||||
|
loading: active.some((p) => p.loading),
|
||||||
|
hint: active.find((p) => p.hint)?.hint || '',
|
||||||
|
error: active.find((p) => p.error)?.error || null,
|
||||||
|
keyRequired:
|
||||||
|
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the snapshot the panel renders from the core state and one
|
* Compose the snapshot the panel renders from the core state and one
|
||||||
* snapshot per registered provider. Pure, so the merge rules are testable:
|
* snapshot per registered provider. Pure, so the merge rules are testable:
|
||||||
@@ -33,8 +50,7 @@ export function composeUIState({
|
|||||||
sequence,
|
sequence,
|
||||||
}) {
|
}) {
|
||||||
const active = providers.filter((p) => p.on);
|
const active = providers.filter((p) => p.on);
|
||||||
const keyRequired =
|
const { keyRequired, ...coverage } = summarizeCoverage(providers);
|
||||||
active.length > 0 && active.every((p) => p.keyRequired === true);
|
|
||||||
const legend = [];
|
const legend = [];
|
||||||
for (const provider of active)
|
for (const provider of active)
|
||||||
for (const entry of provider.legend)
|
for (const entry of provider.legend)
|
||||||
@@ -53,12 +69,7 @@ export function composeUIState({
|
|||||||
keyRequired,
|
keyRequired,
|
||||||
filter: { ...filter },
|
filter: { ...filter },
|
||||||
providers: providers.map((p) => ({ ...p, legend: [...p.legend] })),
|
providers: providers.map((p) => ({ ...p, legend: [...p.legend] })),
|
||||||
coverage: {
|
coverage,
|
||||||
loading: active.some((p) => p.loading),
|
|
||||||
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
|
||||||
hint: active.find((p) => p.hint)?.hint || '',
|
|
||||||
error: active.find((p) => p.error)?.error || null,
|
|
||||||
},
|
|
||||||
legend,
|
legend,
|
||||||
sequence: { ...sequence },
|
sequence: { ...sequence },
|
||||||
street: { ...street },
|
street: { ...street },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { composeUIState } from './uiState.js';
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
|
|
||||||
const provider = (overrides = {}) => ({
|
const provider = (overrides = {}) => ({
|
||||||
id: 'mapillary',
|
id: 'mapillary',
|
||||||
@@ -102,3 +102,21 @@ test('hint and error come from the first active provider that has one', () => {
|
|||||||
assert.deepEqual(ui.filter, base.filter);
|
assert.deepEqual(ui.filter, base.filter);
|
||||||
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
|
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
summarizeCoverage([
|
||||||
|
provider({ count: 4, hint: 'Look down' }),
|
||||||
|
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
|
||||||
|
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
count: 10,
|
||||||
|
loading: true,
|
||||||
|
hint: 'Look down',
|
||||||
|
error: 'boom',
|
||||||
|
keyRequired: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(summarizeCoverage([]).keyRequired, false);
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
export function createViewerHost({ state, parts }) {
|
export function createViewerHost({ state, parts }) {
|
||||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||||
let active = null;
|
let active = null;
|
||||||
|
/** @type {{id: string, promise: Promise<object>}|null} */
|
||||||
|
let mounting = null;
|
||||||
let openSeq = 0;
|
let openSeq = 0;
|
||||||
|
|
||||||
function notify() {
|
function notify() {
|
||||||
@@ -51,20 +53,50 @@ export function createViewerHost({ state, parts }) {
|
|||||||
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mount(entry) {
|
/**
|
||||||
if (active?.id === entry.def.id) return active.adapter;
|
* Mount a provider's viewer adapter in the host. The adapter becomes
|
||||||
|
* `active` only once its mount succeeded, so a failed mount (a library
|
||||||
|
* that did not load) is retried on the next open; concurrent opens for the
|
||||||
|
* same provider share one mount.
|
||||||
|
*/
|
||||||
|
function mount(entry) {
|
||||||
|
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
|
||||||
|
if (mounting?.id === entry.def.id) return mounting.promise;
|
||||||
if (active) {
|
if (active) {
|
||||||
active.adapter.close();
|
active.adapter.close();
|
||||||
active.unsubscribe();
|
active.unsubscribe();
|
||||||
active.adapter.unmount();
|
active.adapter.unmount();
|
||||||
active = null;
|
active = null;
|
||||||
}
|
}
|
||||||
const { adapter } = { adapter: entry.instance.viewer };
|
const adapter = entry.instance.viewer;
|
||||||
const unsubscribe = adapter.onPose(applyPose);
|
const promise = (async () => {
|
||||||
active = { id: entry.def.id, adapter, unsubscribe };
|
const unsubscribe = adapter.onPose(applyPose);
|
||||||
await adapter.mount(state.street.host);
|
try {
|
||||||
adapter.setRenderMode?.(state.street.renderMode);
|
await adapter.mount(state.street.host);
|
||||||
return adapter;
|
} catch (error) {
|
||||||
|
unsubscribe();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (mounting?.promise !== promise) {
|
||||||
|
// Unmounted (layer off, provider switched) while loading.
|
||||||
|
unsubscribe();
|
||||||
|
adapter.unmount();
|
||||||
|
throw new Error('Street-level viewer was closed');
|
||||||
|
}
|
||||||
|
active = { id: entry.def.id, adapter, unsubscribe };
|
||||||
|
adapter.setRenderMode?.(state.street.renderMode);
|
||||||
|
return adapter;
|
||||||
|
})();
|
||||||
|
mounting = { id: entry.def.id, promise };
|
||||||
|
promise.then(
|
||||||
|
() => {
|
||||||
|
if (mounting?.promise === promise) mounting = null;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (mounting?.promise === promise) mounting = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +204,7 @@ export function createViewerHost({ state, parts }) {
|
|||||||
/** Tear the mounted adapter down (layer disabled or destroyed). */
|
/** Tear the mounted adapter down (layer disabled or destroyed). */
|
||||||
function unmount() {
|
function unmount() {
|
||||||
close();
|
close();
|
||||||
|
mounting = null;
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active.unsubscribe();
|
active.unsubscribe();
|
||||||
active.adapter.unmount();
|
active.adapter.unmount();
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createViewerHost } from './viewerHost.js';
|
||||||
|
|
||||||
|
function harness(adapter) {
|
||||||
|
const state = {
|
||||||
|
enabled: true,
|
||||||
|
notify() {},
|
||||||
|
providers: new Map(),
|
||||||
|
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||||
|
};
|
||||||
|
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||||
|
state.providers.set('mapillary', { def, instance: { viewer: adapter } });
|
||||||
|
const parts = {
|
||||||
|
marker: { set() {}, clear() {} },
|
||||||
|
follow: { followCamera() {}, lookAtPosition() {} },
|
||||||
|
};
|
||||||
|
return { state, host: createViewerHost({ state, parts }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAdapter({ failMounts = 0 } = {}) {
|
||||||
|
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
||||||
|
let emit = null;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async mount() {
|
||||||
|
calls.mount++;
|
||||||
|
if (calls.mount <= failMounts) throw new Error('library failed to load');
|
||||||
|
},
|
||||||
|
async open(id) {
|
||||||
|
calls.open.push(id);
|
||||||
|
emit?.({
|
||||||
|
providerId: 'mapillary',
|
||||||
|
imageId: id,
|
||||||
|
position: { lon: 1, lat: 2 },
|
||||||
|
bearing: 90,
|
||||||
|
isPano: false,
|
||||||
|
externalUrl: `https://example.test/${id}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
unmount() {
|
||||||
|
calls.unmount++;
|
||||||
|
},
|
||||||
|
resize() {},
|
||||||
|
onPose(listener) {
|
||||||
|
calls.listeners++;
|
||||||
|
emit = listener;
|
||||||
|
return () => {
|
||||||
|
calls.listeners--;
|
||||||
|
emit = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a failed mount is retried on the next open instead of being cached', async () => {
|
||||||
|
const adapter = fakeAdapter({ failMounts: 1 });
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'a');
|
||||||
|
assert.equal(state.street.error, 'library failed to load');
|
||||||
|
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
|
||||||
|
await host.open('mapillary', 'b');
|
||||||
|
assert.equal(state.street.error, null);
|
||||||
|
assert.equal(adapter.calls.mount, 2);
|
||||||
|
assert.deepEqual(adapter.calls.open, ['b']);
|
||||||
|
assert.equal(state.street.imageId, 'b');
|
||||||
|
assert.equal(state.street.externalUrl, 'https://example.test/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent opens share one mount and one pose listener', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { host } = harness(adapter);
|
||||||
|
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
|
||||||
|
assert.equal(adapter.calls.mount, 1);
|
||||||
|
assert.equal(adapter.calls.listeners, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmounting while a mount is in flight does not leave it active', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
const opening = host.open('mapillary', 'a');
|
||||||
|
host.unmount();
|
||||||
|
await opening;
|
||||||
|
assert.equal(host.activeProviderId(), null);
|
||||||
|
assert.equal(adapter.calls.listeners, 0);
|
||||||
|
assert.equal(adapter.calls.unmount, 1);
|
||||||
|
assert.equal(state.street.open, false);
|
||||||
|
});
|
||||||
@@ -152,7 +152,7 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
read('src/ui/panelChrome.js'),
|
read('src/ui/panelChrome.js'),
|
||||||
/classList\.toggle\('collapsed', nextCollapsed\);\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/,
|
/classList\.toggle\('collapsed', nextCollapsed\);[\s\S]{0,200}?if \(explicit && !restore\)\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
controls,
|
controls,
|
||||||
|
|||||||
@@ -430,6 +430,8 @@ export class PanelChrome {
|
|||||||
if (
|
if (
|
||||||
peer !== panelEl &&
|
peer !== panelEl &&
|
||||||
peer.matches('[data-panel-id]') &&
|
peer.matches('[data-panel-id]') &&
|
||||||
|
// A floating window is not part of the rail's accordion.
|
||||||
|
!peer.classList.contains('panel-floating') &&
|
||||||
!peer.hidden
|
!peer.hidden
|
||||||
) {
|
) {
|
||||||
this.setPanelCollapsed(peer.id, true, {
|
this.setPanelCollapsed(peer.id, true, {
|
||||||
@@ -557,7 +559,10 @@ export class PanelChrome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
panelEl.classList.toggle('collapsed', nextCollapsed);
|
panelEl.classList.toggle('collapsed', nextCollapsed);
|
||||||
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
|
// Only a user's own collapse docks a floating window; cockpit entry,
|
||||||
|
// restores and accordion peers must not discard where they put it.
|
||||||
|
if (explicit && !restore)
|
||||||
|
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
|
||||||
if (
|
if (
|
||||||
nextCollapsed &&
|
nextCollapsed &&
|
||||||
this.cockpitView?.active &&
|
this.cockpitView?.active &&
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { PanelPositionControls } from './panelPositionControls.js';
|
||||||
|
|
||||||
|
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
|
||||||
|
function fixture() {
|
||||||
|
const saved = Object.fromEntries(
|
||||||
|
['document', 'window', 'localStorage', 'performance'].map((key) => [
|
||||||
|
key,
|
||||||
|
globalThis[key],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const store = new Map();
|
||||||
|
const element = () => {
|
||||||
|
const classes = new Set();
|
||||||
|
const style = {
|
||||||
|
removeProperty(name) {
|
||||||
|
delete this[name];
|
||||||
|
},
|
||||||
|
getPropertyValue: () => '',
|
||||||
|
};
|
||||||
|
return Object.assign(new EventTarget(), {
|
||||||
|
id: '',
|
||||||
|
style,
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||||
|
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||||
|
contains: (name) => classes.has(name),
|
||||||
|
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
|
||||||
|
},
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
closest: () => null,
|
||||||
|
removeAttribute() {},
|
||||||
|
appendChild() {},
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: 100,
|
||||||
|
top: 80,
|
||||||
|
width: 360,
|
||||||
|
height: 420,
|
||||||
|
right: 460,
|
||||||
|
bottom: 500,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
});
|
||||||
|
globalThis.window = Object.assign(new EventTarget(), {
|
||||||
|
innerWidth: 1440,
|
||||||
|
innerHeight: 900,
|
||||||
|
});
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem: (key, value) => store.set(key, String(value)),
|
||||||
|
removeItem: (key) => store.delete(key),
|
||||||
|
};
|
||||||
|
const calls = { layout: 0, resized: [] };
|
||||||
|
const owner = new PanelPositionControls({
|
||||||
|
syncPanelCollapseButton() {},
|
||||||
|
layoutRightPanels: () => calls.layout++,
|
||||||
|
syncCctvPanelViewport() {},
|
||||||
|
showToast() {},
|
||||||
|
onPanelResized: (id) => calls.resized.push(id),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
element,
|
||||||
|
store,
|
||||||
|
calls,
|
||||||
|
restore() {
|
||||||
|
owner.destroy();
|
||||||
|
Object.assign(globalThis, saved);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatingPanel(f, id, { dockOnCollapse = false } = {}) {
|
||||||
|
const panel = f.element();
|
||||||
|
const handle = f.element();
|
||||||
|
panel.id = id;
|
||||||
|
panel.classList.add('panel-floating', 'panel-draggable');
|
||||||
|
Object.assign(panel.style, {
|
||||||
|
left: '100px',
|
||||||
|
top: '80px',
|
||||||
|
width: '360px',
|
||||||
|
height: '420px',
|
||||||
|
});
|
||||||
|
f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}');
|
||||||
|
f.owner._portablePanels.set(id, {
|
||||||
|
panel,
|
||||||
|
min: { width: 320, height: 280 },
|
||||||
|
dockOnCollapse,
|
||||||
|
});
|
||||||
|
f.owner._makePanelDraggable(id, panel, handle);
|
||||||
|
return { panel, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A left-button header press; `timeStamp` is a read-only getter on Event. */
|
||||||
|
function press(handle, { timeStamp, clientX = 200, clientY = 90 }) {
|
||||||
|
const event = new Event('pointerdown', { cancelable: true });
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
button: 0,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
timeStamp,
|
||||||
|
}))
|
||||||
|
Object.defineProperty(event, key, { value });
|
||||||
|
handle.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dockPanel returns a floating window to its rail and forgets its place', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const { panel } = floatingPanel(f, 'street-level-panel');
|
||||||
|
assert.equal(f.owner.dockPanel('street-level-panel'), true);
|
||||||
|
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||||
|
assert.equal(panel.style.width, undefined);
|
||||||
|
assert.equal(panel.style.height, undefined);
|
||||||
|
assert.equal(
|
||||||
|
f.store.has('godsEyeView.v8.panelPos.street-level-panel'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(f.calls.resized, ['street-level-panel']);
|
||||||
|
assert.equal(
|
||||||
|
f.owner.dockPanel('street-level-panel'),
|
||||||
|
false,
|
||||||
|
'already docked',
|
||||||
|
);
|
||||||
|
assert.equal(f.owner.dockPanel('unknown'), false);
|
||||||
|
} finally {
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const street = floatingPanel(f, 'street-level-panel', {
|
||||||
|
dockOnCollapse: true,
|
||||||
|
});
|
||||||
|
const cctv = floatingPanel(f, 'cctv-panel');
|
||||||
|
f.owner.onPanelCollapsed('street-level-panel', false);
|
||||||
|
assert.equal(
|
||||||
|
street.panel.classList.contains('panel-floating'),
|
||||||
|
true,
|
||||||
|
'expanding keeps it',
|
||||||
|
);
|
||||||
|
f.owner.onPanelCollapsed('cctv-panel', true);
|
||||||
|
assert.equal(
|
||||||
|
cctv.panel.classList.contains('panel-floating'),
|
||||||
|
true,
|
||||||
|
'CCTV did not opt in',
|
||||||
|
);
|
||||||
|
f.owner.onPanelCollapsed('street-level-panel', true);
|
||||||
|
assert.equal(street.panel.classList.contains('panel-floating'), false);
|
||||||
|
} finally {
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two quick header presses snap a floating panel back; slow or distant ones do not', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const { panel, handle } = floatingPanel(f, 'cctv-panel');
|
||||||
|
press(handle, { timeStamp: 1000 });
|
||||||
|
window.dispatchEvent(new Event('pointerup'));
|
||||||
|
press(handle, { timeStamp: 1600 });
|
||||||
|
window.dispatchEvent(new Event('pointerup'));
|
||||||
|
assert.equal(
|
||||||
|
panel.classList.contains('panel-floating'),
|
||||||
|
true,
|
||||||
|
'600 ms apart is two clicks',
|
||||||
|
);
|
||||||
|
press(handle, { timeStamp: 1700, clientX: 260 });
|
||||||
|
window.dispatchEvent(new Event('pointerup'));
|
||||||
|
assert.equal(
|
||||||
|
panel.classList.contains('panel-floating'),
|
||||||
|
true,
|
||||||
|
'60 px apart is two clicks',
|
||||||
|
);
|
||||||
|
press(handle, { timeStamp: 1900, clientX: 262 });
|
||||||
|
assert.equal(
|
||||||
|
panel.classList.contains('panel-floating'),
|
||||||
|
false,
|
||||||
|
'a double press docks',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -341,11 +341,19 @@ export class StreetLevelControls {
|
|||||||
button.classList.toggle('is-active', active);
|
button.classList.toggle('is-active', active);
|
||||||
button.setAttribute('aria-checked', String(active));
|
button.setAttribute('aria-checked', String(active));
|
||||||
}
|
}
|
||||||
if (el.sinceRange && document.activeElement !== el.sinceRange) {
|
if (el.sinceRange) {
|
||||||
|
// Never move the thumb under the user's hand; once the committed value
|
||||||
|
// matches the thumb, show the full readout with its cut-off date.
|
||||||
const index = String(view.since.index);
|
const index = String(view.since.index);
|
||||||
if (el.sinceRange.value !== index) el.sinceRange.value = index;
|
if (
|
||||||
if (el.sinceLabel) el.sinceLabel.textContent = view.since.label;
|
document.activeElement !== el.sinceRange &&
|
||||||
el.sinceRange.setAttribute('aria-valuetext', view.since.label);
|
el.sinceRange.value !== index
|
||||||
|
)
|
||||||
|
el.sinceRange.value = index;
|
||||||
|
if (el.sinceRange.value === index) {
|
||||||
|
if (el.sinceLabel) el.sinceLabel.textContent = view.since.label;
|
||||||
|
el.sinceRange.setAttribute('aria-valuetext', view.since.label);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (el.legend && el.legend.childElementCount !== view.legend.length) {
|
if (el.legend && el.legend.childElementCount !== view.legend.length) {
|
||||||
el.legend.replaceChildren(
|
el.legend.replaceChildren(
|
||||||
|
|||||||
@@ -426,6 +426,14 @@ button.sl-status:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Floating window ─────────────────────────────────────────────────────── */
|
/* ── Floating window ─────────────────────────────────────────────────────── */
|
||||||
|
/* A window that was only dragged keeps its natural height; bound it to the
|
||||||
|
viewport so nothing runs off-screen (the rail rule's max-height: 100% does
|
||||||
|
not bind an auto-height parent). Id-qualified to outrank that rule. */
|
||||||
|
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
|
||||||
|
max-height: calc(100vh - 12px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* A resized window (PanelPositionControls writes an inline height) hands the
|
/* A resized window (PanelPositionControls writes an inline height) hands the
|
||||||
spare height to the viewer, so the image never needs scrolling to. */
|
spare height to the viewer, so the image never needs scrolling to. */
|
||||||
.panel-floating[style*='height'] > .street-level-panel-inner {
|
.panel-floating[style*='height'] > .street-level-panel-inner {
|
||||||
|
|||||||
Reference in new issue
Block a user