Files
gods-eye-view/src/ui/streetLevelControls.js
T

451 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
} from './streetLevelPresentation.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'];
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
// 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: filters, legend and viewer. The panel
* chrome itself belongs to the application shell.
*/
export class StreetLevelControls {
constructor({ root, layer, actions }) {
this.root = root;
this.layer = layer;
this.actions = actions;
this.destroyed = false;
this.listeners = new AbortController();
this._unsubscribe = null;
this._state = null;
this._wasEnabled = null;
this._wasOpen = false;
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
this._explicitEnable = false;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._elements = this._collect();
this._bind();
}
_collect() {
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
const all = (selector) => [
...(this.root?.querySelectorAll(selector) || []),
];
return {
status: byId('sl-status'),
controls: byId('sl-controls'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'),
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]'),
};
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
}
_bind() {
const el = this._elements;
if (!this.root) return;
this.layer.attachViewerHost?.(el.viewer);
this.listen(el.status, 'click', () => this._toggleEnabled());
for (const button of el.panoButtons) {
this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
// 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 = 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.viewerClose, 'click', () => this.layer.closeViewer?.());
// EXPAND is the browser's own fullscreen: it handles Esc, focus and the
// rest of the page. iPhone Safari has no element fullscreen, so there the
// viewer opens as a top-layer popover over the page instead (Esc and the
// button close it). With neither, the button is not offered.
this._expandMode =
typeof el.viewerWrap?.requestFullscreen === 'function'
? 'fullscreen'
: typeof el.viewerWrap?.showPopover === 'function'
? 'popover'
: null;
this._popoverOpen = false;
if (this._expandMode) {
this.listen(el.viewerExpand, 'click', () => this._toggleExpanded());
if (this._expandMode === 'fullscreen')
this.listen(document, 'fullscreenchange', () => this._syncExpanded());
else {
this.listen(el.viewerWrap, 'toggle', (event) =>
this._onPopoverToggle(event),
);
// Esc shrinks the photo before the panel's own Esc can collapse it.
this.listen(el.viewerWrap, 'keydown', (event) => {
if (event.key !== 'Escape' || !this.isViewerExpanded()) return;
event.preventDefault();
event.stopPropagation();
this._exitExpanded();
});
}
} else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
this.layer.setViewerRenderMode?.(mode);
try {
localStorage.setItem(RENDER_MODE_KEY, mode);
} catch {
/* storage unavailable */
}
});
}
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
this.layer.setViewerRenderMode?.(stored);
} catch {
/* storage unavailable */
}
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
this._resizeObserver = new ResizeObserver(() => this._requestResize());
this._resizeObserver.observe(el.viewer);
}
}
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
*/
_requestResize() {
if (this._resizeQueued) return;
this._resizeQueued = true;
requestAnimationFrame(() => {
this._resizeQueued = false;
const viewer = this._elements.viewer;
if (this.destroyed) return;
if (viewer && (viewer.clientWidth === 0 || viewer.clientHeight === 0))
return;
this.layer.resizeViewer?.();
});
}
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch. A share link or tool can switch Mapillary
// off under a layer that is on; the pill then reads OFF, and switching on
// brings Mapillary back (and the layer, if it is off too).
if (this._state?.providerOn === false) {
this._setParams({ mapillary: true });
if (enabled) return;
}
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
}
}
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
this.actions.setParams(params, { origin: 'user' });
}
/** Whether EXPAND has the viewer full screen (or, on iPhone, open over the page). */
isViewerExpanded() {
const wrap = this._elements.viewerWrap;
if (!wrap) return false;
return this._expandMode === 'popover'
? this._popoverOpen
: document.fullscreenElement === wrap;
}
async _toggleExpanded() {
const wrap = this._elements.viewerWrap;
try {
if (this.isViewerExpanded()) this._exitExpanded();
else if (this._expandMode === 'fullscreen')
await wrap.requestFullscreen();
else {
// Only while open: a closed popover is hidden even inside the panel.
wrap.popover = 'auto';
wrap.showPopover();
}
} catch {
/* refused (no user gesture, policy): the panel view stays */
}
}
_exitExpanded() {
if (!this.isViewerExpanded()) return;
if (this._expandMode === 'popover') this._elements.viewerWrap.hidePopover();
else document.exitFullscreen().catch(() => {});
}
_onPopoverToggle(event) {
this._popoverOpen = event.newState === 'open';
if (!this._popoverOpen)
this._elements.viewerWrap.removeAttribute('popover');
this._syncExpanded();
}
/**
* While expanded, leave when Clean View, recording or the cockpit starts:
* they hide every panel, and the photo would stay over the globe (and in a
* recording).
*/
_watchPanelHidingModes(on) {
const body = document.body;
if (!on || !body) {
this._modesObserver?.disconnect();
this._modesObserver = null;
return;
}
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
this._modesObserver = new MutationObserver(leaveIfHiding);
this._modesObserver.observe(body, {
attributes: true,
attributeFilter: ['class'],
});
}
leaveIfHiding();
}
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
_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 ? 'SHRINK' : 'EXPAND',
);
setAttr(button, 'aria-pressed', String(on));
setAttr(button, 'aria-label', on ? 'Shrink' : 'Expand');
}
this._requestResize();
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = this.actions.subscribeEnableRequests(
(origin) => {
this._explicitEnable = isExplicitLayerStateOrigin(origin);
},
);
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) this._requestResize();
}
render(state) {
if (this.destroyed || !state || !this.root) return;
this._state = state;
const view = presentStreetLevelPanel(state);
this._renderHeader(view);
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view);
setProp(this._elements.coverageMeta, 'textContent', view.meta);
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 || '');
}
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
}
}
_renderFilters(view) {
const el = this._elements;
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
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);
}
}
setProp(el.legend, 'hidden', !view.legend);
}
_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);
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
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);
}
}
/**
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
*/
_reactToTransitions(view) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// A restore must not reopen a panel the user or a share link kept collapsed.
if (this._explicitEnable) this.setCollapsed(false, { persist: false });
this._explicitEnable = false;
}
this._wasEnabled = enabled;
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = view.viewer.open;
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
this._wasOpen = open;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._exitExpanded();
this._watchPanelHidingModes(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = null;
this.layer.attachViewerHost?.(null);
}
}