mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the Street Level panel from a store
The panel's markup moves, verbatim, into StreetLevelPanel, which renders the presentation StreetLevelControls publishes to the gev-street-level store, with the EXPAND state. The controls keep their listeners, full screen and popover handling, focus moves, resizing and transitions. The SINCE readout follows the thumb while dragging and shows the recorded window once the thumb is there; the thumb never moves under the user's hand. Text the panel now renders itself drops its data-i18n markers. Panels lifted out of a view live in src/ui/views/panels/, kept out of Prettier like the views.
This commit is contained in:
1 parent
3a2acf9b0f
commit
e6aee52ea0
8 files changed
+500
-528
No files matched your search
+3
-1
@@ -21,5 +21,7 @@ node_modules
|
||||
.vite-*
|
||||
*.min.js
|
||||
|
||||
# Application views hold the page markup verbatim; reflowing it changes the DOM
|
||||
# Application views, and panels lifted from them, hold the page markup
|
||||
# verbatim; reflowing it changes the DOM
|
||||
src/ui/views/*View.vue
|
||||
src/ui/views/panels/
|
||||
@@ -84,7 +84,9 @@ They contain no application state.
|
||||
`SceneShotList` over the `gev-scenes` store), Weather (done: the owner
|
||||
renders `WeatherReadout` from the view it computes), Recent imagery
|
||||
(done: `RecentImageryReadout` and its day cards, the same way),
|
||||
Street level, CCTV, Layers, Display, Context and Radio, the command dock,
|
||||
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||
store that `StreetLevelControls` publishes to; the controls keep their
|
||||
listeners, EXPAND, focus and resizing), CCTV, Layers, Display, Context and Radio, the command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
@@ -100,6 +102,12 @@ They contain no application state.
|
||||
`@vue/test-utils` and `happy-dom`. `npm test` registers the loader; to run
|
||||
one file that reaches a component (including anything importing the
|
||||
shell), use `node --import=./build/registerVue.js --test <file>`.
|
||||
- Never pass happy-dom nodes to `assert.equal` or `deepEqual`: a failure
|
||||
prints both operands, and a node reaches the whole page and window, which
|
||||
runs the process out of memory. Compare nodes with `assertSameNode()`
|
||||
(`src/testSupport/domAssert.mjs`).
|
||||
- Panels lifted out of a view keep its verbatim markup in
|
||||
`src/ui/views/panels/`, which Prettier leaves alone like the views.
|
||||
- Visual parity: before/after screenshots of every panel at desktop and phone
|
||||
widths, in the default and Cyber themes, with the rail as the only expected
|
||||
difference.
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/views/panels/StreetLevelPanel.vue",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -944,7 +947,8 @@
|
||||
"src/ui/rail/utcClock.js",
|
||||
"src/ui/rail/RailTimeline.vue",
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/weather/WeatherReadout.vue"
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/stores/streetLevel.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia"]
|
||||
},
|
||||
@@ -2909,7 +2913,8 @@
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/recentImagery/RecentImageryCard.vue",
|
||||
"src/ui/recentImagery/RecentImageryReadout.vue"
|
||||
"src/ui/recentImagery/RecentImageryReadout.vue",
|
||||
"src/ui/stores/streetLevel.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* @typedef {object} StreetLevelHandlers
|
||||
* @property {(stop: number) => void} setSinceStop Record a SINCE stop.
|
||||
* @property {(stop: number) => string} describeSinceStop A stop's readout.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Street Level panel as its view renders it. StreetLevelControls owns
|
||||
* the presentation (`presentStreetLevelPanel()`), the viewer and EXPAND: it
|
||||
* publishes here and attaches the actions the view forwards.
|
||||
*/
|
||||
export const useStreetLevelStore = defineStore('gev-street-level', () => {
|
||||
/** The presented panel; null until an owner renders, then its last render. */
|
||||
const view = shallowRef(null);
|
||||
/** Whether EXPAND has the viewer full screen; null until first synced. */
|
||||
const expanded = shallowRef(null);
|
||||
/** False on a page with neither element fullscreen nor popovers. */
|
||||
const expandAvailable = shallowRef(true);
|
||||
/** @type {{handlers: StreetLevelHandlers}|null} */
|
||||
let owner = null;
|
||||
|
||||
/**
|
||||
* Present the panel for an owner, replacing any earlier owner.
|
||||
* @param {StreetLevelHandlers} handlers
|
||||
* @returns {object} Token for detach().
|
||||
*/
|
||||
function attach(handlers) {
|
||||
owner = { handlers };
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** @param {object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = markRaw(next);
|
||||
}
|
||||
|
||||
/** @param {boolean} on */
|
||||
function setExpanded(on) {
|
||||
expanded.value = Boolean(on);
|
||||
}
|
||||
|
||||
/** @param {boolean} available */
|
||||
function setExpandAvailable(available) {
|
||||
expandAvailable.value = Boolean(available);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function detach(token) {
|
||||
if (owner === token) owner = null;
|
||||
}
|
||||
|
||||
/** Forward a view action to the owner. */
|
||||
function act(name, ...args) {
|
||||
owner?.handlers[name]?.(...args);
|
||||
}
|
||||
|
||||
/** The readout of a SINCE stop while its thumb is dragged. */
|
||||
function describeSinceStop(stop) {
|
||||
return owner?.handlers.describeSinceStop?.(stop) ?? '';
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
expanded,
|
||||
expandAvailable,
|
||||
attach,
|
||||
publish,
|
||||
setExpanded,
|
||||
setExpandAvailable,
|
||||
detach,
|
||||
act,
|
||||
describeSinceStop,
|
||||
};
|
||||
});
|
||||
+33
-157
@@ -1,37 +1,39 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
syncSegmentedControl,
|
||||
} from './segmentedControl.js';
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
} from './streetLevelPresentation.js';
|
||||
import { useStreetLevelStore } from './stores/streetLevel.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
|
||||
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
|
||||
|
||||
// Renders run on every layer notification, and the rail's MutationObserver
|
||||
// relays out on each attribute write: write only what changed.
|
||||
const setProp = (node, key, value) => {
|
||||
if (node && node[key] !== value) node[key] = value;
|
||||
};
|
||||
const setAttr = (node, key, value) => {
|
||||
if (node && node.getAttribute(key) !== value) node.setAttribute(key, value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills the Street Level panel body: provider chips, filters and viewer. The
|
||||
* Drives the Street Level panel body: provider chips, filters and viewer.
|
||||
* The panel view renders what this presents (`gev-street-level` store); the
|
||||
* panel chrome itself belongs to the application shell.
|
||||
*/
|
||||
export class StreetLevelControls {
|
||||
constructor({ root, layer, actions }) {
|
||||
constructor({
|
||||
root,
|
||||
layer,
|
||||
actions,
|
||||
store = useStreetLevelStore(applicationPinia()),
|
||||
}) {
|
||||
this.root = root;
|
||||
this.layer = layer;
|
||||
this.actions = actions;
|
||||
this.store = store;
|
||||
this._storeToken = store.attach({
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
setSinceStop: (stop) =>
|
||||
this._setParams({ sinceDays: SINCE_STOPS[stop]?.days ?? 0 }),
|
||||
describeSinceStop: (stop) => t(SINCE_STOPS[stop]?.label ?? ''),
|
||||
});
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
this._unsubscribe = null;
|
||||
@@ -56,23 +58,10 @@ export class StreetLevelControls {
|
||||
status: byId('sl-status'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
nearestBtn: byId('sl-nearest-btn'),
|
||||
controls: byId('sl-controls'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
panoRow: byId('sl-pano-row'),
|
||||
sinceRow: byId('sl-since-row'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
sinceNote: byId('sl-since-note'),
|
||||
viewerWrap: byId('sl-viewer-wrap'),
|
||||
viewerExpand: byId('sl-viewer-expand'),
|
||||
viewerClose: byId('sl-viewer-close'),
|
||||
viewerPlaceholder: byId('sl-viewer-placeholder'),
|
||||
viewer: byId('sl-viewer'),
|
||||
imageBy: byId('sl-image-by'),
|
||||
imageWhen: byId('sl-image-when'),
|
||||
imageLink: byId('sl-image-link'),
|
||||
coverageMeta: byId('sl-coverage-meta'),
|
||||
panoButtons: all('[data-sl-pano]'),
|
||||
renderButtons: all('[data-sl-render]'),
|
||||
};
|
||||
@@ -111,17 +100,6 @@ export class StreetLevelControls {
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
});
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
this.listen(el.sinceRange, 'input', () => {
|
||||
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
|
||||
if (el.sinceLabel) el.sinceLabel.textContent = label;
|
||||
el.sinceRange.setAttribute('aria-valuetext', label);
|
||||
});
|
||||
this.listen(el.sinceRange, 'change', () =>
|
||||
this._setParams({ sinceDays: sinceDays() }),
|
||||
);
|
||||
this.listen(el.nearestBtn, 'click', () => {
|
||||
const nearest = this._view?.nearest;
|
||||
if (!nearest?.visible || nearest.disabled) return;
|
||||
@@ -157,7 +135,8 @@ export class StreetLevelControls {
|
||||
event.stopPropagation();
|
||||
this._exitExpanded();
|
||||
});
|
||||
} else setProp(el.viewerExpand, 'hidden', true);
|
||||
}
|
||||
this.store.setExpandAvailable(Boolean(this._expandMode));
|
||||
createSegmentedControl({
|
||||
buttons: el.renderButtons,
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
@@ -324,21 +303,7 @@ export class StreetLevelControls {
|
||||
_syncExpanded() {
|
||||
const on = this.isViewerExpanded();
|
||||
this._watchPanelHidingModes(on);
|
||||
const button = this._elements.viewerExpand;
|
||||
if (button) {
|
||||
setProp(
|
||||
button.querySelector('.sl-btn-icon'),
|
||||
'textContent',
|
||||
on ? '⤡' : '⤢',
|
||||
);
|
||||
setProp(
|
||||
button.querySelector('.sl-btn-text'),
|
||||
'textContent',
|
||||
on ? t('SHRINK') : t('EXPAND'),
|
||||
);
|
||||
setAttr(button, 'aria-pressed', String(on));
|
||||
setAttr(button, 'aria-label', on ? t('Shrink') : t('Expand'));
|
||||
}
|
||||
this.store.setExpanded(on);
|
||||
this._requestResize();
|
||||
}
|
||||
|
||||
@@ -366,110 +331,20 @@ export class StreetLevelControls {
|
||||
this._state = state;
|
||||
const view = presentStreetLevelPanel(state);
|
||||
this._view = view;
|
||||
this._renderHeader(view);
|
||||
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
|
||||
syncChipGroup(this._elements.providerChips, view.providers);
|
||||
this._renderError(view);
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view);
|
||||
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
||||
this._renderNearest(view);
|
||||
this._leaveClosedViewer(view);
|
||||
this.store.publish(view);
|
||||
this._reactToTransitions(view);
|
||||
}
|
||||
|
||||
_renderHeader(view) {
|
||||
const el = this._elements;
|
||||
setProp(this.root.dataset, 'slEnabled', String(view.enabled));
|
||||
if (el.status) {
|
||||
setProp(el.status, 'textContent', view.status.text);
|
||||
setProp(
|
||||
el.status,
|
||||
'className',
|
||||
`sl-status${view.status.tone ? ` is-${view.status.tone}` : ''}`,
|
||||
);
|
||||
setAttr(el.status, 'aria-pressed', String(view.status.pressed));
|
||||
setProp(el.status, 'title', view.status.title);
|
||||
}
|
||||
}
|
||||
|
||||
_renderError(view) {
|
||||
const el = this._elements;
|
||||
if (!el.error) return;
|
||||
setProp(el.error, 'hidden', !view.error);
|
||||
setProp(el.errorText, 'textContent', view.error || '');
|
||||
}
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
setProp(el.panoRow, 'hidden', !shows.pano);
|
||||
setProp(el.sinceRow, 'hidden', !shows.since);
|
||||
// A lit source that dates by upload says so under the slider.
|
||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
syncSegmentedControl(el.panoButtons, view.filter.pano, {
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
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);
|
||||
if (
|
||||
document.activeElement !== el.sinceRange &&
|
||||
el.sinceRange.value !== index
|
||||
)
|
||||
el.sinceRange.value = index;
|
||||
if (el.sinceRange.value === index) {
|
||||
setProp(el.sinceLabel, 'textContent', view.since.label);
|
||||
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_renderViewer(view) {
|
||||
const el = this._elements;
|
||||
const { viewer } = view;
|
||||
const wrap = el.viewerWrap;
|
||||
if (!viewer.open) this._exitExpanded();
|
||||
if (wrap) {
|
||||
// Closing the image (its × button) hides the wrap: move focus out
|
||||
// first, or it drops to <body>.
|
||||
if (!viewer.open && !wrap.hidden && wrap.contains(document.activeElement))
|
||||
el.status?.focus?.({ preventScroll: true });
|
||||
setProp(wrap, 'hidden', !viewer.open);
|
||||
}
|
||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
if (!viewer.open) return;
|
||||
setProp(el.imageBy, 'textContent', viewer.captionLeft);
|
||||
setProp(el.imageWhen, 'textContent', viewer.captionRight);
|
||||
if (el.imageLink) {
|
||||
setProp(el.imageLink, 'hidden', !viewer.link);
|
||||
if (viewer.link) setAttr(el.imageLink, 'href', viewer.link);
|
||||
if (viewer.linkLabel)
|
||||
setProp(el.imageLink, 'textContent', viewer.linkLabel);
|
||||
}
|
||||
}
|
||||
|
||||
_renderNearest(view) {
|
||||
const button = this._elements.nearestBtn;
|
||||
if (!button) return;
|
||||
const { nearest } = view;
|
||||
setProp(button, 'hidden', !nearest.visible);
|
||||
if (!nearest.visible) return;
|
||||
setProp(button, 'textContent', nearest.label);
|
||||
setProp(button, 'title', nearest.title);
|
||||
setProp(button, 'disabled', nearest.disabled);
|
||||
/** A closed image leaves EXPAND, and focus stays in the panel. */
|
||||
_leaveClosedViewer(view) {
|
||||
if (view.viewer.open) return;
|
||||
this._exitExpanded();
|
||||
// Closing the image (its × button) hides the wrap: move focus out
|
||||
// first, or it drops to <body>.
|
||||
const wrap = this._elements.viewerWrap;
|
||||
if (wrap && !wrap.hidden && wrap.contains(document.activeElement))
|
||||
this._elements.status?.focus?.({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,5 +383,6 @@ export class StreetLevelControls {
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this.layer.attachViewerHost?.(null);
|
||||
this.store.detach(this._storeToken);
|
||||
}
|
||||
}
|
||||
+194
-298
@@ -1,7 +1,13 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { StreetLevelControls } from './streetLevelControls.js';
|
||||
import { useStreetLevelStore } from './stores/streetLevel.js';
|
||||
import StreetLevelPanel from './views/panels/StreetLevelPanel.vue';
|
||||
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
@@ -16,214 +22,53 @@ import {
|
||||
providerSnapshot,
|
||||
} from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
||||
|
||||
/** Every write a page would see as a mutation, even one to the same value. */
|
||||
const mutations = { count: 0 };
|
||||
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
|
||||
|
||||
class FakeNode {
|
||||
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
|
||||
this.listeners = new Map();
|
||||
this.ownerDocument = document;
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.id = id;
|
||||
this.dataset = { ...dataset };
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.props = {
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
title: '',
|
||||
value: '',
|
||||
};
|
||||
for (const key of REFLECTED)
|
||||
Object.defineProperty(this, key, {
|
||||
get: () => this.props[key],
|
||||
set: (value) => {
|
||||
mutations.count++;
|
||||
this.props[key] = value;
|
||||
},
|
||||
});
|
||||
this.attributes = new Map();
|
||||
const names = new Set(classes);
|
||||
this.classList = {
|
||||
contains: (name) => names.has(name),
|
||||
toggle: (name, force) => {
|
||||
const on = force ?? !names.has(name);
|
||||
if (on) names.add(name);
|
||||
else names.delete(name);
|
||||
return on;
|
||||
},
|
||||
};
|
||||
const style = {};
|
||||
this.style = Object.assign(style, {
|
||||
setProperty: (key, value) => {
|
||||
style[key] = value;
|
||||
},
|
||||
removeProperty: (key) => {
|
||||
delete style[key];
|
||||
},
|
||||
});
|
||||
Object.defineProperty(this, 'className', {
|
||||
get: () => [...names].join(' '),
|
||||
set: (value) => {
|
||||
mutations.count++;
|
||||
names.clear();
|
||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||
names.add(name);
|
||||
},
|
||||
});
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
setAttribute(key, value) {
|
||||
mutations.count++;
|
||||
this.attributes.set(key, String(value));
|
||||
}
|
||||
getAttribute(key) {
|
||||
return this.attributes.get(key) ?? null;
|
||||
}
|
||||
removeAttribute(key) {
|
||||
mutations.count++;
|
||||
this.attributes.delete(key);
|
||||
}
|
||||
appendChild(child) {
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.appendChild(node);
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
this.children = [];
|
||||
this.append(...nodes);
|
||||
}
|
||||
contains(node) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
if (current === this) return true;
|
||||
return false;
|
||||
}
|
||||
closest(selector) {
|
||||
for (let current = this; current; current = current.parent)
|
||||
if (current.matches?.(selector)) return current;
|
||||
return null;
|
||||
}
|
||||
insertBefore(child, before) {
|
||||
child.parent?.children.splice(child.parent.children.indexOf(child), 1);
|
||||
child.parent = this;
|
||||
const at = before ? this.children.indexOf(before) : -1;
|
||||
if (at === -1) this.children.push(child);
|
||||
else this.children.splice(at, 0, child);
|
||||
return child;
|
||||
}
|
||||
remove() {
|
||||
this.parent?.children.splice(this.parent.children.indexOf(this), 1);
|
||||
this.parent = null;
|
||||
}
|
||||
focus() {
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
*walk() {
|
||||
for (const child of this.children) {
|
||||
yield child;
|
||||
yield* child.walk();
|
||||
}
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
/** `#id`, `.class` or `[data-x]`. */
|
||||
matches(selector) {
|
||||
const byId = /^#(.+)$/.exec(selector);
|
||||
if (byId) return this.id === byId[1];
|
||||
const byClass = /^\.(.+)$/.exec(selector);
|
||||
if (byClass) return this.classList.contains(byClass[1]);
|
||||
const byData = /^\[data-([a-z-]+)\]$/.exec(selector);
|
||||
return Boolean(
|
||||
byData &&
|
||||
byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset,
|
||||
);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return [...this.walk()].filter((node) => node.matches(selector));
|
||||
}
|
||||
addEventListener(type, listener, { signal } = {}) {
|
||||
if (signal?.aborted) return;
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(listener);
|
||||
signal?.addEventListener('abort', () =>
|
||||
this.listeners.get(type)?.delete(listener),
|
||||
);
|
||||
}
|
||||
/** Deliver to this node, then bubble up with the same target. */
|
||||
dispatchEvent(event) {
|
||||
const delivered = {
|
||||
type: event.type,
|
||||
target: this,
|
||||
key: event.key,
|
||||
newState: event.newState,
|
||||
stopped: false,
|
||||
preventDefault() {},
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
for (
|
||||
let node = this;
|
||||
node && !delivered.stopped;
|
||||
node = event.bubbles ? node.parent : null
|
||||
)
|
||||
for (const listener of [...(node.listeners.get(event.type) || [])])
|
||||
listener(delivered);
|
||||
return true;
|
||||
}
|
||||
click() {
|
||||
this.dispatchEvent({ type: 'click', bubbles: true });
|
||||
}
|
||||
}
|
||||
/* ── The panel view, mounted on a happy-dom page ───────────────────────── */
|
||||
|
||||
/**
|
||||
* The Street Level panel's body, as layer-panels.html lays it out, on a page
|
||||
* with the Fullscreen API unless `fullscreen` is false, and the Popover API
|
||||
* if `popover` is true (iPhone Safari: popovers, no element fullscreen).
|
||||
* The Street Level panel as the page renders it, on a page with the
|
||||
* Fullscreen API unless `fullscreen` is false, and the Popover API if
|
||||
* `popover` is true (iPhone Safari: popovers, no element fullscreen).
|
||||
*/
|
||||
function panelDom({ fullscreen = true, popover = false } = {}) {
|
||||
const document = new FakeNode(null, '#document');
|
||||
Object.assign(document, {
|
||||
ownerDocument: document,
|
||||
activeElement: null,
|
||||
fullscreenElement: null,
|
||||
createElement: (tag) => new FakeNode(document, tag),
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
document.body.className = '';
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(StreetLevelPanel).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useStreetLevelStore(pinia);
|
||||
const root = document.getElementById('street-level-panel');
|
||||
const wrap = document.getElementById('sl-viewer-wrap');
|
||||
// happy-dom lays nothing out: the viewer has a size, as on a page.
|
||||
sized(document.getElementById('sl-viewer'), 640, 400);
|
||||
// The browser reports a change after the request settles.
|
||||
let fullscreenElement = null;
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => fullscreenElement,
|
||||
});
|
||||
const fullscreenTo = (element) => {
|
||||
document.fullscreenElement = element;
|
||||
setTimeout(() => document.dispatchEvent({ type: 'fullscreenchange' }), 0);
|
||||
fullscreenElement = element;
|
||||
setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
|
||||
};
|
||||
document.exitFullscreen = async () => fullscreenTo(null);
|
||||
document.body = document.appendChild(new FakeNode(document, 'body'));
|
||||
const root = document.body.appendChild(
|
||||
new FakeNode(document, 'section', { id: 'street-level-panel' }),
|
||||
);
|
||||
const add = (parent, tag, options) =>
|
||||
parent.appendChild(new FakeNode(document, tag, options));
|
||||
add(root, 'button', { id: 'sl-status' });
|
||||
add(root, 'div', { id: 'sl-provider-chips' });
|
||||
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
|
||||
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
|
||||
wrap.requestFullscreen = fullscreen ? async () => fullscreenTo(wrap) : null;
|
||||
wrap.showPopover = null;
|
||||
wrap.hidePopover = null;
|
||||
if (popover) {
|
||||
// `popover` reflects its attribute, as in the DOM.
|
||||
Object.defineProperty(wrap, 'popover', {
|
||||
configurable: true,
|
||||
get: () => wrap.getAttribute('popover'),
|
||||
set: (value) => wrap.setAttribute('popover', value),
|
||||
});
|
||||
// The browser fires `toggle` after the popover opens or closes.
|
||||
const toggleTo = (newState) =>
|
||||
setTimeout(() => wrap.dispatchEvent({ type: 'toggle', newState }), 0);
|
||||
setTimeout(() => {
|
||||
const event = new Event('toggle');
|
||||
event.newState = newState;
|
||||
wrap.dispatchEvent(event);
|
||||
}, 0);
|
||||
wrap.showPopover = () => {
|
||||
wrap.popoverOpen = true;
|
||||
toggleTo('open');
|
||||
@@ -233,51 +78,47 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
||||
toggleTo('closed');
|
||||
};
|
||||
}
|
||||
const expand = add(wrap, 'button', { id: 'sl-viewer-expand' });
|
||||
add(expand, 'span', { classes: ['sl-btn-icon'] });
|
||||
add(expand, 'span', { classes: ['sl-btn-text'] });
|
||||
const fit = add(wrap, 'div', { classes: ['sl-seg'] });
|
||||
for (const mode of ['letterbox', 'fill'])
|
||||
add(fit, 'button', { dataset: { slRender: mode } });
|
||||
add(wrap, 'button', { id: 'sl-viewer-close' });
|
||||
for (const id of [
|
||||
'sl-viewer-placeholder',
|
||||
'sl-viewer',
|
||||
'sl-image-by',
|
||||
'sl-image-when',
|
||||
'sl-image-link',
|
||||
])
|
||||
add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id });
|
||||
const error = add(root, 'div', { id: 'sl-error' });
|
||||
add(error, 'span', { id: 'sl-error-text' });
|
||||
const controls = add(root, 'fieldset', { id: 'sl-controls' });
|
||||
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(panoRow, 'button', { dataset: { slPano: pano } });
|
||||
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
||||
add(sinceRow, 'input', { id: 'sl-since' });
|
||||
add(sinceRow, 'output', { id: 'sl-since-label' });
|
||||
add(sinceRow, 'small', { id: 'sl-since-note' });
|
||||
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
add(metaRow, 'button', { id: 'sl-nearest-btn' });
|
||||
return { document, root, wrap };
|
||||
// Every DOM write the rail's MutationObserver would see.
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(root, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
return {
|
||||
document,
|
||||
root,
|
||||
wrap,
|
||||
store,
|
||||
writes: () => observer.takeRecords().length,
|
||||
unmount() {
|
||||
observer.disconnect();
|
||||
app.unmount();
|
||||
delete document.fullscreenElement;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run `fn` with the fake document and an immediate animation frame. */
|
||||
/** Give an element the layout size happy-dom never computes. */
|
||||
function sized(element, width, height) {
|
||||
for (const [key, value] of [
|
||||
['clientWidth', width],
|
||||
['clientHeight', height],
|
||||
])
|
||||
Object.defineProperty(element, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
/** Run `fn` on a fresh panel with an immediate animation frame. */
|
||||
async function withDom(fn, options) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
const saved = globalThis.requestAnimationFrame;
|
||||
const page = panelDom(options);
|
||||
globalThis.document = page.document;
|
||||
globalThis.requestAnimationFrame = (task) => setTimeout(task, 0);
|
||||
try {
|
||||
return await fn(page);
|
||||
} finally {
|
||||
globalThis.document = saved.document;
|
||||
globalThis.requestAnimationFrame = saved.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = saved;
|
||||
page.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +177,7 @@ async function productionPanel(dom) {
|
||||
const controls = new StreetLevelControls({
|
||||
root: dom.root,
|
||||
layer,
|
||||
store: dom.store,
|
||||
actions: {
|
||||
isEnabled: () => manager.isEnabled('street-level'),
|
||||
setEnabled: (on) =>
|
||||
@@ -403,9 +245,11 @@ function stubLayer() {
|
||||
const calls = { resize: 0 };
|
||||
return {
|
||||
calls,
|
||||
/** Notify, then wait for the panel to render. */
|
||||
publish(next) {
|
||||
state = next;
|
||||
for (const listener of listeners) listener(state);
|
||||
return nextTick();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
@@ -445,16 +289,17 @@ function uiState({
|
||||
};
|
||||
}
|
||||
|
||||
function stubPanel(dom, state, extraActions = {}) {
|
||||
async function stubPanel(dom, state, extraActions = {}) {
|
||||
const layer = stubLayer();
|
||||
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
||||
let enabled = state.enabled;
|
||||
/** Reports a switch-on request's origin, as the data manager does. */
|
||||
let announce = null;
|
||||
layer.publish(state);
|
||||
await layer.publish(state);
|
||||
const controls = new StreetLevelControls({
|
||||
root: dom.root,
|
||||
layer,
|
||||
store: dom.store,
|
||||
actions: {
|
||||
isEnabled: () => enabled,
|
||||
setEnabled: async (on) => {
|
||||
@@ -475,6 +320,7 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
},
|
||||
});
|
||||
controls.connect();
|
||||
await nextTick();
|
||||
return {
|
||||
layer,
|
||||
calls,
|
||||
@@ -488,7 +334,7 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
test('the header pill switches the layer; provider switches are left alone', () =>
|
||||
withDom(async (dom) => {
|
||||
const status = dom.root.querySelector('#sl-status');
|
||||
const { calls, controls } = stubPanel(dom, uiState());
|
||||
const { calls, controls } = await stubPanel(dom, uiState());
|
||||
status.click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setEnabled, [false]);
|
||||
@@ -498,7 +344,7 @@ test('the header pill switches the layer; provider switches are left alone', ()
|
||||
|
||||
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(dom, uiState());
|
||||
const { calls, chip, controls } = await stubPanel(dom, uiState());
|
||||
assert.equal(chip().dataset.chipId, 'mapillary');
|
||||
assert.equal(chip().getAttribute('aria-pressed'), 'true');
|
||||
chip().click();
|
||||
@@ -510,7 +356,7 @@ test('darkening the only lit chip turns the layer off and keeps the provider swi
|
||||
|
||||
test('lighting a dark chip switches the provider on as a user params request, then the layer', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
const { calls, chip, controls } = await stubPanel(
|
||||
dom,
|
||||
uiState({ enabled: false, on: false }),
|
||||
);
|
||||
@@ -526,7 +372,7 @@ test('lighting a dark chip switches the provider on as a user params request, th
|
||||
|
||||
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
const { calls, chip, controls } = await stubPanel(
|
||||
dom,
|
||||
uiState({ keyRequired: true }),
|
||||
);
|
||||
@@ -546,7 +392,7 @@ test('under KEY REQUIRED the provider chip stays live and explains the key inste
|
||||
|
||||
test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ keyRequired: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ keyRequired: true }));
|
||||
assert.equal(dom.root.querySelector('#sl-controls').disabled, true);
|
||||
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
|
||||
assert.match(
|
||||
@@ -558,18 +404,18 @@ test('under KEY REQUIRED the filters are gated and the error line says how to ad
|
||||
|
||||
test('the viewer is resized once when it opens, not on every render', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState());
|
||||
const { layer, controls } = await stubPanel(dom, uiState());
|
||||
await settle();
|
||||
assert.equal(layer.calls.resize, 0);
|
||||
layer.publish(uiState({ open: true }));
|
||||
await layer.publish(uiState({ open: true }));
|
||||
await settle();
|
||||
// Opening also opens the panel; the two share one coalesced resize.
|
||||
assert.equal(layer.calls.resize, 1, 'once on open');
|
||||
for (let i = 0; i < 5; i++) layer.publish(uiState({ open: true }));
|
||||
for (let i = 0; i < 5; i++) await layer.publish(uiState({ open: true }));
|
||||
await settle();
|
||||
assert.equal(layer.calls.resize, 1, 'not again while it stays open');
|
||||
layer.publish(uiState({ open: false }));
|
||||
layer.publish(uiState({ open: true }));
|
||||
await layer.publish(uiState({ open: false }));
|
||||
await layer.publish(uiState({ open: true }));
|
||||
await settle();
|
||||
assert.equal(layer.calls.resize, 2, 'the next photo resizes again');
|
||||
controls.destroy();
|
||||
@@ -577,11 +423,11 @@ test('the viewer is resized once when it opens, not on every render', () =>
|
||||
|
||||
test('closing the image from its × button leaves focus on the panel, not <body>', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-close').focus();
|
||||
layer.publish(uiState({ open: false }));
|
||||
await layer.publish(uiState({ open: false }));
|
||||
assert.equal(dom.wrap.hidden, true);
|
||||
assert.equal(
|
||||
assertSameNode(
|
||||
dom.document.activeElement,
|
||||
dom.root.querySelector('#sl-status'),
|
||||
);
|
||||
@@ -600,55 +446,59 @@ const expandLabel = (dom) => {
|
||||
|
||||
test('EXPAND puts the viewer full screen in place, and the button shrinks it again', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
await settle();
|
||||
const resizes = layer.calls.resize;
|
||||
const expand = dom.root.querySelector('#sl-viewer-expand');
|
||||
expand.click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||
assertSameNode(dom.document.fullscreenElement, dom.wrap);
|
||||
assert.equal(dom.root.contains(dom.wrap), true, 'never leaves the panel');
|
||||
assert.deepEqual(expandLabel(dom), ['SHRINK', 'true']);
|
||||
assert.equal(layer.calls.resize, resizes + 1, 'the viewer refits');
|
||||
expand.click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
assertSameNode(dom.document.fullscreenElement, null);
|
||||
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('Esc in full screen shrinks the photo; the panel never sees it', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
const expand = dom.root.querySelector('#sl-viewer-expand');
|
||||
expand.click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||
assertSameNode(dom.document.fullscreenElement, dom.wrap);
|
||||
let panelEsc = 0;
|
||||
dom.root.addEventListener('keydown', () => panelEsc++);
|
||||
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
|
||||
expand.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
assertSameNode(dom.document.fullscreenElement, null);
|
||||
assert.equal(panelEsc, 0, 'the panel stays open');
|
||||
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
|
||||
// Not expanded, Esc is the panel's again.
|
||||
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
|
||||
expand.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
assert.equal(panelEsc, 1);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('an image closed while full screen leaves full screen', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
layer.publish(uiState({ open: false }));
|
||||
await layer.publish(uiState({ open: false }));
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
assertSameNode(dom.document.fullscreenElement, null);
|
||||
assert.equal(dom.wrap.hidden, true);
|
||||
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
|
||||
controls.destroy();
|
||||
@@ -657,7 +507,7 @@ test('an image closed while full screen leaves full screen', () =>
|
||||
test('without the Fullscreen or Popover API there is no EXPAND button', () =>
|
||||
withDom(
|
||||
async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
assert.equal(dom.root.querySelector('#sl-viewer-expand').hidden, true);
|
||||
controls.destroy();
|
||||
},
|
||||
@@ -667,7 +517,7 @@ test('without the Fullscreen or Popover API there is no EXPAND button', () =>
|
||||
test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover over the page', () =>
|
||||
withDom(
|
||||
async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
const expand = dom.root.querySelector('#sl-viewer-expand');
|
||||
assert.equal(expand.hidden, false, 'offered');
|
||||
expand.click();
|
||||
@@ -679,7 +529,9 @@ test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover ove
|
||||
// Esc shrinks the photo; the panel's own Esc (collapse) never sees it.
|
||||
let panelEsc = 0;
|
||||
dom.root.addEventListener('keydown', () => panelEsc++);
|
||||
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
|
||||
expand.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.wrap.popoverOpen, false);
|
||||
@@ -707,7 +559,7 @@ test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover ove
|
||||
test('destroying the panel while the iPhone popover is open leaves no popover behind', () =>
|
||||
withDom(
|
||||
async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
await settle();
|
||||
@@ -752,17 +604,17 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
|
||||
const watch = fakeMutationObserver();
|
||||
globalThis.MutationObserver = watch.FakeObserver;
|
||||
try {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||
assertSameNode(dom.document.fullscreenElement, dom.wrap);
|
||||
assert.equal(watch.observers.size, 1, 'watching while expanded');
|
||||
dom.document.body.classList.toggle(mode, true);
|
||||
watch.fire();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
assertSameNode(dom.document.fullscreenElement, null);
|
||||
assert.equal(watch.observers.size, 0, 'and not after');
|
||||
controls.destroy();
|
||||
} finally {
|
||||
@@ -774,18 +626,18 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
|
||||
|
||||
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, calls, controls, announce, subscribed } = stubPanel(
|
||||
const { layer, calls, controls, announce, subscribed } = await stubPanel(
|
||||
dom,
|
||||
uiState({ enabled: false }),
|
||||
);
|
||||
announce('restore');
|
||||
layer.publish(uiState({ enabled: true }));
|
||||
await layer.publish(uiState({ enabled: true }));
|
||||
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
|
||||
layer.publish(uiState({ enabled: false }));
|
||||
await layer.publish(uiState({ enabled: false }));
|
||||
announce('user');
|
||||
layer.publish(uiState({ enabled: true }));
|
||||
await layer.publish(uiState({ enabled: true }));
|
||||
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
||||
layer.publish(uiState({ enabled: true, open: true }));
|
||||
await layer.publish(uiState({ enabled: true, open: true }));
|
||||
assert.equal(calls.collapsed.length, 2, 'a photo opening opens it too');
|
||||
controls.destroy();
|
||||
assert.equal(subscribed(), false, 'destroy unsubscribes');
|
||||
@@ -793,8 +645,8 @@ test('only an explicit switch-on opens the panel, unstored; a restore never does
|
||||
|
||||
test('FIT / FILL show the selected render mode (P3)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
layer.publish(uiState({ open: true, renderMode: 'fill' }));
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
await layer.publish(uiState({ open: true, renderMode: 'fill' }));
|
||||
const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
|
||||
assert.equal(fill.getAttribute('aria-checked'), 'true');
|
||||
assert.equal(fit.getAttribute('aria-checked'), 'false');
|
||||
@@ -814,15 +666,15 @@ test('a hidden (0×0) viewer is not resized, so no z=NaN tile request (P3)', ()
|
||||
disconnect() {}
|
||||
};
|
||||
try {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
await settle();
|
||||
const resizes = layer.calls.resize;
|
||||
const viewer = dom.root.querySelector('#sl-viewer');
|
||||
Object.assign(viewer, { clientWidth: 0, clientHeight: 0 });
|
||||
sized(viewer, 0, 0);
|
||||
observers[0]();
|
||||
await settle();
|
||||
assert.equal(layer.calls.resize, resizes, 'skipped while 0×0');
|
||||
Object.assign(viewer, { clientWidth: 640, clientHeight: 400 });
|
||||
sized(viewer, 640, 400);
|
||||
observers[0]();
|
||||
await settle();
|
||||
assert.equal(layer.calls.resize, resizes + 1, 'resized once it shows');
|
||||
@@ -834,19 +686,23 @@ test('a hidden (0×0) viewer is not resized, so no z=NaN tile request (P3)', ()
|
||||
|
||||
test('identical renders write nothing to the DOM (P3: rail MutationObserver)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
layer.publish(uiState({ open: true }));
|
||||
const before = mutations.count;
|
||||
for (let i = 0; i < 10; i++) layer.publish(uiState({ open: true }));
|
||||
assert.equal(mutations.count - before, 0);
|
||||
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
|
||||
await layer.publish(uiState({ open: true }));
|
||||
await settle();
|
||||
dom.writes();
|
||||
for (let i = 0; i < 10; i++) await layer.publish(uiState({ open: true }));
|
||||
await settle();
|
||||
assert.equal(dom.writes(), 0);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, calls, controls } = stubPanel(dom, uiState());
|
||||
const { layer, calls, controls } = await stubPanel(dom, uiState());
|
||||
const keydown = (target, key) =>
|
||||
target.dispatchEvent({ type: 'keydown', bubbles: true, key });
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key }),
|
||||
);
|
||||
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
|
||||
assert.deepEqual(
|
||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
||||
@@ -854,16 +710,16 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop
|
||||
);
|
||||
all.focus();
|
||||
keydown(all, 'ArrowRight');
|
||||
assert.equal(dom.document.activeElement, pano);
|
||||
assertSameNode(dom.document.activeElement, pano);
|
||||
assert.deepEqual(calls.setParams.at(-1), [
|
||||
{ pano: 'pano' },
|
||||
{ origin: 'user' },
|
||||
]);
|
||||
keydown(all, 'ArrowLeft'); // wraps to the end
|
||||
assert.equal(dom.document.activeElement, flat);
|
||||
assertSameNode(dom.document.activeElement, flat);
|
||||
keydown(flat, 'Home');
|
||||
assert.equal(dom.document.activeElement, all);
|
||||
layer.publish(uiState({ pano: 'flat' }));
|
||||
assertSameNode(dom.document.activeElement, all);
|
||||
await layer.publish(uiState({ pano: 'flat' }));
|
||||
assert.deepEqual(
|
||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
||||
['-1', '-1', '0'],
|
||||
@@ -873,7 +729,7 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop
|
||||
|
||||
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
const { calls, chip, controls } = await stubPanel(
|
||||
dom,
|
||||
uiState({ keyRequired: true }),
|
||||
);
|
||||
@@ -904,7 +760,7 @@ test('OPEN STREET VIEW opens the nearest panorama at the view centre without mov
|
||||
count: 0,
|
||||
});
|
||||
state.groundClickReady = true;
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const { layer } = await stubPanel(dom, state);
|
||||
const asked = [];
|
||||
layer.openNearest = (point, options) => asked.push([point, options]);
|
||||
const button = dom.root.querySelector('#sl-nearest-btn');
|
||||
@@ -918,12 +774,12 @@ test('OPEN STREET VIEW opens the nearest panorama at the view centre without mov
|
||||
],
|
||||
]);
|
||||
// Too high to mean a street: disabled, and a click does nothing.
|
||||
layer.publish({ ...state, groundClickReady: false });
|
||||
await layer.publish({ ...state, groundClickReady: false });
|
||||
assert.equal(button.disabled, true);
|
||||
button.click();
|
||||
assert.equal(asked.length, 1);
|
||||
// Off Google 3D the provider is not listed: no button.
|
||||
layer.publish({ ...state, providers: [state.providers[0]] });
|
||||
await layer.publish({ ...state, providers: [state.providers[0]] });
|
||||
assert.equal(button.hidden, true);
|
||||
}));
|
||||
|
||||
@@ -941,7 +797,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
|
||||
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
|
||||
});
|
||||
state.providers[0].keyRequired = false;
|
||||
const { calls, layer } = stubPanel(dom, state);
|
||||
const { calls, layer } = await stubPanel(dom, state);
|
||||
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
|
||||
chips()[0].click(); // Mapillary: configured, dark
|
||||
await settle();
|
||||
@@ -953,7 +809,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
|
||||
state.providers[0].on = true;
|
||||
state.providers[1].on = false;
|
||||
state.keyRequired = false;
|
||||
layer.publish({ ...state });
|
||||
await layer.publish({ ...state });
|
||||
chips()[1].click();
|
||||
await settle();
|
||||
assert.equal(calls.setParams.length, 1);
|
||||
@@ -965,7 +821,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
|
||||
test('a swap names every registered provider, also those hidden on this map', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false });
|
||||
const { calls, layer, chip } = stubPanel(dom, state);
|
||||
const { calls, layer, chip } = await stubPanel(dom, state);
|
||||
// Off Google 3D the Street View chip is not listed, but stays registered.
|
||||
layer.providerIds = ['mapillary', 'google'];
|
||||
chip().click();
|
||||
@@ -978,27 +834,27 @@ test('a swap names every registered provider, also those hidden on this map', ()
|
||||
test('a note under SINCE says what date it compares, only while one is given', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const { layer } = await stubPanel(dom, state);
|
||||
const note = () => dom.root.querySelector('#sl-since-note');
|
||||
assert.equal(note().hidden, true, 'none for Mapillary');
|
||||
layer.publish({ ...state, sinceNote: '(by upload date)' });
|
||||
await layer.publish({ ...state, sinceNote: '(by upload date)' });
|
||||
assert.equal(note().hidden, false);
|
||||
assert.equal(note().textContent, '(by upload date)');
|
||||
layer.publish(state);
|
||||
await layer.publish(state);
|
||||
assert.equal(note().hidden, true);
|
||||
// The panel's own markup has it, read out with the slider.
|
||||
const template = fs.readFileSync(
|
||||
new URL('./views/LayerPanelsView.vue', import.meta.url),
|
||||
new URL('./views/panels/StreetLevelPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
|
||||
assert.match(template, /<small id="sl-since-note"[^>]* :hidden="!view/);
|
||||
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
|
||||
}));
|
||||
|
||||
test('Street View lit hides the imagery filters; Mapillary lit shows them', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const { layer } = await stubPanel(dom, state);
|
||||
const shown = () => ({
|
||||
pano: !dom.root.querySelector('#sl-pano-row').hidden,
|
||||
since: !dom.root.querySelector('#sl-since-row').hidden,
|
||||
@@ -1009,7 +865,7 @@ test('Street View lit hides the imagery filters; Mapillary lit shows them', () =
|
||||
since: true,
|
||||
controls: true,
|
||||
});
|
||||
layer.publish({
|
||||
await layer.publish({
|
||||
...state,
|
||||
filterControls: { pano: false, since: false },
|
||||
});
|
||||
@@ -1018,10 +874,50 @@ test('Street View lit hides the imagery filters; Mapillary lit shows them', () =
|
||||
since: false,
|
||||
controls: false,
|
||||
});
|
||||
layer.publish(state);
|
||||
await layer.publish(state);
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
});
|
||||
}));
|
||||
|
||||
test('dragging SINCE shows the stop under the thumb and leaves the thumb there', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, controls } = await stubPanel(dom, uiState());
|
||||
const since = dom.root.querySelector('#sl-since');
|
||||
since.value = '5';
|
||||
since.dispatchEvent(new Event('input'));
|
||||
await settle();
|
||||
assert.equal(since.value, '5', 'the readout render keeps the thumb');
|
||||
assert.equal(
|
||||
dom.root.querySelector('#sl-since-label').textContent,
|
||||
'LAST YEAR',
|
||||
);
|
||||
since.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls.setParams.at(-1)[0], { sinceDays: 365 });
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('a window changed elsewhere under a focused SINCE thumb leaves its readout alone', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer, controls } = await stubPanel(dom, {
|
||||
...state,
|
||||
filter: { ...state.filter, sinceDays: 365 },
|
||||
});
|
||||
const since = dom.root.querySelector('#sl-since');
|
||||
const label = () => dom.root.querySelector('#sl-since-label').textContent;
|
||||
assert.equal(since.value, '5');
|
||||
assert.match(label(), /^LAST YEAR · SINCE /);
|
||||
since.focus();
|
||||
await layer.publish({
|
||||
...state,
|
||||
filter: { ...state.filter, sinceDays: 30 },
|
||||
});
|
||||
await settle();
|
||||
assert.equal(since.value, '5', 'the thumb stays under the hand');
|
||||
assert.match(label(), /^LAST YEAR · SINCE /, 'and the readout with it');
|
||||
assert.equal(since.getAttribute('aria-valuetext'), label());
|
||||
controls.destroy();
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import SceneOptions from './SceneOptions.vue';
|
||||
import SceneShotList from './SceneShotList.vue';
|
||||
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,74 +80,7 @@ import SceneShotList from './SceneShotList.vue';
|
||||
</div>
|
||||
|
||||
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
|
||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="street-level-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off" data-i18n-title data-i18n>OFF</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div id="sl-body" class="sl-body">
|
||||
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
|
||||
<div class="sl-viewer-bar">
|
||||
<div class="sl-viewer-tools">
|
||||
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-viewer-stage">
|
||||
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
|
||||
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer" data-i18n-aria-label></div>
|
||||
</div>
|
||||
<div id="sl-image-meta" class="sl-image-meta">
|
||||
<span id="sl-image-by" class="sl-image-by"></span>
|
||||
<span class="sl-image-right">
|
||||
<span id="sl-image-when" class="sl-image-when"></span>
|
||||
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title hidden></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>PROVIDERS</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></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">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
||||
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
|
||||
<span class="sl-since-value">
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StreetLevelPanel />
|
||||
|
||||
<!-- Scene Director Panel -->
|
||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, shallowRef, watch } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import RailChips from '../../rail/RailChips.vue';
|
||||
import { useStreetLevelStore } from '../../stores/streetLevel.js';
|
||||
|
||||
// The Street Level panel, rendered from what StreetLevelControls presents.
|
||||
// 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 status = computed(
|
||||
() =>
|
||||
view.value?.status ?? {
|
||||
text: t('OFF'),
|
||||
tone: '',
|
||||
pressed: false,
|
||||
title: t('Turn Street Level on or off'),
|
||||
},
|
||||
);
|
||||
const viewer = computed(
|
||||
() =>
|
||||
view.value?.viewer ?? {
|
||||
open: false,
|
||||
loading: false,
|
||||
renderMode: 'letterbox',
|
||||
renderModes: true,
|
||||
},
|
||||
);
|
||||
const shows = computed(
|
||||
() => view.value?.filterControls ?? { pano: true, since: true },
|
||||
);
|
||||
const pano = computed(() => view.value?.filter.pano ?? 'all');
|
||||
|
||||
/** A radio segment; the chosen one is the group's tab stop once rendered. */
|
||||
const segment = (value, option) => ({
|
||||
class: `gev-segment sl-seg-btn${value === option ? ' is-active' : ''}`,
|
||||
'aria-checked': String(value === option),
|
||||
tabindex: value === option ? (view.value ? '0' : undefined) : '-1',
|
||||
});
|
||||
|
||||
// The image line and OPEN keep what they last showed while hidden.
|
||||
const image = shallowRef({
|
||||
captionLeft: '',
|
||||
captionRight: '',
|
||||
link: null,
|
||||
href: '#',
|
||||
linkLabel: '',
|
||||
});
|
||||
// The page's placeholder, hidden until the controls name the providers.
|
||||
const nearest = shallowRef({
|
||||
label: 'OPEN STREET VIEW', // i18n-ignore: never shown before it is replaced
|
||||
title: '',
|
||||
disabled: false,
|
||||
});
|
||||
watch(
|
||||
view,
|
||||
(next) => {
|
||||
if (next?.viewer.open) {
|
||||
const { captionLeft, captionRight, link, linkLabel } = next.viewer;
|
||||
image.value = {
|
||||
captionLeft,
|
||||
captionRight,
|
||||
link,
|
||||
href: link || image.value.href,
|
||||
linkLabel: linkLabel || image.value.linkLabel,
|
||||
};
|
||||
}
|
||||
if (next?.nearest.visible) nearest.value = next.nearest;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
// SINCE: the readout follows the thumb; the window is recorded on release.
|
||||
const sinceLabel = shallowRef(t('ANY DATE'));
|
||||
const stop = (input) => Number(input.value) || 0;
|
||||
function onSinceInput(event) {
|
||||
// Without controls attached the readout stays as it is.
|
||||
sinceLabel.value =
|
||||
store.describeSinceStop(stop(event.target)) || sinceLabel.value;
|
||||
}
|
||||
function onSinceChange(event) {
|
||||
store.act('setSinceStop', stop(event.target));
|
||||
}
|
||||
/**
|
||||
* Each new presentation places the thumb, as the controls' renders did, but
|
||||
* never under the user's hand; the readout's own updates leave it alone.
|
||||
* Once the thumb is at the presented window, the readout shows it in full.
|
||||
*/
|
||||
const placeThumb = (el, { value: since, oldValue }) => {
|
||||
if (!since || since === oldValue) return;
|
||||
const index = String(since.index);
|
||||
if (el.ownerDocument.activeElement !== el && el.value !== index)
|
||||
el.value = index;
|
||||
if (el.value === index) sinceLabel.value = since.label;
|
||||
};
|
||||
const vThumb = { mounted: placeThumb, updated: placeThumb };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel" :data-sl-enabled="view ? String(view.enabled) : undefined">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="street-level-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" type="button" :aria-pressed="String(status.pressed)" :title="status.title">{{ status.text }}</button>
|
||||
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
|
||||
</div>
|
||||
<div id="sl-body" class="sl-body">
|
||||
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
|
||||
<div class="sl-viewer-bar">
|
||||
<div class="sl-viewer-tools">
|
||||
<button id="sl-viewer-expand" class="scene-btn" type="button" :hidden="!expandAvailable" :aria-pressed="String(Boolean(expanded))" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></button>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label :hidden="!viewer.renderModes">
|
||||
<button type="button" v-bind="segment(viewer.renderMode, 'letterbox')" data-sl-render="letterbox" role="radio" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
||||
<button type="button" v-bind="segment(viewer.renderMode, 'fill')" data-sl-render="fill" role="radio" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
|
||||
</div>
|
||||
</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="Street-level viewer" data-i18n-aria-label></div>
|
||||
</div>
|
||||
<div id="sl-image-meta" class="sl-image-meta">
|
||||
<span id="sl-image-by" class="sl-image-by">{{ image.captionLeft }}</span>
|
||||
<span class="sl-image-right">
|
||||
<span id="sl-image-when" class="sl-image-when">{{ image.captionRight }}</span>
|
||||
<a id="sl-image-link" class="sl-image-link" :href="image.href" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title :hidden="!image.link">{{ image.linkLabel }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>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>
|
||||
<!-- 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">
|
||||
<div id="sl-pano-row" class="sl-filters" :hidden="!shows.pano" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" v-bind="segment(pano, 'all')" data-sl-pano="all" role="radio" data-i18n>ALL</button>
|
||||
<button type="button" v-bind="segment(pano, 'pano')" data-sl-pano="pano" role="radio">360°</button>
|
||||
<button type="button" v-bind="segment(pano, 'flat')" data-sl-pano="flat" role="radio" data-i18n>FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-since-row" class="sl-since" :hidden="!shows.since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" v-thumb="view?.since" class="sl-range" type="range" min="0" max="8" step="1" value="0" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" />
|
||||
<span class="sl-since-value">
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">{{ sinceLabel }}</output>
|
||||
<small id="sl-since-note" class="sl-since-note" :hidden="!view?.sinceNote">{{ view?.sinceNote || '' }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta">{{ view?.meta ?? '' }}</div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" :hidden="!view?.nearest.visible" :title="nearest.title || undefined" :disabled="nearest.disabled" data-i18n-runtime>{{ nearest.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user