mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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).
|
||||
@@ -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`,
|
||||
`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view,
|
||||
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
|
||||
newer turn retires the lookup
|
||||
(`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
|
||||
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
|
||||
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
|
||||
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,
|
||||
the assistant) while the link waited behind its own marks. A plain
|
||||
link never loads a photo from a source that bills every load
|
||||
|
||||
@@ -724,7 +724,7 @@ export function createStreetLevelLayer({
|
||||
if (nearestLookup !== lookup) return false;
|
||||
nearestLookup = null;
|
||||
// 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;
|
||||
notify();
|
||||
}
|
||||
@@ -751,13 +751,13 @@ export function createStreetLevelLayer({
|
||||
} catch (error) {
|
||||
if (callerSignal?.aborted) return retired();
|
||||
// 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.
|
||||
lastError = error;
|
||||
continue;
|
||||
}
|
||||
if (callerSignal?.aborted) return retired();
|
||||
if (signal.aborted) return false;
|
||||
if (signal.aborted) return retired();
|
||||
if (!state.enabled) break;
|
||||
// Switched off, or its map left, while it looked: try the next.
|
||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createStreetLevelLayer } from './index.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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const answers = [];
|
||||
const provider = fakeProvider({
|
||||
|
||||
@@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
||||
const expanded = shallowRef(null);
|
||||
/** False on a page with neither element fullscreen nor popovers. */
|
||||
const expandAvailable = shallowRef(true);
|
||||
/** Whether the photo is shown over the globe on an embedded page. */
|
||||
const embedOverlay = shallowRef(false);
|
||||
/** @type {{handlers: StreetLevelHandlers}|null} */
|
||||
let owner = null;
|
||||
|
||||
@@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
||||
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
|
||||
* 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,
|
||||
expanded,
|
||||
expandAvailable,
|
||||
embedOverlay,
|
||||
attach,
|
||||
publish,
|
||||
setExpanded,
|
||||
setExpandAvailable,
|
||||
setEmbedOverlay,
|
||||
detach,
|
||||
act,
|
||||
describeSinceStop,
|
||||
|
||||
@@ -318,6 +318,7 @@ export class StreetLevelControls {
|
||||
const wrap = this._elements.viewerWrap;
|
||||
if (!wrap || !document.body) return;
|
||||
const lift = open === true && isEmbedPage();
|
||||
this.store.setEmbedOverlay(lift);
|
||||
if (lift && !this._embedHome) {
|
||||
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
|
||||
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);
|
||||
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
|
||||
// belongs to the layer and EXPAND to the controls.
|
||||
const store = useStreetLevelStore();
|
||||
const { view, expanded, expandAvailable } = storeToRefs(store);
|
||||
const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store);
|
||||
|
||||
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>
|
||||
</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 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>
|
||||
@@ -158,7 +159,7 @@ const vThumb = {
|
||||
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></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
|
||||
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">
|
||||
|
||||
+10
-2
@@ -3440,9 +3440,17 @@ export async function showStreetLevel(
|
||||
}
|
||||
|
||||
// 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;
|
||||
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(
|
||||
'view',
|
||||
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