mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
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);
|
||
}
|
||
}
|