mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(mapillary): Street Level panel in the right context rail
An ordinary collapsible GEV panel between CCTV and Context, starting collapsed with the Mapillary mark in its strip. Body: a gated empty state when no client token is present, plain-English query box (ASK becomes STOP while running), streamed result chips, imagery filters with a colour legend, and the embedded viewer whose expanded mode is a focus-trapped dialog. All wording lives in a pure presentation module. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
791e77fa33
commit
3169d205d9
11 files changed
+1073
-1072
No files matched your search
@@ -14,6 +14,7 @@ import { readShellElements } from './shellElements.js';
|
|||||||
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||||
import { ContextControls } from './context.js';
|
import { ContextControls } from './context.js';
|
||||||
import { CctvControls } from './cctv.js';
|
import { CctvControls } from './cctv.js';
|
||||||
|
import { MapillaryControls } from './mapillaryControls.js';
|
||||||
import { RadioControls } from './radio.js';
|
import { RadioControls } from './radio.js';
|
||||||
import { LocationNavigation } from './locationNavigation.js';
|
import { LocationNavigation } from './locationNavigation.js';
|
||||||
import { bindClearLayersControl } from './layers.js';
|
import { bindClearLayersControl } from './layers.js';
|
||||||
@@ -536,6 +537,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._initRightPanelAdaptiveLayout();
|
this._initRightPanelAdaptiveLayout();
|
||||||
this._initRadioPanel();
|
this._initRadioPanel();
|
||||||
this._initCctvPanel();
|
this._initCctvPanel();
|
||||||
|
this._initMapillaryPanel();
|
||||||
this._initGlobalContextPanel();
|
this._initGlobalContextPanel();
|
||||||
this._initLocationBar();
|
this._initLocationBar();
|
||||||
this._initShareButton();
|
this._initShareButton();
|
||||||
@@ -876,6 +878,29 @@ export class StyleManager extends ShellFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compose camera panel controls from the existing camera port and application actions. */
|
/** Compose camera panel controls from the existing camera port and application actions. */
|
||||||
|
_initMapillaryPanel() {
|
||||||
|
const { mapillaryLayer } = this.services;
|
||||||
|
this._mapillaryControls?.destroy();
|
||||||
|
this._mapillaryControls = null;
|
||||||
|
if (!this._mapillaryPanel || !mapillaryLayer) return;
|
||||||
|
this._mapillaryControls = new MapillaryControls({
|
||||||
|
root: this._mapillaryPanel,
|
||||||
|
mapillary: mapillaryLayer,
|
||||||
|
actions: {
|
||||||
|
isEnabled: () => this._dataManager?.isEnabled('mapillary') === true,
|
||||||
|
setEnabled: (enabled) =>
|
||||||
|
this._dataManager?.setEnabled('mapillary', enabled, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
setPanelCollapsed: (collapsed, options) =>
|
||||||
|
this.setPanelCollapsed('mapillary-panel', collapsed, options),
|
||||||
|
openKeySetup: () => document.getElementById('key-setup-chip')?.click(),
|
||||||
|
showToast: (message) => this._showToast(message),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this._mapillaryControls.connect();
|
||||||
|
}
|
||||||
|
|
||||||
_initCctvPanel() {
|
_initCctvPanel() {
|
||||||
const { cctvLayer } = this.services;
|
const { cctvLayer } = this.services;
|
||||||
this._cctvControls?.destroy();
|
this._cctvControls?.destroy();
|
||||||
@@ -1471,6 +1496,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._cameraOrientationControls?.destroy();
|
this._cameraOrientationControls?.destroy();
|
||||||
this._clearLayersControl?.destroy();
|
this._clearLayersControl?.destroy();
|
||||||
this._cctvControls?.destroy();
|
this._cctvControls?.destroy();
|
||||||
|
this._mapillaryControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
this._cockpitCoordinator.stop();
|
this._cockpitCoordinator.stop();
|
||||||
this._visualSettings.stop();
|
this._visualSettings.stop();
|
||||||
|
|||||||
+310
-400
@@ -1,31 +1,17 @@
|
|||||||
import { makeFloating } from './floatingWindow.js';
|
import { presentMapillaryPanel } from './mapillaryPresentation.js';
|
||||||
|
|
||||||
|
export { mapillaryImageUrl } from './mapillaryPresentation.js';
|
||||||
|
|
||||||
const DOCK_STORAGE_KEY = 'gev:mapillary-dock:collapsed';
|
|
||||||
const DOCK_MINIMIZED_KEY = 'gev:mapillary-dock:minimized';
|
|
||||||
const DOCK_WINDOW_KEY = 'gev:mapillary-dock:window:v1';
|
|
||||||
const RENDER_MODE_KEY = 'gev:mapillary:render-mode';
|
const RENDER_MODE_KEY = 'gev:mapillary:render-mode';
|
||||||
const MAPILLARY_APP_URL = 'https://www.mapillary.com/app/';
|
const FOCUSABLE =
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
|
||||||
export function mapillaryImageUrl(imageId) {
|
|
||||||
const id = String(imageId || '').trim();
|
|
||||||
if (!id) return MAPILLARY_APP_URL;
|
|
||||||
return `${MAPILLARY_APP_URL}?pKey=${encodeURIComponent(id)}&focus=photo`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ms) {
|
|
||||||
if (!Number.isFinite(ms)) return '';
|
|
||||||
try {
|
|
||||||
return new Date(ms).toISOString().slice(0, 10);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Own the Mapillary dock: the AI query box, result chips, progress, the
|
* Own the Street Level panel: the query box, result chips, progress, the
|
||||||
* street-level viewer host and its controls. Receives the layer instance and
|
* imagery filters and the embedded MapillaryJS viewer. The panel itself is
|
||||||
* application actions; never reaches into the layer's internals.
|
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
|
||||||
|
* the application shell; this class only fills the body and asks the shell
|
||||||
|
* to open the panel when something worth seeing arrives.
|
||||||
*/
|
*/
|
||||||
export class MapillaryControls {
|
export class MapillaryControls {
|
||||||
constructor({ root, mapillary, actions }) {
|
constructor({ root, mapillary, actions }) {
|
||||||
@@ -36,52 +22,50 @@ export class MapillaryControls {
|
|||||||
this.listeners = new AbortController();
|
this.listeners = new AbortController();
|
||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
this._state = null;
|
this._state = null;
|
||||||
|
this._view = null;
|
||||||
|
this._wasEnabled = null;
|
||||||
|
this._wasStreetOpen = false;
|
||||||
this._lastStage = null;
|
this._lastStage = null;
|
||||||
|
this._wrapHome = null;
|
||||||
|
this._expandReturnFocus = null;
|
||||||
this._elements = this._collect();
|
this._elements = this._collect();
|
||||||
this._bind();
|
this._bind();
|
||||||
this._restoreCollapsed();
|
|
||||||
this._restoreMinimized();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_collect() {
|
_collect() {
|
||||||
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
|
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
|
||||||
return {
|
return {
|
||||||
toggle: byId('mly-dock-toggle'),
|
status: byId('mly-status'),
|
||||||
body: byId('mly-body'),
|
keyless: byId('mly-keyless'),
|
||||||
statusChip: byId('mly-status-chip'),
|
keylessBtn: byId('mly-keyless-btn'),
|
||||||
|
controls: byId('mly-controls'),
|
||||||
enableBtn: byId('mly-enable-btn'),
|
enableBtn: byId('mly-enable-btn'),
|
||||||
minimizeBtn: byId('mly-minimize-btn'),
|
lookBtn: byId('mly-look-btn'),
|
||||||
minimized: byId('mly-minimized'),
|
|
||||||
minimizedBadge: byId('mly-minimized-badge'),
|
|
||||||
form: byId('mly-query-form'),
|
form: byId('mly-query-form'),
|
||||||
input: byId('mly-query-input'),
|
input: byId('mly-query-input'),
|
||||||
runBtn: byId('mly-query-run'),
|
runBtn: byId('mly-query-run'),
|
||||||
|
hint: byId('mly-ai-hint'),
|
||||||
answer: byId('mly-answer'),
|
answer: byId('mly-answer'),
|
||||||
|
error: byId('mly-error'),
|
||||||
|
errorText: byId('mly-error-text'),
|
||||||
progress: byId('mly-progress'),
|
progress: byId('mly-progress'),
|
||||||
progressFill: byId('mly-progress-fill'),
|
progressFill: byId('mly-progress-fill'),
|
||||||
progressLabel: byId('mly-progress-label'),
|
progressLabel: byId('mly-progress-label'),
|
||||||
results: byId('mly-results'),
|
results: byId('mly-results'),
|
||||||
chips: byId('mly-result-chips'),
|
chips: byId('mly-result-chips'),
|
||||||
frameBtn: byId('mly-frame-btn'),
|
frameBtn: byId('mly-frame-btn'),
|
||||||
threeDBtn: byId('mly-3d-btn'),
|
|
||||||
threeDNote: byId('mly-3d-note'),
|
|
||||||
photorealBtn: byId('mly-photoreal-btn'),
|
|
||||||
sinceSelect: byId('mly-since'),
|
|
||||||
aiHint: byId('mly-ai-hint'),
|
|
||||||
aiModel: byId('mly-ai-model'),
|
|
||||||
clearBtn: byId('mly-clear-btn'),
|
clearBtn: byId('mly-clear-btn'),
|
||||||
lookBtn: byId('mly-look-btn'),
|
sinceSelect: byId('mly-since'),
|
||||||
|
legend: byId('mly-legend'),
|
||||||
followBtn: byId('mly-follow-btn'),
|
followBtn: byId('mly-follow-btn'),
|
||||||
closeBtn: byId('mly-viewer-close'),
|
|
||||||
viewerWrap: byId('mly-viewer-wrap'),
|
viewerWrap: byId('mly-viewer-wrap'),
|
||||||
viewerExpand: byId('mly-viewer-expand'),
|
viewerExpand: byId('mly-viewer-expand'),
|
||||||
viewerFit: byId('mly-viewer-fit'),
|
viewerClose: byId('mly-viewer-close'),
|
||||||
|
viewerPlaceholder: byId('mly-viewer-placeholder'),
|
||||||
|
viewer: byId('mly-viewer'),
|
||||||
imageBy: byId('mly-image-by'),
|
imageBy: byId('mly-image-by'),
|
||||||
imageWhen: byId('mly-image-when'),
|
imageWhen: byId('mly-image-when'),
|
||||||
imageLink: byId('mly-image-link'),
|
imageLink: byId('mly-image-link'),
|
||||||
header: this.root?.querySelector('.mly-header') || null,
|
|
||||||
viewer: byId('mly-viewer'),
|
|
||||||
imageMeta: byId('mly-image-meta'),
|
|
||||||
coverageMeta: byId('mly-coverage-meta'),
|
coverageMeta: byId('mly-coverage-meta'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,67 +81,17 @@ export class MapillaryControls {
|
|||||||
const el = this._elements;
|
const el = this._elements;
|
||||||
if (!this.root) return;
|
if (!this.root) return;
|
||||||
this.mapillary.attachViewerHost?.(el.viewer);
|
this.mapillary.attachViewerHost?.(el.viewer);
|
||||||
this.listen(el.toggle, 'click', () =>
|
|
||||||
this.setCollapsed(!this.root.classList.contains('collapsed')),
|
this.listen(el.keylessBtn, 'click', () => this.actions.openKeySetup?.());
|
||||||
);
|
|
||||||
// Portable, resizable window: drag the header, resize from the grip.
|
|
||||||
this._floating = makeFloating(this.root, {
|
|
||||||
handle: el.header,
|
|
||||||
storageKey: DOCK_WINDOW_KEY,
|
|
||||||
dragThrough: '.mly-title-btn',
|
|
||||||
minWidth: 320,
|
|
||||||
minHeight: 220,
|
|
||||||
onChange: () => this.mapillary.resizeViewer?.(),
|
|
||||||
});
|
|
||||||
this.listen(el.viewerExpand, 'click', () =>
|
|
||||||
this.setViewerExpanded(
|
|
||||||
!this.root.classList.contains('mly-viewer-expanded'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.listen(document, 'keydown', (event) => {
|
|
||||||
if (
|
|
||||||
event.key === 'Escape' &&
|
|
||||||
this.root.classList.contains('mly-viewer-expanded')
|
|
||||||
)
|
|
||||||
this.setViewerExpanded(false);
|
|
||||||
});
|
|
||||||
this.listen(el.viewerFit, 'click', () => {
|
|
||||||
const next =
|
|
||||||
this._state?.street?.renderMode === 'fill' ? 'letterbox' : 'fill';
|
|
||||||
this.mapillary.setViewerRenderMode?.(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(RENDER_MODE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
|
||||||
if (stored === 'fill' || stored === 'letterbox')
|
|
||||||
this.mapillary.setViewerRenderMode?.(stored);
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
// MapillaryJS only tracks window resizes; the dock resizes on its own
|
|
||||||
// (drag grip, expand, reflow), so watch the host element directly.
|
|
||||||
if (typeof ResizeObserver === 'function' && el.viewer) {
|
|
||||||
let queued = false;
|
|
||||||
this._resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (queued) return;
|
|
||||||
queued = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
queued = false;
|
|
||||||
this.mapillary.resizeViewer?.();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this._resizeObserver.observe(el.viewer);
|
|
||||||
}
|
|
||||||
this.listen(el.minimizeBtn, 'click', () => this.setMinimized(true));
|
|
||||||
this.listen(el.minimized, 'click', () => this.setMinimized(false));
|
|
||||||
this.listen(el.enableBtn, 'click', () => this._toggleEnabled());
|
this.listen(el.enableBtn, 'click', () => this._toggleEnabled());
|
||||||
|
this.listen(el.lookBtn, 'click', async () => {
|
||||||
|
if (!(await this._ensureEnabled())) return;
|
||||||
|
this.mapillary.openNearest?.();
|
||||||
|
});
|
||||||
this.listen(el.form, 'submit', (event) => {
|
this.listen(el.form, 'submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this._run(el.input?.value);
|
if (this._view?.query.submitIsStop) this.mapillary.abortQuery?.();
|
||||||
|
else this._run(el.input?.value);
|
||||||
});
|
});
|
||||||
for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) {
|
for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) {
|
||||||
this.listen(button, 'click', () => {
|
this.listen(button, 'click', () => {
|
||||||
@@ -167,19 +101,14 @@ export class MapillaryControls {
|
|||||||
}
|
}
|
||||||
this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.());
|
this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.());
|
||||||
this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.());
|
this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.());
|
||||||
this.listen(el.lookBtn, 'click', async () => {
|
this.listen(el.chips, 'click', (event) => {
|
||||||
if (!(await this._ensureEnabled())) return;
|
const more = event.target.closest?.('[data-mly-more]');
|
||||||
this.setCollapsed(false);
|
if (!more) return;
|
||||||
this.mapillary.openNearest?.();
|
const expanded = el.chips.classList.toggle('is-expanded');
|
||||||
});
|
more.setAttribute('aria-expanded', String(expanded));
|
||||||
this.listen(el.followBtn, 'click', () => {
|
more.textContent = expanded
|
||||||
const next = !(this._state?.street?.follow === true);
|
? 'FEWER'
|
||||||
this.mapillary.setFollow?.(next);
|
: `+${more.dataset.mlyMore} CLASSES`;
|
||||||
});
|
|
||||||
this.listen(el.closeBtn, 'click', () => this.mapillary.closeViewer?.());
|
|
||||||
this.listen(el.threeDBtn, 'click', () => {
|
|
||||||
const next = !(this._state?.objects3d?.enabled === true);
|
|
||||||
this.mapillary.setObjects3d?.(next);
|
|
||||||
});
|
});
|
||||||
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
|
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
|
||||||
this.listen(button, 'click', () =>
|
this.listen(button, 'click', () =>
|
||||||
@@ -195,13 +124,53 @@ export class MapillaryControls {
|
|||||||
sinceMs = Date.now() - Number(value) * 86_400_000;
|
sinceMs = Date.now() - Number(value) * 86_400_000;
|
||||||
this.mapillary.setCoverageFilter?.({ sinceMs });
|
this.mapillary.setCoverageFilter?.({ sinceMs });
|
||||||
});
|
});
|
||||||
this.listen(el.photorealBtn, 'click', async () => {
|
this.listen(el.followBtn, 'click', () => {
|
||||||
const result = await this.actions.setMapStack?.('photoreal');
|
this.mapillary.setFollow?.(!(this._state?.street?.follow === true));
|
||||||
if (result?.error) this.actions.showToast?.(result.error);
|
|
||||||
});
|
});
|
||||||
// Keep the app's keyboard shortcuts from firing while typing a query.
|
this.listen(el.viewerClose, 'click', () => this.mapillary.closeViewer?.());
|
||||||
this.listen(el.input, 'keydown', (event) => event.stopPropagation());
|
this.listen(el.viewerExpand, 'click', () =>
|
||||||
this.listen(el.input, 'keyup', (event) => event.stopPropagation());
|
this.setViewerExpanded(!this.isViewerExpanded()),
|
||||||
|
);
|
||||||
|
for (const button of this.root.querySelectorAll('[data-mly-render]')) {
|
||||||
|
this.listen(button, 'click', () => {
|
||||||
|
const mode = button.dataset.mlyRender;
|
||||||
|
this.mapillary.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.mapillary.setViewerRenderMode?.(stored);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
// The expanded viewer is a dialog: Esc closes it, Tab stays inside.
|
||||||
|
this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event));
|
||||||
|
// Keep the app's keyboard shortcuts from firing while typing a query,
|
||||||
|
// but let Escape through so GEV's panel disclosure can collapse the panel.
|
||||||
|
const shield = (event) => {
|
||||||
|
if (event.key !== 'Escape') event.stopPropagation();
|
||||||
|
};
|
||||||
|
this.listen(el.input, 'keydown', shield);
|
||||||
|
this.listen(el.input, 'keyup', shield);
|
||||||
|
// MapillaryJS only tracks window resizes; the panel resizes on its own.
|
||||||
|
if (typeof ResizeObserver === 'function' && el.viewer) {
|
||||||
|
let queued = false;
|
||||||
|
this._resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
queued = false;
|
||||||
|
this.mapillary.resizeViewer?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._resizeObserver.observe(el.viewer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _ensureEnabled() {
|
async _ensureEnabled() {
|
||||||
@@ -224,14 +193,12 @@ export class MapillaryControls {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
|
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
|
||||||
}
|
}
|
||||||
if (!enabled) this.setCollapsed(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _run(prompt) {
|
async _run(prompt) {
|
||||||
const text = String(prompt || '').trim();
|
const text = String(prompt || '').trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (!(await this._ensureEnabled())) return;
|
if (!(await this._ensureEnabled())) return;
|
||||||
this.setCollapsed(false);
|
|
||||||
this.mapillary.runQuery?.(text);
|
this.mapillary.runQuery?.(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,92 +212,53 @@ export class MapillaryControls {
|
|||||||
if (this.mapillary.getUIState) this.render(this.mapillary.getUIState());
|
if (this.mapillary.getUIState) this.render(this.mapillary.getUIState());
|
||||||
}
|
}
|
||||||
|
|
||||||
_restoreCollapsed() {
|
/** Ask the shell to open (or close) the rail panel. */
|
||||||
// Open by default: the typed Mapillary AI box is the front door.
|
setCollapsed(collapsed, options = {}) {
|
||||||
let collapsed = false;
|
this.actions.setPanelCollapsed?.(collapsed, options);
|
||||||
try {
|
if (!collapsed)
|
||||||
const stored = localStorage.getItem(DOCK_STORAGE_KEY);
|
|
||||||
if (stored === '1') collapsed = true;
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
this.setCollapsed(collapsed, { persist: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
_restoreMinimized() {
|
|
||||||
let minimized = false;
|
|
||||||
try {
|
|
||||||
minimized = localStorage.getItem(DOCK_MINIMIZED_KEY) === '1';
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
this.setMinimized(minimized, { persist: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shrink the whole panel to a round Mapillary logo, or bring it back.
|
|
||||||
* The logo carries the layer's on/off state and a result-count badge.
|
|
||||||
*/
|
|
||||||
setMinimized(minimized, { persist = true } = {}) {
|
|
||||||
if (!this.root) return;
|
|
||||||
const on = minimized === true;
|
|
||||||
if (on) this.setViewerExpanded(false);
|
|
||||||
this.root.classList.toggle('minimized', on);
|
|
||||||
if (this._elements.minimized) this._elements.minimized.hidden = !on;
|
|
||||||
this.root.setAttribute('aria-expanded', String(!on));
|
|
||||||
if (!on) {
|
|
||||||
this.setCollapsed(false, { persist });
|
|
||||||
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
|
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
|
||||||
}
|
|
||||||
if (persist) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DOCK_MINIMIZED_KEY, on ? '1' : '0');
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._renderMinimized(this._state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinimized() {
|
isViewerExpanded() {
|
||||||
return this.root?.classList.contains('minimized') === true;
|
return (
|
||||||
}
|
this._elements.viewerWrap?.classList.contains(
|
||||||
|
'mly-viewer-wrap-expanded',
|
||||||
_renderMinimized(state) {
|
) === true
|
||||||
const el = this._elements;
|
);
|
||||||
if (!el.minimized) return;
|
|
||||||
const enabled = state?.enabled === true;
|
|
||||||
el.minimized.classList.toggle('is-off', !enabled);
|
|
||||||
const total = state?.features?.total || 0;
|
|
||||||
if (el.minimizedBadge) {
|
|
||||||
el.minimizedBadge.hidden = !(enabled && total > 0);
|
|
||||||
el.minimizedBadge.textContent =
|
|
||||||
total >= 1000 ? `${Math.round(total / 1000)}k` : String(total);
|
|
||||||
}
|
|
||||||
el.minimized.title = enabled
|
|
||||||
? `Open Mapillary · Street Level on${total ? ` · ${total.toLocaleString()} results` : ''}`
|
|
||||||
: 'Open Mapillary';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grow the street-level viewer to most of the screen, or shrink it back. */
|
/** Grow the street-level viewer to most of the screen, or shrink it back. */
|
||||||
setViewerExpanded(expanded) {
|
setViewerExpanded(expanded) {
|
||||||
if (!this.root) return;
|
|
||||||
const on = expanded === true;
|
|
||||||
const wrap = this._elements.viewerWrap;
|
const wrap = this._elements.viewerWrap;
|
||||||
if (on === this.root.classList.contains('mly-viewer-expanded')) return;
|
if (!wrap) return;
|
||||||
this.root.classList.toggle('mly-viewer-expanded', on);
|
const on = expanded === true;
|
||||||
if (wrap) {
|
if (on === this.isViewerExpanded()) return;
|
||||||
if (on) {
|
if (on) {
|
||||||
// Lift the viewer out of the dock: the dock's backdrop-filter would
|
// Lift the viewer out of the panel: the panel's backdrop-filter would
|
||||||
// otherwise pin a fixed-position child inside the panel.
|
// otherwise pin a fixed-position child inside it.
|
||||||
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
|
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
|
||||||
document.body.appendChild(wrap);
|
this._expandReturnFocus = document.activeElement;
|
||||||
wrap.classList.add('mly-viewer-wrap-expanded');
|
document.body.appendChild(wrap);
|
||||||
} else if (this._wrapHome?.parent) {
|
wrap.classList.add('mly-viewer-wrap-expanded');
|
||||||
wrap.classList.remove('mly-viewer-wrap-expanded');
|
wrap.setAttribute('role', 'dialog');
|
||||||
|
wrap.setAttribute('aria-modal', 'true');
|
||||||
|
wrap.setAttribute('aria-label', 'Street-level image');
|
||||||
|
wrap.tabIndex = -1;
|
||||||
|
wrap.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
wrap.classList.remove('mly-viewer-wrap-expanded');
|
||||||
|
wrap.removeAttribute('role');
|
||||||
|
wrap.removeAttribute('aria-modal');
|
||||||
|
wrap.removeAttribute('aria-label');
|
||||||
|
wrap.removeAttribute('tabindex');
|
||||||
|
if (this._wrapHome?.parent)
|
||||||
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
|
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
|
||||||
this._wrapHome = null;
|
this._wrapHome = null;
|
||||||
}
|
const target = this._expandReturnFocus;
|
||||||
|
this._expandReturnFocus = null;
|
||||||
|
if (target?.isConnected && typeof target.focus === 'function')
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
else this._elements.viewerExpand?.focus?.({ preventScroll: true });
|
||||||
}
|
}
|
||||||
const button = this._elements.viewerExpand;
|
const button = this._elements.viewerExpand;
|
||||||
if (button) {
|
if (button) {
|
||||||
@@ -344,230 +272,213 @@ export class MapillaryControls {
|
|||||||
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
|
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
|
||||||
}
|
}
|
||||||
|
|
||||||
setCollapsed(collapsed, { persist = true } = {}) {
|
_onDialogKey(event) {
|
||||||
if (!this.root) return;
|
if (!this.isViewerExpanded()) return;
|
||||||
this.root.classList.toggle('collapsed', collapsed);
|
if (event.key === 'Escape') {
|
||||||
this._elements.toggle?.setAttribute('aria-expanded', String(!collapsed));
|
event.preventDefault();
|
||||||
this._elements.toggle?.setAttribute(
|
event.stopPropagation();
|
||||||
'title',
|
this.setViewerExpanded(false);
|
||||||
collapsed ? 'Expand Mapillary' : 'Collapse Mapillary',
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const wrap = this._elements.viewerWrap;
|
||||||
|
const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter(
|
||||||
|
(node) => node.offsetParent !== null || node === document.activeElement,
|
||||||
);
|
);
|
||||||
if (!collapsed) this.mapillary.resizeViewer?.();
|
if (!focusable.length) return;
|
||||||
if (persist) {
|
const first = focusable[0];
|
||||||
try {
|
const last = focusable[focusable.length - 1];
|
||||||
localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0');
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
} catch {
|
event.preventDefault();
|
||||||
/* storage unavailable */
|
last.focus();
|
||||||
}
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
} else if (!wrap.contains(document.activeElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(state) {
|
render(state) {
|
||||||
if (this.destroyed || !state || !this.root) return;
|
if (this.destroyed || !state || !this.root) return;
|
||||||
this._state = state;
|
this._state = state;
|
||||||
|
const view = presentMapillaryPanel(state);
|
||||||
|
this._view = view;
|
||||||
|
this._renderHeader(view);
|
||||||
|
this._renderEmptyState(view);
|
||||||
|
this._renderQuery(view);
|
||||||
|
this._renderResults(view);
|
||||||
|
this._renderFilters(view);
|
||||||
|
this._renderViewer(view, state);
|
||||||
|
this._renderMeta(view);
|
||||||
|
this._reactToTransitions(view, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHeader(view) {
|
||||||
const el = this._elements;
|
const el = this._elements;
|
||||||
const enabled = state.enabled === true;
|
this.root.dataset.mlyEnabled = String(view.enabled);
|
||||||
|
if (el.status) {
|
||||||
el.enableBtn.textContent = enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF';
|
el.status.textContent = view.status.text;
|
||||||
el.enableBtn.setAttribute('aria-pressed', String(enabled));
|
el.status.className = `mly-status${view.status.tone ? ` is-${view.status.tone}` : ''}`;
|
||||||
|
|
||||||
let chip = enabled ? 'ON' : 'OFF';
|
|
||||||
let chipClass = enabled ? 'is-on' : '';
|
|
||||||
if (state.keyRequired) {
|
|
||||||
chip = 'KEY REQUIRED';
|
|
||||||
chipClass = 'is-warn';
|
|
||||||
} else if (state.query.busy) {
|
|
||||||
chip = `AI · ${state.query.stage.toUpperCase()}`;
|
|
||||||
chipClass = 'is-busy';
|
|
||||||
} else if (state.coverage.loading || state.features.loading) {
|
|
||||||
chip = 'LOADING';
|
|
||||||
chipClass = 'is-busy';
|
|
||||||
}
|
}
|
||||||
el.statusChip.textContent = chip;
|
if (el.enableBtn) {
|
||||||
el.statusChip.className = `mly-chip ${chipClass}`.trim();
|
el.enableBtn.textContent = view.enableButton.text;
|
||||||
|
el.enableBtn.setAttribute(
|
||||||
if (enabled && this._wasEnabled === false) this.setCollapsed(false);
|
'aria-pressed',
|
||||||
this._wasEnabled = enabled;
|
String(view.enableButton.pressed),
|
||||||
this._renderMinimized(state);
|
);
|
||||||
// Imagery needs the panel: opening an image brings a minimized dock back.
|
|
||||||
if (state.street.open && !this._wasStreetOpen && this.isMinimized())
|
|
||||||
this.setMinimized(false);
|
|
||||||
this._wasStreetOpen = state.street.open === true;
|
|
||||||
|
|
||||||
el.runBtn.disabled = state.query.busy;
|
|
||||||
el.runBtn.textContent = state.query.busy ? '…' : 'ASK';
|
|
||||||
el.input.disabled = false;
|
|
||||||
el.input.placeholder = state.planner
|
|
||||||
? 'Type a question, e.g. show me all fire hydrants in Sacramento'
|
|
||||||
: 'Add an Anthropic key in POWER UP to enable Mapillary AI';
|
|
||||||
if (el.aiModel)
|
|
||||||
el.aiModel.textContent = state.plannerModel
|
|
||||||
? `· ${state.plannerModel}`
|
|
||||||
: '';
|
|
||||||
if (el.aiHint) {
|
|
||||||
el.aiHint.classList.toggle('is-warn', !state.planner);
|
|
||||||
el.aiHint.textContent = state.planner
|
|
||||||
? 'Type what you want to see in plain English — objects, traffic signs, coverage or a street view of any place — and press Enter. Follow-ups refine the last answer.'
|
|
||||||
: 'Mapillary AI is off until an Anthropic key is added in POWER UP. Coverage, sequences and the viewer work without it.';
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Imagery filters.
|
_renderEmptyState(view) {
|
||||||
const filter = state.coverage.filter || { pano: 'all', sinceMs: null };
|
const el = this._elements;
|
||||||
|
if (el.keyless) el.keyless.hidden = !view.keyless.visible;
|
||||||
|
if (el.controls) el.controls.disabled = view.controlsDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderQuery(view) {
|
||||||
|
const el = this._elements;
|
||||||
|
const { query } = view;
|
||||||
|
if (el.input) {
|
||||||
|
el.input.placeholder = query.placeholder;
|
||||||
|
el.input.disabled = query.inputDisabled;
|
||||||
|
}
|
||||||
|
if (el.runBtn) {
|
||||||
|
el.runBtn.textContent = query.submitLabel;
|
||||||
|
el.runBtn.classList.toggle('is-stop', query.submitIsStop);
|
||||||
|
el.runBtn.disabled = query.inputDisabled && !query.submitIsStop;
|
||||||
|
el.runBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
query.submitIsStop ? 'Stop the query' : 'Ask',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const button of this.root.querySelectorAll('[data-mly-suggestion]'))
|
||||||
|
button.disabled = query.suggestionsDisabled;
|
||||||
|
if (el.hint) {
|
||||||
|
el.hint.textContent = query.hint;
|
||||||
|
el.hint.classList.toggle('is-warn', query.hintWarn);
|
||||||
|
}
|
||||||
|
if (el.answer) el.answer.textContent = view.answer;
|
||||||
|
if (el.error) {
|
||||||
|
el.error.hidden = !view.error;
|
||||||
|
if (el.errorText) el.errorText.textContent = view.error || '';
|
||||||
|
}
|
||||||
|
if (el.progress) {
|
||||||
|
el.progress.hidden = !view.progress.visible;
|
||||||
|
el.progress.classList.toggle(
|
||||||
|
'is-indeterminate',
|
||||||
|
view.progress.indeterminate,
|
||||||
|
);
|
||||||
|
if (el.progressFill && !view.progress.indeterminate)
|
||||||
|
el.progressFill.style.width = `${view.progress.percent}%`;
|
||||||
|
if (el.progressFill && view.progress.indeterminate)
|
||||||
|
el.progressFill.style.removeProperty('width');
|
||||||
|
if (el.progressLabel) el.progressLabel.textContent = view.progress.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderResults(view) {
|
||||||
|
const el = this._elements;
|
||||||
|
if (!el.results) return;
|
||||||
|
el.results.hidden = !view.results.visible;
|
||||||
|
if (!view.results.visible || !el.chips) return;
|
||||||
|
const expanded = el.chips.classList.contains('is-expanded');
|
||||||
|
const chip = (entry, overflow) => {
|
||||||
|
const node = document.createElement('span');
|
||||||
|
node.className = `mly-value-chip${overflow ? ' is-overflow' : ''}`;
|
||||||
|
node.title = entry.value;
|
||||||
|
const dot = document.createElement('i');
|
||||||
|
dot.style.background = entry.color;
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = entry.label;
|
||||||
|
const num = document.createElement('b');
|
||||||
|
num.textContent = entry.count;
|
||||||
|
node.append(dot, text, num);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
const nodes = view.results.all.map((entry, index) =>
|
||||||
|
chip(entry, index >= view.results.chips.length),
|
||||||
|
);
|
||||||
|
if (view.results.more) {
|
||||||
|
const more = document.createElement('button');
|
||||||
|
more.type = 'button';
|
||||||
|
more.className = 'mly-value-chip';
|
||||||
|
more.dataset.mlyMore = String(view.results.more.count);
|
||||||
|
more.title = view.results.more.title;
|
||||||
|
more.setAttribute('aria-expanded', String(expanded));
|
||||||
|
more.textContent = expanded
|
||||||
|
? 'FEWER'
|
||||||
|
: `+${view.results.more.count} CLASSES`;
|
||||||
|
nodes.push(more);
|
||||||
|
} else el.chips.classList.remove('is-expanded');
|
||||||
|
el.chips.replaceChildren(...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderFilters(view) {
|
||||||
|
const el = this._elements;
|
||||||
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
|
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
|
||||||
const active = button.dataset.mlyPano === filter.pano;
|
const active = button.dataset.mlyPano === view.filter.pano;
|
||||||
button.classList.toggle('is-active', active);
|
button.classList.toggle('is-active', active);
|
||||||
button.setAttribute('aria-checked', String(active));
|
button.setAttribute('aria-checked', String(active));
|
||||||
}
|
}
|
||||||
|
if (el.legend && el.legend.childElementCount !== view.legend.length) {
|
||||||
// 3D objects.
|
el.legend.replaceChildren(
|
||||||
const objects = state.objects3d || {};
|
...view.legend.map((entry) => {
|
||||||
el.threeDBtn.setAttribute('aria-pressed', String(objects.enabled === true));
|
const item = document.createElement('li');
|
||||||
el.threeDBtn.textContent = objects.building
|
const swatch = document.createElement('i');
|
||||||
? '3D OBJECTS …'
|
swatch.className = 'mly-legend-swatch';
|
||||||
: objects.enabled
|
swatch.style.background = entry.color;
|
||||||
? '3D OBJECTS ON'
|
const label = document.createElement('span');
|
||||||
: '3D OBJECTS OFF';
|
label.textContent = entry.label;
|
||||||
el.threeDNote.hidden = !(
|
item.append(swatch, label);
|
||||||
objects.enabled &&
|
return item;
|
||||||
(objects.error || objects.active)
|
}),
|
||||||
);
|
|
||||||
el.threeDNote.textContent = objects.error
|
|
||||||
? objects.error
|
|
||||||
: objects.active
|
|
||||||
? `${objects.count.toLocaleString()} objects standing in 3D · click one to open its image`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
el.photorealBtn.setAttribute(
|
|
||||||
'aria-pressed',
|
|
||||||
String(this.actions.isMapStackActive?.('photoreal') === true),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Answer / error line.
|
|
||||||
const error = state.query.error || state.street.error;
|
|
||||||
if (error) {
|
|
||||||
el.answer.textContent = error;
|
|
||||||
el.answer.classList.add('is-error');
|
|
||||||
} else {
|
|
||||||
el.answer.classList.remove('is-error');
|
|
||||||
if (state.query.answer) el.answer.textContent = state.query.answer;
|
|
||||||
else if (state.query.busy) el.answer.textContent = 'Planning…';
|
|
||||||
else if (!enabled)
|
|
||||||
el.answer.textContent =
|
|
||||||
'Turn on Street Level to see Mapillary coverage, then ask for anything that appears in street imagery.';
|
|
||||||
else if (state.coverage.hint) el.answer.textContent = state.coverage.hint;
|
|
||||||
else
|
|
||||||
el.answer.textContent =
|
|
||||||
'Click a green sequence to see its images, or ask Mapillary AI for objects and signs.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress bar while tiles stream.
|
|
||||||
const { progress } = state.features;
|
|
||||||
const showProgress = state.features.loading && progress.tiles > 0;
|
|
||||||
el.progress.hidden = !showProgress;
|
|
||||||
if (showProgress) {
|
|
||||||
const pct = Math.round((progress.done / progress.tiles) * 100);
|
|
||||||
el.progressFill.style.width = `${pct}%`;
|
|
||||||
el.progressLabel.textContent = `${progress.done}/${progress.tiles} tiles · ${state.features.total.toLocaleString()} found`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result chips.
|
|
||||||
const hasResults = state.features.total > 0 || state.features.hasBbox;
|
|
||||||
el.results.hidden = !hasResults;
|
|
||||||
if (hasResults) {
|
|
||||||
el.chips.replaceChildren(
|
|
||||||
...state.features.counts
|
|
||||||
.slice(0, 8)
|
|
||||||
.map(({ value, count, label, color }) => {
|
|
||||||
const chipEl = document.createElement('span');
|
|
||||||
chipEl.className = 'mly-value-chip';
|
|
||||||
chipEl.title = value;
|
|
||||||
const dot = document.createElement('i');
|
|
||||||
dot.style.background = color;
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = label;
|
|
||||||
const num = document.createElement('b');
|
|
||||||
num.textContent = count.toLocaleString();
|
|
||||||
chipEl.append(dot, text, num);
|
|
||||||
return chipEl;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if (state.features.counts.length > 8) {
|
|
||||||
const more = document.createElement('span');
|
|
||||||
more.className = 'mly-value-chip';
|
|
||||||
more.textContent = `+${state.features.counts.length - 8} classes`;
|
|
||||||
el.chips.append(more);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Street-level viewer.
|
_renderViewer(view, state) {
|
||||||
const { street } = state;
|
const el = this._elements;
|
||||||
el.viewerWrap.hidden = !street.open;
|
const { viewer } = view;
|
||||||
this.root.classList.toggle('mly-has-viewer', street.open === true);
|
if (el.viewerWrap) el.viewerWrap.hidden = !viewer.open;
|
||||||
if (!street.open && this.root.classList.contains('mly-viewer-expanded'))
|
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
|
||||||
this.setViewerExpanded(false);
|
if (el.viewerPlaceholder) el.viewerPlaceholder.hidden = !viewer.loading;
|
||||||
el.followBtn.setAttribute('aria-pressed', String(street.follow === true));
|
if (el.followBtn) {
|
||||||
el.followBtn.textContent = street.follow
|
el.followBtn.setAttribute('aria-pressed', String(viewer.follow.pressed));
|
||||||
? 'STREET COCKPIT ON'
|
el.followBtn.disabled = viewer.follow.disabled;
|
||||||
: 'STREET COCKPIT OFF';
|
|
||||||
el.followBtn.disabled = !street.open;
|
|
||||||
if (el.viewerFit) {
|
|
||||||
const fill = street.renderMode === 'fill';
|
|
||||||
el.viewerFit.textContent = fill ? 'FILL' : 'FIT';
|
|
||||||
el.viewerFit.title = fill
|
|
||||||
? 'Filling the frame (cropped) · click to show the whole image'
|
|
||||||
: 'Showing the whole image · click to fill the frame';
|
|
||||||
}
|
}
|
||||||
if (street.open) {
|
for (const button of this.root.querySelectorAll('[data-mly-render]')) {
|
||||||
// Caption bar as on mapillary.com: "Image by …" left, date right.
|
const active = button.dataset.mlyRender === viewer.renderMode;
|
||||||
const left = [];
|
button.classList.toggle('is-active', active);
|
||||||
if (street.loading) left.push('LOADING…');
|
button.setAttribute('aria-checked', String(active));
|
||||||
if (street.feature?.label)
|
|
||||||
left.push(
|
|
||||||
`${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`,
|
|
||||||
);
|
|
||||||
if (street.highlight?.count)
|
|
||||||
left.push(
|
|
||||||
`${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`,
|
|
||||||
);
|
|
||||||
if (street.creator) left.push(`Image by ${street.creator}`);
|
|
||||||
const right = [];
|
|
||||||
if (street.isPano) right.push('360°');
|
|
||||||
if (Number.isFinite(street.bearing))
|
|
||||||
right.push(`${Math.round(street.bearing)}°`);
|
|
||||||
if (street.capturedAt) right.push(formatDate(street.capturedAt));
|
|
||||||
if (el.imageBy) el.imageBy.textContent = left.join(' · ');
|
|
||||||
if (el.imageWhen) el.imageWhen.textContent = right.join(' · ');
|
|
||||||
if (el.imageLink) {
|
|
||||||
el.imageLink.href = mapillaryImageUrl(street.imageId);
|
|
||||||
el.imageLink.hidden = !street.imageId;
|
|
||||||
}
|
|
||||||
this.mapillary.resizeViewer?.();
|
|
||||||
}
|
}
|
||||||
|
if (!viewer.open) return;
|
||||||
|
if (el.imageBy) el.imageBy.textContent = viewer.captionLeft;
|
||||||
|
if (el.imageWhen) el.imageWhen.textContent = viewer.captionRight;
|
||||||
|
if (el.imageLink) {
|
||||||
|
el.imageLink.hidden = !viewer.link;
|
||||||
|
if (viewer.link) el.imageLink.href = viewer.link;
|
||||||
|
}
|
||||||
|
if (state.street.open) this.mapillary.resizeViewer?.();
|
||||||
|
}
|
||||||
|
|
||||||
// Coverage meta line.
|
_renderMeta(view) {
|
||||||
const coverageBits = [];
|
const el = this._elements;
|
||||||
if (enabled && state.coverage.zoom)
|
if (el.coverageMeta) el.coverageMeta.textContent = view.meta;
|
||||||
coverageBits.push(
|
}
|
||||||
`coverage z${state.coverage.zoom} · ${state.coverage.sequences.toLocaleString()} sequences`,
|
|
||||||
);
|
|
||||||
if (state.sequence.selectedId)
|
|
||||||
coverageBits.push(
|
|
||||||
state.sequence.loading
|
|
||||||
? 'loading sequence…'
|
|
||||||
: `${state.sequence.images} images in sequence · Esc clears`,
|
|
||||||
);
|
|
||||||
if (state.query.usage?.model)
|
|
||||||
coverageBits.push(
|
|
||||||
`${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`,
|
|
||||||
);
|
|
||||||
el.coverageMeta.textContent = coverageBits.join(' · ');
|
|
||||||
|
|
||||||
|
/** Open the panel at the moments a user would look for it. */
|
||||||
|
_reactToTransitions(view, state) {
|
||||||
|
const enabled = view.enabled;
|
||||||
|
if (enabled && this._wasEnabled === false) this.setCollapsed(false);
|
||||||
|
this._wasEnabled = enabled;
|
||||||
|
if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false);
|
||||||
|
this._wasStreetOpen = state.street.open === true;
|
||||||
if (state.query.stage !== this._lastStage) {
|
if (state.query.stage !== this._lastStage) {
|
||||||
this._lastStage = state.query.stage;
|
this._lastStage = state.query.stage;
|
||||||
if (state.query.stage === 'done' || state.query.stage === 'error') {
|
if (state.query.stage === 'done' || state.query.stage === 'error')
|
||||||
this.setCollapsed(false);
|
this.setCollapsed(false);
|
||||||
if (this.isMinimized()) this.setMinimized(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +489,6 @@ export class MapillaryControls {
|
|||||||
this.listeners.abort();
|
this.listeners.abort();
|
||||||
this._resizeObserver?.disconnect();
|
this._resizeObserver?.disconnect();
|
||||||
this._resizeObserver = null;
|
this._resizeObserver = null;
|
||||||
this._floating?.destroy();
|
|
||||||
this._unsubscribe?.();
|
this._unsubscribe?.();
|
||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
this.mapillary.attachViewerHost?.(null);
|
this.mapillary.attachViewerHost?.(null);
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Turn the Street Level layer's UI state into the strings and flags the panel
|
||||||
|
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HINT_READY =
|
||||||
|
'Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.';
|
||||||
|
const HINT_NO_PLANNER =
|
||||||
|
'Plain-English questions need an Anthropic key in POWER UP. Coverage, photos and filters work without it.';
|
||||||
|
const IDLE_ANSWER_OFF =
|
||||||
|
'Turn on Street Level to see Mapillary coverage on the globe.';
|
||||||
|
const IDLE_ANSWER_ON =
|
||||||
|
'Click a green line to see its photos, or ask for objects and signs.';
|
||||||
|
|
||||||
|
function formatDate(ms) {
|
||||||
|
if (!Number.isFinite(ms)) return '';
|
||||||
|
try {
|
||||||
|
return new Date(ms).toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
||||||
|
export function mapillaryImageUrl(imageId) {
|
||||||
|
const id = String(imageId || '').trim();
|
||||||
|
if (!id) return 'https://www.mapillary.com/app/';
|
||||||
|
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentStatus(state) {
|
||||||
|
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
|
||||||
|
if (state.query.busy)
|
||||||
|
return { text: `AI · ${state.query.stage.toUpperCase()}`, tone: 'busy' };
|
||||||
|
if (state.coverage.loading || state.features.loading)
|
||||||
|
return { text: 'LOADING', tone: 'busy' };
|
||||||
|
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentProgress(state) {
|
||||||
|
const { stage, busy } = state.query;
|
||||||
|
const { progress, loading, total } = state.features;
|
||||||
|
if (loading && progress.tiles > 0) {
|
||||||
|
const pct = Math.round((progress.done / progress.tiles) * 100);
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
indeterminate: false,
|
||||||
|
percent: pct,
|
||||||
|
label: `${progress.done}/${progress.tiles} tiles · ${total.toLocaleString()} found`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (busy && stage === 'planning')
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
indeterminate: true,
|
||||||
|
percent: 0,
|
||||||
|
label: 'Planning…',
|
||||||
|
};
|
||||||
|
if (busy && stage === 'resolving')
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
indeterminate: true,
|
||||||
|
percent: 0,
|
||||||
|
label: `Finding ${state.query.place || 'the area'}…`,
|
||||||
|
};
|
||||||
|
if (busy)
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
indeterminate: true,
|
||||||
|
percent: 0,
|
||||||
|
label: 'Working…',
|
||||||
|
};
|
||||||
|
return { visible: false, indeterminate: false, percent: 0, label: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentResults(state, { maxChips = 8 } = {}) {
|
||||||
|
const counts = state.features.counts || [];
|
||||||
|
const visible = state.features.total > 0 || state.features.hasBbox === true;
|
||||||
|
const chips = counts.map(({ value, count, label, color }) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
count: count.toLocaleString(),
|
||||||
|
}));
|
||||||
|
const hidden = chips.slice(maxChips);
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
chips: chips.slice(0, maxChips),
|
||||||
|
more: hidden.length
|
||||||
|
? {
|
||||||
|
count: hidden.length,
|
||||||
|
title: hidden.map((chip) => `${chip.label} ${chip.count}`).join(', '),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
all: chips,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentViewer(state) {
|
||||||
|
const { street } = state;
|
||||||
|
const left = [];
|
||||||
|
if (street.feature?.label)
|
||||||
|
left.push(
|
||||||
|
`${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`,
|
||||||
|
);
|
||||||
|
if (street.highlight?.count)
|
||||||
|
left.push(
|
||||||
|
`${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`,
|
||||||
|
);
|
||||||
|
if (street.creator) left.push(`Image by ${street.creator}`);
|
||||||
|
const right = [];
|
||||||
|
if (street.isPano) right.push('360°');
|
||||||
|
if (Number.isFinite(street.bearing))
|
||||||
|
right.push(`${Math.round(street.bearing)}°`);
|
||||||
|
if (street.capturedAt) right.push(formatDate(street.capturedAt));
|
||||||
|
return {
|
||||||
|
open: street.open === true,
|
||||||
|
loading: street.loading === true && !street.imageId,
|
||||||
|
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||||
|
captionLeft: left.join(' · '),
|
||||||
|
captionRight: right.join(' · '),
|
||||||
|
link: street.imageId ? mapillaryImageUrl(street.imageId) : null,
|
||||||
|
follow: {
|
||||||
|
pressed: street.follow === true,
|
||||||
|
disabled: street.open !== true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentMeta(state) {
|
||||||
|
if (!state.enabled) return '';
|
||||||
|
if (state.sequence.selectedId)
|
||||||
|
return state.sequence.loading
|
||||||
|
? 'Loading this sequence…'
|
||||||
|
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||||
|
const bits = [];
|
||||||
|
if (state.coverage.zoom && state.coverage.sequences > 0)
|
||||||
|
bits.push(
|
||||||
|
`${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`,
|
||||||
|
);
|
||||||
|
else if (state.coverage.hint) bits.push(state.coverage.hint);
|
||||||
|
if (state.query.usage?.model)
|
||||||
|
bits.push(
|
||||||
|
`${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`,
|
||||||
|
);
|
||||||
|
return bits.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} state Snapshot from the layer's `getUIState()`.
|
||||||
|
* @returns {object} Everything the panel needs, already worded.
|
||||||
|
*/
|
||||||
|
export function presentMapillaryPanel(state) {
|
||||||
|
const enabled = state.enabled === true;
|
||||||
|
const keyRequired = state.keyRequired === true;
|
||||||
|
const planner = state.planner === true;
|
||||||
|
const busy = state.query.busy === true;
|
||||||
|
const error = state.query.error || state.street.error || null;
|
||||||
|
let answer = state.query.answer || '';
|
||||||
|
if (!answer && !error) {
|
||||||
|
if (busy) answer = '';
|
||||||
|
else if (!enabled) answer = IDLE_ANSWER_OFF;
|
||||||
|
else if (state.coverage.hint) answer = state.coverage.hint;
|
||||||
|
else answer = IDLE_ANSWER_ON;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
keyRequired,
|
||||||
|
status: presentStatus(state),
|
||||||
|
keyless: {
|
||||||
|
visible: keyRequired,
|
||||||
|
text: 'Street Level needs a free Mapillary client token (MAPILLARY_CLIENT_TOKEN). Coverage, photos and queries stay off until one is added.',
|
||||||
|
},
|
||||||
|
controlsDisabled: keyRequired,
|
||||||
|
enableButton: {
|
||||||
|
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
|
||||||
|
pressed: enabled,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
placeholder: planner
|
||||||
|
? 'Ask about objects, signs or a place'
|
||||||
|
: 'Anthropic key needed — see POWER UP',
|
||||||
|
inputDisabled: !planner,
|
||||||
|
suggestionsDisabled: !planner,
|
||||||
|
hint: planner ? HINT_READY : HINT_NO_PLANNER,
|
||||||
|
hintWarn: !planner,
|
||||||
|
model: state.plannerModel ? `· ${state.plannerModel}` : '',
|
||||||
|
submitLabel: busy ? 'STOP' : 'ASK',
|
||||||
|
submitIsStop: busy,
|
||||||
|
},
|
||||||
|
progress: presentProgress(state),
|
||||||
|
answer,
|
||||||
|
error,
|
||||||
|
results: presentResults(state),
|
||||||
|
filter: state.coverage.filter || { pano: 'all', sinceMs: null },
|
||||||
|
legend: state.coverage.legend || [],
|
||||||
|
viewer: presentViewer(state),
|
||||||
|
meta: presentMeta(state),
|
||||||
|
/** The panel opens itself at these moments (a native panel stays put otherwise). */
|
||||||
|
wantsOpen:
|
||||||
|
state.street.open === true ||
|
||||||
|
state.query.stage === 'done' ||
|
||||||
|
state.query.stage === 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
|||||||
{ id: 'location-bar', pinnable: true },
|
{ id: 'location-bar', pinnable: true },
|
||||||
{ id: 'data-panel' },
|
{ id: 'data-panel' },
|
||||||
{ id: 'cctv-panel' },
|
{ id: 'cctv-panel' },
|
||||||
|
{ id: 'mapillary-panel' },
|
||||||
{ id: 'radio-panel' },
|
{ id: 'radio-panel' },
|
||||||
{ id: 'scene-panel' },
|
{ id: 'scene-panel' },
|
||||||
{ id: 'global-context-panel' },
|
{ id: 'global-context-panel' },
|
||||||
@@ -21,6 +22,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
|||||||
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||||
'data-panel',
|
'data-panel',
|
||||||
'cctv-panel',
|
'cctv-panel',
|
||||||
|
'mapillary-panel',
|
||||||
'scene-panel',
|
'scene-panel',
|
||||||
'pp-toggles',
|
'pp-toggles',
|
||||||
'global-context-panel',
|
'global-context-panel',
|
||||||
@@ -281,6 +283,7 @@ export class PanelChrome {
|
|||||||
const isRightRail = [
|
const isRightRail = [
|
||||||
'pp-toggles',
|
'pp-toggles',
|
||||||
'cctv-panel',
|
'cctv-panel',
|
||||||
|
'mapillary-panel',
|
||||||
'global-context-panel',
|
'global-context-panel',
|
||||||
].includes(panelEl?.id);
|
].includes(panelEl?.id);
|
||||||
const collapsed = panelEl.classList.contains('collapsed');
|
const collapsed = panelEl.classList.contains('collapsed');
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export class PanelLayoutController {
|
|||||||
this._rightPanelStack = document.getElementById('right-context-rail');
|
this._rightPanelStack = document.getElementById('right-context-rail');
|
||||||
this._ppToggles = document.getElementById('pp-toggles');
|
this._ppToggles = document.getElementById('pp-toggles');
|
||||||
this._cctvPanel = document.getElementById('cctv-panel');
|
this._cctvPanel = document.getElementById('cctv-panel');
|
||||||
|
this._mapillaryPanel = document.getElementById('mapillary-panel');
|
||||||
this._sliderPanel = document.getElementById('param-slider-panel');
|
this._sliderPanel = document.getElementById('param-slider-panel');
|
||||||
this._detectionBtn = document.getElementById('detection-toggle');
|
this._detectionBtn = document.getElementById('detection-toggle');
|
||||||
}
|
}
|
||||||
@@ -204,6 +205,19 @@ export class PanelLayoutController {
|
|||||||
stack.insertBefore(this._cctvPanel, globalContextPanel);
|
stack.insertBefore(this._cctvPanel, globalContextPanel);
|
||||||
this._syncPanelCollapseButton(this._cctvPanel);
|
this._syncPanelCollapseButton(this._cctvPanel);
|
||||||
}
|
}
|
||||||
|
if (this._mapillaryPanel) {
|
||||||
|
this._mapillaryPanel.style.removeProperty('top');
|
||||||
|
this._mapillaryPanel.style.removeProperty('right');
|
||||||
|
this._mapillaryPanel.style.removeProperty('bottom');
|
||||||
|
this._mapillaryPanel.style.removeProperty('left');
|
||||||
|
this._mapillaryPanel.style.removeProperty('z-index');
|
||||||
|
this._mapillaryPanel.classList.remove(
|
||||||
|
'panel-draggable',
|
||||||
|
'panel-dragging',
|
||||||
|
);
|
||||||
|
stack.insertBefore(this._mapillaryPanel, globalContextPanel);
|
||||||
|
this._syncPanelCollapseButton(this._mapillaryPanel);
|
||||||
|
}
|
||||||
if (this._sliderPanel) {
|
if (this._sliderPanel) {
|
||||||
this._sliderPanel.style.removeProperty('top');
|
this._sliderPanel.style.removeProperty('top');
|
||||||
this._sliderPanel.style.removeProperty('right');
|
this._sliderPanel.style.removeProperty('right');
|
||||||
@@ -222,6 +236,7 @@ export class PanelLayoutController {
|
|||||||
for (const panel of [
|
for (const panel of [
|
||||||
this._ppToggles,
|
this._ppToggles,
|
||||||
this._cctvPanel,
|
this._cctvPanel,
|
||||||
|
this._mapillaryPanel,
|
||||||
globalContextPanel,
|
globalContextPanel,
|
||||||
]) {
|
]) {
|
||||||
if (panel) this._rightStackResizeObserver.observe(panel);
|
if (panel) this._rightStackResizeObserver.observe(panel);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function readShellElements(document = globalThis.document) {
|
|||||||
_dataPanel: document.getElementById('data-panel'),
|
_dataPanel: document.getElementById('data-panel'),
|
||||||
_scenePanel: document.getElementById('scene-panel'),
|
_scenePanel: document.getElementById('scene-panel'),
|
||||||
_cctvPanel: document.getElementById('cctv-panel'),
|
_cctvPanel: document.getElementById('cctv-panel'),
|
||||||
_mapillaryDock: document.getElementById('mapillary-dock'),
|
_mapillaryPanel: document.getElementById('mapillary-panel'),
|
||||||
_radioPanel: document.getElementById('radio-panel'),
|
_radioPanel: document.getElementById('radio-panel'),
|
||||||
_contextRadioDock: document.getElementById('context-radio-dock'),
|
_contextRadioDock: document.getElementById('context-radio-dock'),
|
||||||
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
|
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
|
||||||
|
|||||||
@@ -162,6 +162,7 @@
|
|||||||
}
|
}
|
||||||
#right-context-rail > #pp-toggles,
|
#right-context-rail > #pp-toggles,
|
||||||
#right-context-rail > #cctv-panel,
|
#right-context-rail > #cctv-panel,
|
||||||
|
#right-context-rail > #mapillary-panel,
|
||||||
#right-context-rail > #global-context-panel {
|
#right-context-rail > #global-context-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
inset: auto;
|
inset: auto;
|
||||||
@@ -173,12 +174,14 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#right-context-rail #cctv-panel {
|
#right-context-rail #cctv-panel,
|
||||||
|
#right-context-rail #mapillary-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#right-context-rail #cctv-panel.collapsed {
|
#right-context-rail #cctv-panel.collapsed,
|
||||||
|
#right-context-rail #mapillary-panel.collapsed {
|
||||||
width: var(--right-display-collapsed-width);
|
width: var(--right-display-collapsed-width);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -191,6 +194,7 @@
|
|||||||
}
|
}
|
||||||
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
|
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
|
||||||
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
|
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
|
||||||
|
#right-context-rail #mapillary-panel.collapsed .mapillary-panel-inner,
|
||||||
#right-context-rail
|
#right-context-rail
|
||||||
#global-context-panel.collapsed
|
#global-context-panel.collapsed
|
||||||
.global-context-panel-inner {
|
.global-context-panel-inner {
|
||||||
@@ -198,7 +202,8 @@
|
|||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner {
|
#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner,
|
||||||
|
#right-context-rail #mapillary-panel:not(.collapsed) .mapillary-panel-inner {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
@@ -246,6 +251,7 @@
|
|||||||
}
|
}
|
||||||
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
||||||
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
|
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
|
||||||
|
#right-context-rail.layout-focus > #mapillary-panel:not(.collapsed),
|
||||||
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
||||||
flex: 0 1 var(--right-panel-allocated-height);
|
flex: 0 1 var(--right-panel-allocated-height);
|
||||||
height: var(--right-panel-allocated-height);
|
height: var(--right-panel-allocated-height);
|
||||||
@@ -261,6 +267,9 @@
|
|||||||
#right-context-rail.layout-focus
|
#right-context-rail.layout-focus
|
||||||
> #cctv-panel:not(.collapsed)
|
> #cctv-panel:not(.collapsed)
|
||||||
.cctv-panel-inner,
|
.cctv-panel-inner,
|
||||||
|
#right-context-rail.layout-focus
|
||||||
|
> #mapillary-panel:not(.collapsed)
|
||||||
|
.mapillary-panel-inner,
|
||||||
#right-context-rail.layout-focus
|
#right-context-rail.layout-focus
|
||||||
> #global-context-panel:not(.collapsed)
|
> #global-context-panel:not(.collapsed)
|
||||||
.global-context-panel-inner {
|
.global-context-panel-inner {
|
||||||
|
|||||||
+393
-668
File diff suppressed because it is too large.
Load diff
@@ -204,6 +204,8 @@
|
|||||||
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
||||||
#right-context-rail #cctv-panel,
|
#right-context-rail #cctv-panel,
|
||||||
#right-context-rail #cctv-panel.collapsed,
|
#right-context-rail #cctv-panel.collapsed,
|
||||||
|
#right-context-rail #mapillary-panel,
|
||||||
|
#right-context-rail #mapillary-panel.collapsed,
|
||||||
#right-context-rail #radio-panel,
|
#right-context-rail #radio-panel,
|
||||||
#right-context-rail #radio-panel.collapsed,
|
#right-context-rail #radio-panel.collapsed,
|
||||||
#right-context-rail #global-context-panel,
|
#right-context-rail #global-context-panel,
|
||||||
|
|||||||
@@ -68,6 +68,107 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Street Level (Mapillary) Panel — moved into #right-context-rail at
|
||||||
|
runtime next to CCTV (see PanelLayoutController). -->
|
||||||
|
<div id="mapillary-panel" class="panel-collapsible collapsed" data-panel-id="mapillary-panel">
|
||||||
|
<div class="panel-glow"></div>
|
||||||
|
<div class="mapillary-panel-inner">
|
||||||
|
<div class="panel-header">
|
||||||
|
<!-- Mapillary mark: path from the MapillaryJS attribution component
|
||||||
|
(mapillary-js, MIT, © Mapillary). Mapillary is a trademark of
|
||||||
|
Meta Platforms, Inc.; it is used here to identify the data source. -->
|
||||||
|
<svg class="mly-mark" viewBox="0 0 13.5 13.8" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.38154274 9.6505067C6.14007412 9.52786404 6.08124006 9.31709453 6.21044037 9.08865565 6.27614941 8.97247561 6.41859234 8.74074422 6.53468168 8.5539822 6.69432724 8.29714786 6.92701434 8.29637588 7.05835866 8.37253362 7.18970298 8.44869135 9.04853604 9.42087063 9.16874871 9.48670656 9.4525926 9.64215711 9.87118629 9.28623741 9.68342251 8.97441326 9.59375332 8.82549731 8.8103938 7.4044783 8.5614978 6.93610438 8.44825156 6.72299696 8.47921005 6.53132251 8.731647 6.40377463 8.85656436 6.3406581 9.08004239 6.20890798 9.27394762 6.09190586 9.47189219 5.9724664 9.7353488 6.06223885 9.84245555 6.24022621 9.94956229 6.41821357 13.1209182 12.2294019 13.2221517 12.4098169 13.4231294 12.7679928 13.0322853 13.1511137 12.662949 12.953094 12.4781436 12.8540105 6.62301136 9.77314936 6.38154274 9.6505067zM.200146062 6.40081205C-.111554282 6.23274106-.0545442328 5.79604052.338782648 5.68466036.672030083 5.59029317 3.37820647 4.6882634 4.19715115 4.4149007 4.34479443 4.36561757 4.46005809 4.25228232 4.50965757 4.1075867 4.78858853 3.29386671 5.72024556.575961334 5.82468911.27126979 5.9477048-.087601979 6.39304077-.0889184867 6.57454671.258502251 6.63901566.381902346 9.08940484 4.79813543 9.17465946 4.98547051 9.25991408 5.17280559 9.20498731 5.38031434 9.01176406 5.50000515 8.8185408 5.61969596 8.50343522 5.79872065 8.3936507 5.86654323 8.19489463 5.98933057 8.00439135 5.90384324 7.91365501 5.71364816 7.82291866 5.52345307 7.12387828 4.29626886 6.83882952 3.77527795 6.69634941 3.51486341 6.2671132 3.41492796 6.12283721 3.83582159 6.01848488 4.14024669 5.77526264 4.84979439 5.64864557 5.219172 5.60020375 5.3604904 5.47741944 5.48212554 5.33398124 5.52937001 4.9780163 5.64661478 4.30162944 5.86939744 3.90672375 5.99946818 3.62491469 6.0922881 3.48793523 6.52150257 3.86841776 6.70224674 3.94789525 6.74000167 5.66097153 7.65404391 5.83772054 7.73764558 6.01446954 7.82124724 6.11275281 8.06257945 6.00925316 8.23355004 5.8680804 8.46675268 5.68734796 8.76272052 5.6217862 8.85880815 5.50815838 9.02534163 5.28495077 9.08080286 5.09748996 8.98251076 4.91002915 8.88421866.311522289 6.46086689.200146062 6.40081205z"/></svg>
|
||||||
|
<span class="panel-title">STREET LEVEL</span>
|
||||||
|
<span class="panel-divider"></span>
|
||||||
|
<span id="mly-status" class="mly-status" role="status" aria-live="polite">OFF</span>
|
||||||
|
<button class="panel-collapse-btn" data-collapse-target="mapillary-panel" title="Expand panel">◀</button>
|
||||||
|
</div>
|
||||||
|
<div id="mly-body" class="mly-body">
|
||||||
|
<div id="mly-keyless" class="mly-keyless" hidden>
|
||||||
|
<p class="mly-keyless-text">Street Level needs a free Mapillary client token (<code>MAPILLARY_CLIENT_TOKEN</code>). Coverage, photos and queries stay off until one is added.</p>
|
||||||
|
<button id="mly-keyless-btn" class="scene-btn" type="button">ADD KEY IN POWER UP</button>
|
||||||
|
</div>
|
||||||
|
<fieldset id="mly-controls" class="mly-controls">
|
||||||
|
<div class="cctv-controls">
|
||||||
|
<button id="mly-enable-btn" class="scene-btn" type="button" aria-pressed="false" title="Turn the Street Level layer on or off">STREET LEVEL OFF</button>
|
||||||
|
<button id="mly-look-btn" class="scene-btn" type="button" title="Open the nearest Mapillary photo to the view centre">OPEN NEAREST PHOTO</button>
|
||||||
|
</div>
|
||||||
|
<form id="mly-query-form" class="mly-query" autocomplete="off">
|
||||||
|
<label for="mly-query-input" class="cctv-summary-label">ASK IN PLAIN ENGLISH</label>
|
||||||
|
<div class="mly-query-row">
|
||||||
|
<input id="mly-query-input" type="text" maxlength="600" spellcheck="false" placeholder="Ask about objects, signs or a place" aria-label="Ask about objects, signs or a place" />
|
||||||
|
<button id="mly-query-run" class="scene-btn mly-ask" type="submit">ASK</button>
|
||||||
|
</div>
|
||||||
|
<p id="mly-ai-hint" class="mly-hint">Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.</p>
|
||||||
|
<div class="mly-suggestions" role="group" aria-label="Example queries">
|
||||||
|
<button type="button" class="mly-suggestion" data-mly-suggestion="show me all fire hydrants in Sacramento">Hydrants · Sacramento</button>
|
||||||
|
<button type="button" class="mly-suggestion" data-mly-suggestion="stop signs in downtown Detroit">Stop signs · Detroit</button>
|
||||||
|
<button type="button" class="mly-suggestion" data-mly-suggestion="cctv cameras in London">CCTV cameras · London</button>
|
||||||
|
<button type="button" class="mly-suggestion" data-mly-suggestion="show me a 360 street view here">360° view · here</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="mly-answer" class="mly-answer" role="status" aria-live="polite"></div>
|
||||||
|
<div id="mly-error" class="mly-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="mly-error-text"></span></div>
|
||||||
|
<div id="mly-progress" class="mly-progress" hidden>
|
||||||
|
<div id="mly-progress-fill" class="mly-progress-fill"></div>
|
||||||
|
<span id="mly-progress-label" class="mly-progress-label"></span>
|
||||||
|
</div>
|
||||||
|
<div id="mly-results" class="mly-results" hidden>
|
||||||
|
<div id="mly-result-chips" class="mly-chips"></div>
|
||||||
|
<div class="cctv-controls">
|
||||||
|
<button id="mly-frame-btn" class="scene-btn" type="button" title="Fly the camera to the result area">ZOOM TO RESULTS</button>
|
||||||
|
<button id="mly-clear-btn" class="scene-btn" type="button" title="Remove the results from the globe">CLEAR</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mly-filters" role="group" aria-label="Imagery filters">
|
||||||
|
<span class="cctv-summary-label">IMAGERY</span>
|
||||||
|
<div class="mly-seg" role="radiogroup" aria-label="Panorama filter">
|
||||||
|
<button type="button" class="mly-seg-btn is-active" data-mly-pano="all" role="radio" aria-checked="true">ALL</button>
|
||||||
|
<button type="button" class="mly-seg-btn" data-mly-pano="pano" role="radio" aria-checked="false">360°</button>
|
||||||
|
<button type="button" class="mly-seg-btn" data-mly-pano="flat" role="radio" aria-checked="false">FLAT</button>
|
||||||
|
</div>
|
||||||
|
<label class="cctv-summary-label" for="mly-since">SINCE</label>
|
||||||
|
<select id="mly-since" class="mly-select" aria-label="Show imagery captured since">
|
||||||
|
<option value="0">any date</option>
|
||||||
|
<option value="365">last year</option>
|
||||||
|
<option value="730">2 years</option>
|
||||||
|
<option value="1826">5 years</option>
|
||||||
|
<option value="year:2024">2024</option>
|
||||||
|
<option value="year:2022">2022</option>
|
||||||
|
<option value="year:2020">2020</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<ul id="mly-legend" class="mly-legend" aria-label="Coverage colours"></ul>
|
||||||
|
<div class="cctv-controls">
|
||||||
|
<button id="mly-follow-btn" class="scene-btn" type="button" aria-pressed="false" title="Move the globe camera wherever the street-level view looks">CAMERA FOLLOWS VIEW</button>
|
||||||
|
</div>
|
||||||
|
<div id="mly-viewer-wrap" class="mly-viewer-wrap" hidden>
|
||||||
|
<div class="mly-viewer-tools">
|
||||||
|
<button id="mly-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="mly-btn-icon" aria-hidden="true">⤢</span><span class="mly-btn-text">EXPAND</span></button>
|
||||||
|
<div class="mly-seg" role="radiogroup" aria-label="Image fit">
|
||||||
|
<button type="button" class="mly-seg-btn is-active" data-mly-render="letterbox" role="radio" aria-checked="true" title="Show the whole image">FIT</button>
|
||||||
|
<button type="button" class="mly-seg-btn" data-mly-render="fill" role="radio" aria-checked="false" title="Fill the frame (cropped)">FILL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mly-viewer-tools mly-viewer-tools-right">
|
||||||
|
<button id="mly-viewer-close" class="mly-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="mly-viewer-placeholder" class="mly-viewer-placeholder" hidden></div>
|
||||||
|
<div id="mly-viewer" class="mly-viewer" aria-label="Mapillary street-level viewer"></div>
|
||||||
|
<div id="mly-image-meta" class="mly-image-meta">
|
||||||
|
<span id="mly-image-by" class="mly-image-by"></span>
|
||||||
|
<span class="mly-image-right">
|
||||||
|
<span id="mly-image-when" class="mly-image-when"></span>
|
||||||
|
<a id="mly-image-link" class="mly-image-link" href="https://www.mapillary.com/app/" target="_blank" rel="noopener noreferrer" title="Open this image on mapillary.com">MAPILLARY ↗</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mly-coverage-meta" class="mly-meta"></div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scene Director Panel -->
|
<!-- Scene Director Panel -->
|
||||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
@import './src/ui/styles/layers.css';
|
@import './src/ui/styles/layers.css';
|
||||||
@import './src/ui/styles/radio.css';
|
@import './src/ui/styles/radio.css';
|
||||||
@import './src/ui/styles/cctv.css';
|
@import './src/ui/styles/cctv.css';
|
||||||
|
/* MapillaryJS's stylesheet is vendored (see its header) because the JS is
|
||||||
|
loaded lazily on first image open while its controls need styling as soon
|
||||||
|
as that first image lands; ~9 KB gzipped. */
|
||||||
|
@import './src/ui/styles/mapillary-js.css';
|
||||||
|
@import './src/ui/styles/mapillary.css';
|
||||||
@import './src/ui/styles/scenes.css';
|
@import './src/ui/styles/scenes.css';
|
||||||
@import './src/ui/styles/bhote-koshi.css';
|
@import './src/ui/styles/bhote-koshi.css';
|
||||||
@import './src/ui/styles/recording.css';
|
@import './src/ui/styles/recording.css';
|
||||||
|
|||||||
Reference in new issue
Block a user