mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
fix(street-level): retire lookups and preserve embedded photos
Clear cancelled lookup loading over an existing photo, release caller abort listeners on internal retirement, surface embedded errors, and permit embedded photos over the clean globe. Add seven regression cases and update runtime documentation. Preserve the published localization and other review fixes.
This commit is contained in:
1 parent
0ec57057fa
commit
7703090d90
10 files changed
+175
-12
No files matched your search
@@ -11,11 +11,15 @@
|
|||||||
God's Eye View panel only for a result issued in the last five minutes. On
|
God's Eye View panel only for a result issued in the last five minutes. On
|
||||||
a phone, a photo that opens or a link that arms opens the Cameras sheet;
|
a phone, a photo that opens or a link that arms opens the Cameras sheet;
|
||||||
in cockpit, clean view, recording or scene playback the action says the
|
in cockpit, clean view, recording or scene playback the action says the
|
||||||
panel is hidden instead of loading a photo nobody sees. Mapillary's
|
panel is hidden instead of loading a photo nobody sees. Embedded photos
|
||||||
|
remain available over the clean globe. Mapillary's
|
||||||
nearest-photo search asks for 100 photos, so the nearest and the filter's
|
nearest-photo search asks for 100 photos, so the nearest and the filter's
|
||||||
pick come from the spot's photos rather than an arbitrary eight. The source the
|
pick come from the spot's photos rather than an arbitrary eight. The source the
|
||||||
action lights stays lit like a chip click. An unavailable map stack now
|
action lights stays lit like a chip click. An unavailable map stack now
|
||||||
reports the controller's own reason instead of always asking for a Cesium
|
reports the controller's own reason instead of always asking for a Cesium
|
||||||
ion token. When nothing is found and a 360°, flat or SINCE filter the source
|
ion token. When nothing is found and a 360°, flat or SINCE filter the source
|
||||||
honours is on, the answer names the filter (Said Turksever, @asturksever,
|
honours is on, the answer names the filter. Cancelled lookups clear their
|
||||||
|
loading state over an existing photo and release their caller abort listeners.
|
||||||
|
Embedded photos show loading errors inside the overlay, and the OPEN prompt
|
||||||
|
uses the recipient's selected language (Said Turksever, @asturksever,
|
||||||
#1113).
|
#1113).
|
||||||
@@ -4342,7 +4342,8 @@ calls `openNearest`. `provider` is `google` for "Street View", `mapillary`,
|
|||||||
`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`,
|
`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`,
|
||||||
`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view,
|
`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view,
|
||||||
recording or scene playback hides the panel, checked after the flight and
|
recording or scene playback hides the panel, checked after the flight and
|
||||||
before anything switches or loads; an empty `imagery` result names a
|
before anything switches or loads; embed mode exempts its own clean-view
|
||||||
|
class because the photo is lifted above the globe; an empty `imagery` result names a
|
||||||
narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a
|
narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a
|
||||||
newer turn retires the lookup
|
newer turn retires the lookup
|
||||||
(`openNearest`'s `signal` option), so nothing opens after the user moved on.
|
(`openNearest`'s `signal` option), so nothing opens after the user moved on.
|
||||||
@@ -4366,9 +4367,13 @@ applies it through the action, and a plain link runs the action once restored
|
|||||||
(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the
|
(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the
|
||||||
panel is hidden, so an open photo moves to an overlay on the globe
|
panel is hidden, so an open photo moves to an overlay on the globe
|
||||||
(`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full
|
(`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full
|
||||||
screen, Esc leaves full screen first). A caller's abort is
|
screen, Esc leaves full screen first). Image-loading failures render inside
|
||||||
|
the embedded overlay, since the panel's error region is hidden with the rail.
|
||||||
|
A caller's abort is
|
||||||
honoured through the viewer's mount and load, not just the lookup, and only
|
honoured through the viewer's mount and load, not just the lookup, and only
|
||||||
the lookup that still owns the panel may change its spinner or error. A
|
the lookup that still owns the panel may change its spinner or error. Cancelling
|
||||||
|
a lookup clears its loading state even when an existing photo stays open;
|
||||||
|
internally retired lookups also release the caller's abort listener. A
|
||||||
passive link's photos stand down if anyone navigated (a gesture, a search,
|
passive link's photos stand down if anyone navigated (a gesture, a search,
|
||||||
the assistant) while the link waited behind its own marks. A plain
|
the assistant) while the link waited behind its own marks. A plain
|
||||||
link never loads a photo from a source that bills every load
|
link never loads a photo from a source that bills every load
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ export function createStreetLevelLayer({
|
|||||||
if (nearestLookup !== lookup) return false;
|
if (nearestLookup !== lookup) return false;
|
||||||
nearestLookup = null;
|
nearestLookup = null;
|
||||||
// The caller moved on: the panel shows nothing of this lookup.
|
// The caller moved on: the panel shows nothing of this lookup.
|
||||||
if (state.street.loading && !state.street.open) {
|
if (state.street.loading) {
|
||||||
state.street.loading = false;
|
state.street.loading = false;
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
@@ -751,13 +751,13 @@ export function createStreetLevelLayer({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (callerSignal?.aborted) return retired();
|
if (callerSignal?.aborted) return retired();
|
||||||
// Overtaken: whatever replaced it owns the panel, errors included.
|
// Overtaken: whatever replaced it owns the panel, errors included.
|
||||||
if (signal.aborted) return false;
|
if (signal.aborted) return retired();
|
||||||
// One provider failing (no key, offline) must not hide the others.
|
// One provider failing (no key, offline) must not hide the others.
|
||||||
lastError = error;
|
lastError = error;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (callerSignal?.aborted) return retired();
|
if (callerSignal?.aborted) return retired();
|
||||||
if (signal.aborted) return false;
|
if (signal.aborted) return retired();
|
||||||
if (!state.enabled) break;
|
if (!state.enabled) break;
|
||||||
// Switched off, or its map left, while it looked: try the next.
|
// Switched off, or its map left, while it looked: try the next.
|
||||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||||
|
|||||||
@@ -1,5 +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 { getEventListeners } from 'node:events';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createStreetLevelLayer } from './index.js';
|
import { createStreetLevelLayer } from './index.js';
|
||||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||||
@@ -1666,6 +1667,85 @@ test('openNearest keeps honouring the caller after the lookup: an abort while th
|
|||||||
assert.equal(layer.getUIState().street.loading, false);
|
assert.equal(layer.getUIState().street.loading, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const rejectsOnAbort of [true, false]) {
|
||||||
|
test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => {
|
||||||
|
const answer = deferred();
|
||||||
|
const provider = fakeProvider({
|
||||||
|
nearestImage: (_point, { signal }) => {
|
||||||
|
if (rejectsOnAbort)
|
||||||
|
signal.addEventListener('abort', () => answer.reject(signal.reason), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
return answer.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
const { fakeHost } =
|
||||||
|
await import('../../testSupport/googleStreetViewFakes.mjs');
|
||||||
|
layer.attachViewerHost(fakeHost());
|
||||||
|
assert.equal(
|
||||||
|
await layer.openImage('mapillary', 'existing', { frame: false }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = layer.openNearest(
|
||||||
|
{ lat: 1, lon: 2 },
|
||||||
|
{ frame: false, signal: controller.signal },
|
||||||
|
);
|
||||||
|
assert.equal(layer.getUIState().street.loading, true);
|
||||||
|
controller.abort();
|
||||||
|
if (!rejectsOnAbort) answer.resolve('late-image');
|
||||||
|
assert.equal(await pending, false);
|
||||||
|
const street = layer.getUIState().street;
|
||||||
|
assert.equal(
|
||||||
|
street.loading,
|
||||||
|
false,
|
||||||
|
'the cancelled lookup owns and clears the spinner',
|
||||||
|
);
|
||||||
|
assert.equal(street.open, true, 'the existing photo remains open');
|
||||||
|
assert.equal(street.error, null);
|
||||||
|
assert.deepEqual(
|
||||||
|
provider.calls.open,
|
||||||
|
['existing'],
|
||||||
|
'no replacement photo opens',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rejectsOnAbort of [true, false]) {
|
||||||
|
test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => {
|
||||||
|
let answer;
|
||||||
|
const provider = fakeProvider({
|
||||||
|
nearestImage: (_point, { signal }) => {
|
||||||
|
answer = deferred();
|
||||||
|
if (rejectsOnAbort)
|
||||||
|
signal.addEventListener('abort', () => answer.reject(signal.reason), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
return answer.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const pending = layer.openNearest(
|
||||||
|
{ lat: 1, lon: 2 },
|
||||||
|
{ frame: false, signal: lifetime.signal },
|
||||||
|
);
|
||||||
|
assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1);
|
||||||
|
layer.closeViewer();
|
||||||
|
if (!rejectsOnAbort) answer.resolve('late-image');
|
||||||
|
assert.equal(await pending, false);
|
||||||
|
assert.equal(
|
||||||
|
getEventListeners(lifetime.signal, 'abort').length,
|
||||||
|
0,
|
||||||
|
'closing the lookup releases its listener on the application lifetime',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(lifetime.signal.aborted, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => {
|
test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => {
|
||||||
const answers = [];
|
const answers = [];
|
||||||
const provider = fakeProvider({
|
const provider = fakeProvider({
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
|||||||
const expanded = shallowRef(null);
|
const expanded = shallowRef(null);
|
||||||
/** False on a page with neither element fullscreen nor popovers. */
|
/** False on a page with neither element fullscreen nor popovers. */
|
||||||
const expandAvailable = shallowRef(true);
|
const expandAvailable = shallowRef(true);
|
||||||
|
/** Whether the photo is shown over the globe on an embedded page. */
|
||||||
|
const embedOverlay = shallowRef(false);
|
||||||
/** @type {{handlers: StreetLevelHandlers}|null} */
|
/** @type {{handlers: StreetLevelHandlers}|null} */
|
||||||
let owner = null;
|
let owner = null;
|
||||||
|
|
||||||
@@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
|||||||
expandAvailable.value = Boolean(available);
|
expandAvailable.value = Boolean(available);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {boolean} on */
|
||||||
|
function setEmbedOverlay(on) {
|
||||||
|
embedOverlay.value = Boolean(on);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop forwarding actions to the owner holding `token`. The panel keeps
|
* Stop forwarding actions to the owner holding `token`. The panel keeps
|
||||||
* its last render, as the page did when its controls were wired by hand.
|
* its last render, as the page did when its controls were wired by hand.
|
||||||
@@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
|||||||
view,
|
view,
|
||||||
expanded,
|
expanded,
|
||||||
expandAvailable,
|
expandAvailable,
|
||||||
|
embedOverlay,
|
||||||
attach,
|
attach,
|
||||||
publish,
|
publish,
|
||||||
setExpanded,
|
setExpanded,
|
||||||
setExpandAvailable,
|
setExpandAvailable,
|
||||||
|
setEmbedOverlay,
|
||||||
detach,
|
detach,
|
||||||
act,
|
act,
|
||||||
describeSinceStop,
|
describeSinceStop,
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export class StreetLevelControls {
|
|||||||
const wrap = this._elements.viewerWrap;
|
const wrap = this._elements.viewerWrap;
|
||||||
if (!wrap || !document.body) return;
|
if (!wrap || !document.body) return;
|
||||||
const lift = open === true && isEmbedPage();
|
const lift = open === true && isEmbedPage();
|
||||||
|
this.store.setEmbedOverlay(lift);
|
||||||
if (lift && !this._embedHome) {
|
if (lift && !this._embedHome) {
|
||||||
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
|
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
|
||||||
document.body.appendChild(wrap);
|
document.body.appendChild(wrap);
|
||||||
|
|||||||
@@ -1038,3 +1038,36 @@ test('destroying the panel in embed mode puts an open photo back in the panel',
|
|||||||
assert.equal(overGlobe(dom), false);
|
assert.equal(overGlobe(dom), false);
|
||||||
assertSameNode(dom.wrap.parentNode, home);
|
assertSameNode(dom.wrap.parentNode, home);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test('an embedded photo failure is shown inside the overlay, and only there', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
dom.document.body.classList.add('ui-embed');
|
||||||
|
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
|
||||||
|
const failed = uiState({ open: true });
|
||||||
|
Object.assign(failed.street, {
|
||||||
|
imageId: null,
|
||||||
|
error: 'Image could not be opened',
|
||||||
|
});
|
||||||
|
await layer.publish(failed);
|
||||||
|
assert.equal(overGlobe(dom), true);
|
||||||
|
const error = dom.wrap.querySelector('#sl-viewer-error');
|
||||||
|
assert.ok(error, 'the overlay contains an error region');
|
||||||
|
assert.equal(error.hidden, false);
|
||||||
|
assert.match(error.textContent, /Image could not be opened/);
|
||||||
|
assert.equal(dom.root.querySelector('#sl-error').hidden, true);
|
||||||
|
await layer.publish(uiState({ open: true }));
|
||||||
|
assert.equal(error.hidden, true, 'a successful retry clears the error');
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('outside embed mode a photo failure stays in the panel error region', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const failed = uiState({ open: true });
|
||||||
|
failed.street.error = 'Image could not be opened';
|
||||||
|
const { controls } = await stubPanel(dom, failed);
|
||||||
|
const error = dom.wrap.querySelector('#sl-viewer-error');
|
||||||
|
assert.ok(error);
|
||||||
|
assert.equal(error.hidden, true, 'no duplicate error inside the photo');
|
||||||
|
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
@@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js';
|
|||||||
// Until it renders, the panel shows its initial markup. The viewer element
|
// Until it renders, the panel shows its initial markup. The viewer element
|
||||||
// belongs to the layer and EXPAND to the controls.
|
// belongs to the layer and EXPAND to the controls.
|
||||||
const store = useStreetLevelStore();
|
const store = useStreetLevelStore();
|
||||||
const { view, expanded, expandAvailable } = storeToRefs(store);
|
const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store);
|
||||||
|
|
||||||
const status = computed(
|
const status = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -142,6 +142,7 @@ const vThumb = {
|
|||||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
|
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
|
||||||
<div class="sl-viewer-stage">
|
<div class="sl-viewer-stage">
|
||||||
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
|
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
|
||||||
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
|
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
|
||||||
@@ -158,7 +159,7 @@ const vThumb = {
|
|||||||
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
|
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
|
||||||
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
|
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sl-error" class="sl-error" role="alert" :hidden="!view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
|
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
|
||||||
<!-- A missing key gates the filters only: the chips above stay
|
<!-- A missing key gates the filters only: the chips above stay
|
||||||
focusable so their tooltip can say how to add the key. -->
|
focusable so their tooltip can say how to add the key. -->
|
||||||
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
|
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
|
||||||
|
|||||||
+10
-2
@@ -3440,9 +3440,17 @@ export async function showStreetLevel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cockpit, clean view, recording and scene playback hide the panel the
|
// Cockpit, clean view, recording and scene playback hide the panel the
|
||||||
// photo opens in: say so before anything loads unseen.
|
// photo opens in: say so before anything loads unseen. Embed mode uses
|
||||||
|
// clean view for its globe but lifts the photo out of the hidden rail.
|
||||||
const body = globalThis.document?.body;
|
const body = globalThis.document?.body;
|
||||||
if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode)))
|
const embedded = body?.classList?.contains('ui-embed') === true;
|
||||||
|
if (
|
||||||
|
EXCLUSIVE_SURFACE_CLASSES.some(
|
||||||
|
(mode) =>
|
||||||
|
body?.classList?.contains(mode) &&
|
||||||
|
!(embedded && mode === 'ui-clean-view'),
|
||||||
|
)
|
||||||
|
)
|
||||||
return fail(
|
return fail(
|
||||||
'view',
|
'view',
|
||||||
msg(
|
msg(
|
||||||
|
|||||||
@@ -4668,3 +4668,25 @@ test('show_street_level in a view that hides its panel stops before anything loa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const saved = globalThis.document;
|
||||||
|
try {
|
||||||
|
for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) {
|
||||||
|
const classes = new Set(['ui-embed', 'ui-clean-view', mode]);
|
||||||
|
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
|
||||||
|
const { runner, order } = createStreetLevelHarness({ enabled: true });
|
||||||
|
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
|
||||||
|
if (mode) {
|
||||||
|
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
|
||||||
|
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
|
||||||
|
} else {
|
||||||
|
assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe');
|
||||||
|
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
globalThis.document = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user