diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..c180c4e97 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -14,6 +14,7 @@ import { readShellElements } from './shellElements.js'; import { CockpitCoordinator } from './cockpitCoordinator.js'; import { ContextControls } from './context.js'; import { CctvControls } from './cctv.js'; +import { MapillaryControls } from './mapillaryControls.js'; import { RadioControls } from './radio.js'; import { LocationNavigation } from './locationNavigation.js'; import { bindClearLayersControl } from './layers.js'; @@ -536,6 +537,7 @@ export class StyleManager extends ShellFacade { this._initRightPanelAdaptiveLayout(); this._initRadioPanel(); this._initCctvPanel(); + this._initMapillaryPanel(); this._initGlobalContextPanel(); this._initLocationBar(); this._initShareButton(); @@ -876,6 +878,29 @@ export class StyleManager extends ShellFacade { } /** 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() { const { cctvLayer } = this.services; this._cctvControls?.destroy(); @@ -1471,6 +1496,7 @@ export class StyleManager extends ShellFacade { this._cameraOrientationControls?.destroy(); this._clearLayersControl?.destroy(); this._cctvControls?.destroy(); + this._mapillaryControls?.destroy(); this._radioControls?.destroy(); this._cockpitCoordinator.stop(); this._visualSettings.stop(); diff --git a/src/ui/mapillaryControls.js b/src/ui/mapillaryControls.js index dabda83ac..ce42e0379 100644 --- a/src/ui/mapillaryControls.js +++ b/src/ui/mapillaryControls.js @@ -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 MAPILLARY_APP_URL = 'https://www.mapillary.com/app/'; - -/** 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 ''; - } -} +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** - * Own the Mapillary dock: the AI query box, result chips, progress, the - * street-level viewer host and its controls. Receives the layer instance and - * application actions; never reaches into the layer's internals. + * Own the Street Level panel: the query box, result chips, progress, the + * imagery filters and the embedded MapillaryJS viewer. The panel itself is + * 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 { constructor({ root, mapillary, actions }) { @@ -36,52 +22,50 @@ export class MapillaryControls { this.listeners = new AbortController(); this._unsubscribe = null; this._state = null; + this._view = null; + this._wasEnabled = null; + this._wasStreetOpen = false; this._lastStage = null; + this._wrapHome = null; + this._expandReturnFocus = null; this._elements = this._collect(); this._bind(); - this._restoreCollapsed(); - this._restoreMinimized(); } _collect() { const byId = (id) => this.root?.querySelector(`#${id}`) || null; return { - toggle: byId('mly-dock-toggle'), - body: byId('mly-body'), - statusChip: byId('mly-status-chip'), + status: byId('mly-status'), + keyless: byId('mly-keyless'), + keylessBtn: byId('mly-keyless-btn'), + controls: byId('mly-controls'), enableBtn: byId('mly-enable-btn'), - minimizeBtn: byId('mly-minimize-btn'), - minimized: byId('mly-minimized'), - minimizedBadge: byId('mly-minimized-badge'), + lookBtn: byId('mly-look-btn'), form: byId('mly-query-form'), input: byId('mly-query-input'), runBtn: byId('mly-query-run'), + hint: byId('mly-ai-hint'), answer: byId('mly-answer'), + error: byId('mly-error'), + errorText: byId('mly-error-text'), progress: byId('mly-progress'), progressFill: byId('mly-progress-fill'), progressLabel: byId('mly-progress-label'), results: byId('mly-results'), chips: byId('mly-result-chips'), 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'), - lookBtn: byId('mly-look-btn'), + sinceSelect: byId('mly-since'), + legend: byId('mly-legend'), followBtn: byId('mly-follow-btn'), - closeBtn: byId('mly-viewer-close'), viewerWrap: byId('mly-viewer-wrap'), 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'), imageWhen: byId('mly-image-when'), 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'), }; } @@ -97,67 +81,17 @@ export class MapillaryControls { const el = this._elements; if (!this.root) return; this.mapillary.attachViewerHost?.(el.viewer); - this.listen(el.toggle, 'click', () => - this.setCollapsed(!this.root.classList.contains('collapsed')), - ); - // 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.keylessBtn, 'click', () => this.actions.openKeySetup?.()); 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) => { 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]')) { this.listen(button, 'click', () => { @@ -167,19 +101,14 @@ export class MapillaryControls { } this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.()); this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.()); - this.listen(el.lookBtn, 'click', async () => { - if (!(await this._ensureEnabled())) return; - this.setCollapsed(false); - this.mapillary.openNearest?.(); - }); - this.listen(el.followBtn, 'click', () => { - const next = !(this._state?.street?.follow === true); - this.mapillary.setFollow?.(next); - }); - this.listen(el.closeBtn, 'click', () => this.mapillary.closeViewer?.()); - this.listen(el.threeDBtn, 'click', () => { - const next = !(this._state?.objects3d?.enabled === true); - this.mapillary.setObjects3d?.(next); + this.listen(el.chips, 'click', (event) => { + const more = event.target.closest?.('[data-mly-more]'); + if (!more) return; + const expanded = el.chips.classList.toggle('is-expanded'); + more.setAttribute('aria-expanded', String(expanded)); + more.textContent = expanded + ? 'FEWER' + : `+${more.dataset.mlyMore} CLASSES`; }); for (const button of this.root.querySelectorAll('[data-mly-pano]')) { this.listen(button, 'click', () => @@ -195,13 +124,53 @@ export class MapillaryControls { sinceMs = Date.now() - Number(value) * 86_400_000; this.mapillary.setCoverageFilter?.({ sinceMs }); }); - this.listen(el.photorealBtn, 'click', async () => { - const result = await this.actions.setMapStack?.('photoreal'); - if (result?.error) this.actions.showToast?.(result.error); + this.listen(el.followBtn, 'click', () => { + this.mapillary.setFollow?.(!(this._state?.street?.follow === true)); }); - // Keep the app's keyboard shortcuts from firing while typing a query. - this.listen(el.input, 'keydown', (event) => event.stopPropagation()); - this.listen(el.input, 'keyup', (event) => event.stopPropagation()); + this.listen(el.viewerClose, 'click', () => this.mapillary.closeViewer?.()); + this.listen(el.viewerExpand, 'click', () => + 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() { @@ -224,14 +193,12 @@ export class MapillaryControls { } catch (error) { this.actions.showToast?.(error?.message || 'Street Level toggle failed'); } - if (!enabled) this.setCollapsed(false); } async _run(prompt) { const text = String(prompt || '').trim(); if (!text) return; if (!(await this._ensureEnabled())) return; - this.setCollapsed(false); this.mapillary.runQuery?.(text); } @@ -245,92 +212,53 @@ export class MapillaryControls { if (this.mapillary.getUIState) this.render(this.mapillary.getUIState()); } - _restoreCollapsed() { - // Open by default: the typed Mapillary AI box is the front door. - let collapsed = false; - try { - 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 }); + /** Ask the shell to open (or close) the rail panel. */ + setCollapsed(collapsed, options = {}) { + this.actions.setPanelCollapsed?.(collapsed, options); + if (!collapsed) requestAnimationFrame(() => this.mapillary.resizeViewer?.()); - } - if (persist) { - try { - localStorage.setItem(DOCK_MINIMIZED_KEY, on ? '1' : '0'); - } catch { - /* storage unavailable */ - } - } - this._renderMinimized(this._state); } - isMinimized() { - return this.root?.classList.contains('minimized') === true; - } - - _renderMinimized(state) { - 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'; + isViewerExpanded() { + return ( + this._elements.viewerWrap?.classList.contains( + 'mly-viewer-wrap-expanded', + ) === true + ); } /** Grow the street-level viewer to most of the screen, or shrink it back. */ setViewerExpanded(expanded) { - if (!this.root) return; - const on = expanded === true; const wrap = this._elements.viewerWrap; - if (on === this.root.classList.contains('mly-viewer-expanded')) return; - this.root.classList.toggle('mly-viewer-expanded', on); - if (wrap) { - if (on) { - // Lift the viewer out of the dock: the dock's backdrop-filter would - // otherwise pin a fixed-position child inside the panel. - this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; - document.body.appendChild(wrap); - wrap.classList.add('mly-viewer-wrap-expanded'); - } else if (this._wrapHome?.parent) { - wrap.classList.remove('mly-viewer-wrap-expanded'); + if (!wrap) return; + const on = expanded === true; + if (on === this.isViewerExpanded()) return; + if (on) { + // Lift the viewer out of the panel: the panel's backdrop-filter would + // otherwise pin a fixed-position child inside it. + this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; + this._expandReturnFocus = document.activeElement; + document.body.appendChild(wrap); + wrap.classList.add('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 = 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; if (button) { @@ -344,230 +272,213 @@ export class MapillaryControls { requestAnimationFrame(() => this.mapillary.resizeViewer?.()); } - setCollapsed(collapsed, { persist = true } = {}) { - if (!this.root) return; - this.root.classList.toggle('collapsed', collapsed); - this._elements.toggle?.setAttribute('aria-expanded', String(!collapsed)); - this._elements.toggle?.setAttribute( - 'title', - collapsed ? 'Expand Mapillary' : 'Collapse Mapillary', + _onDialogKey(event) { + if (!this.isViewerExpanded()) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.setViewerExpanded(false); + 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 (persist) { - try { - localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0'); - } catch { - /* storage unavailable */ - } + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + 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) { if (this.destroyed || !state || !this.root) return; 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 enabled = state.enabled === true; - - el.enableBtn.textContent = enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF'; - el.enableBtn.setAttribute('aria-pressed', String(enabled)); - - 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'; + this.root.dataset.mlyEnabled = String(view.enabled); + if (el.status) { + el.status.textContent = view.status.text; + el.status.className = `mly-status${view.status.tone ? ` is-${view.status.tone}` : ''}`; } - el.statusChip.textContent = chip; - el.statusChip.className = `mly-chip ${chipClass}`.trim(); - - if (enabled && this._wasEnabled === false) this.setCollapsed(false); - this._wasEnabled = enabled; - 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.'; + if (el.enableBtn) { + el.enableBtn.textContent = view.enableButton.text; + el.enableBtn.setAttribute( + 'aria-pressed', + String(view.enableButton.pressed), + ); } + } - // Imagery filters. - const filter = state.coverage.filter || { pano: 'all', sinceMs: null }; + _renderEmptyState(view) { + 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]')) { - const active = button.dataset.mlyPano === filter.pano; + const active = button.dataset.mlyPano === view.filter.pano; button.classList.toggle('is-active', active); button.setAttribute('aria-checked', String(active)); } - - // 3D objects. - const objects = state.objects3d || {}; - el.threeDBtn.setAttribute('aria-pressed', String(objects.enabled === true)); - el.threeDBtn.textContent = objects.building - ? '3D OBJECTS …' - : objects.enabled - ? '3D OBJECTS ON' - : '3D OBJECTS OFF'; - el.threeDNote.hidden = !( - objects.enabled && - (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 (el.legend && el.legend.childElementCount !== view.legend.length) { + el.legend.replaceChildren( + ...view.legend.map((entry) => { + const item = document.createElement('li'); + const swatch = document.createElement('i'); + swatch.className = 'mly-legend-swatch'; + swatch.style.background = entry.color; + const label = document.createElement('span'); + label.textContent = entry.label; + item.append(swatch, label); + return item; + }), ); - 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. - const { street } = state; - el.viewerWrap.hidden = !street.open; - this.root.classList.toggle('mly-has-viewer', street.open === true); - if (!street.open && this.root.classList.contains('mly-viewer-expanded')) - this.setViewerExpanded(false); - el.followBtn.setAttribute('aria-pressed', String(street.follow === true)); - el.followBtn.textContent = street.follow - ? 'STREET COCKPIT ON' - : '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'; + _renderViewer(view, state) { + const el = this._elements; + const { viewer } = view; + if (el.viewerWrap) el.viewerWrap.hidden = !viewer.open; + if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false); + if (el.viewerPlaceholder) el.viewerPlaceholder.hidden = !viewer.loading; + if (el.followBtn) { + el.followBtn.setAttribute('aria-pressed', String(viewer.follow.pressed)); + el.followBtn.disabled = viewer.follow.disabled; } - if (street.open) { - // Caption bar as on mapillary.com: "Image by …" left, date right. - const left = []; - if (street.loading) left.push('LOADING…'); - 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?.(); + for (const button of this.root.querySelectorAll('[data-mly-render]')) { + const active = button.dataset.mlyRender === viewer.renderMode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-checked', String(active)); } + 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. - const coverageBits = []; - if (enabled && state.coverage.zoom) - 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(' · '); + _renderMeta(view) { + const el = this._elements; + if (el.coverageMeta) el.coverageMeta.textContent = view.meta; + } + /** 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) { 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); - if (this.isMinimized()) this.setMinimized(false); - } } } @@ -578,7 +489,6 @@ export class MapillaryControls { this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; - this._floating?.destroy(); this._unsubscribe?.(); this._unsubscribe = null; this.mapillary.attachViewerHost?.(null); diff --git a/src/ui/mapillaryPresentation.js b/src/ui/mapillaryPresentation.js new file mode 100644 index 000000000..eeb9482aa --- /dev/null +++ b/src/ui/mapillaryPresentation.js @@ -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', + }; +} diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 2562f137b..66652c306 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -11,6 +11,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'location-bar', pinnable: true }, { id: 'data-panel' }, { id: 'cctv-panel' }, + { id: 'mapillary-panel' }, { id: 'radio-panel' }, { id: 'scene-panel' }, { id: 'global-context-panel' }, @@ -21,6 +22,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'data-panel', 'cctv-panel', + 'mapillary-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', @@ -281,6 +283,7 @@ export class PanelChrome { const isRightRail = [ 'pp-toggles', 'cctv-panel', + 'mapillary-panel', 'global-context-panel', ].includes(panelEl?.id); const collapsed = panelEl.classList.contains('collapsed'); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index f7bdd1977..5b2567898 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -94,6 +94,7 @@ export class PanelLayoutController { this._rightPanelStack = document.getElementById('right-context-rail'); this._ppToggles = document.getElementById('pp-toggles'); this._cctvPanel = document.getElementById('cctv-panel'); + this._mapillaryPanel = document.getElementById('mapillary-panel'); this._sliderPanel = document.getElementById('param-slider-panel'); this._detectionBtn = document.getElementById('detection-toggle'); } @@ -204,6 +205,19 @@ export class PanelLayoutController { stack.insertBefore(this._cctvPanel, globalContextPanel); 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) { this._sliderPanel.style.removeProperty('top'); this._sliderPanel.style.removeProperty('right'); @@ -222,6 +236,7 @@ export class PanelLayoutController { for (const panel of [ this._ppToggles, this._cctvPanel, + this._mapillaryPanel, globalContextPanel, ]) { if (panel) this._rightStackResizeObserver.observe(panel); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 065380dfb..8e8f0fb96 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -44,7 +44,7 @@ export function readShellElements(document = globalThis.document) { _dataPanel: document.getElementById('data-panel'), _scenePanel: document.getElementById('scene-panel'), _cctvPanel: document.getElementById('cctv-panel'), - _mapillaryDock: document.getElementById('mapillary-dock'), + _mapillaryPanel: document.getElementById('mapillary-panel'), _radioPanel: document.getElementById('radio-panel'), _contextRadioDock: document.getElementById('context-radio-dock'), _contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'), diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 72207fa52..5dc48030a 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -162,6 +162,7 @@ } #right-context-rail > #pp-toggles, #right-context-rail > #cctv-panel, +#right-context-rail > #mapillary-panel, #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +174,14 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel, +#right-context-rail #mapillary-panel { width: 100%; max-height: 100%; 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); margin-left: auto; } @@ -191,6 +194,7 @@ } #right-context-rail > #pp-toggles.collapsed .pp-header-row, #right-context-rail #cctv-panel.collapsed .cctv-panel-inner, +#right-context-rail #mapillary-panel.collapsed .mapillary-panel-inner, #right-context-rail #global-context-panel.collapsed .global-context-panel-inner { @@ -198,7 +202,8 @@ min-height: 50px; 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%; overflow-y: auto; scrollbar-gutter: stable; @@ -246,6 +251,7 @@ } #right-context-rail.layout-focus > #pp-toggles: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) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -261,6 +267,9 @@ #right-context-rail.layout-focus > #cctv-panel:not(.collapsed) .cctv-panel-inner, +#right-context-rail.layout-focus + > #mapillary-panel:not(.collapsed) + .mapillary-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) .global-context-panel-inner { diff --git a/src/ui/styles/mapillary.css b/src/ui/styles/mapillary.css index 8aa634ad0..aed38ac55 100644 --- a/src/ui/styles/mapillary.css +++ b/src/ui/styles/mapillary.css @@ -1,23 +1,13 @@ -/* ── Mapillary dock ─────────────────────────── */ -#mapillary-dock { +/* ── Street Level (Mapillary) panel ───────────────────────────────────────── + Lives in the right context rail next to CCTV; the rail owns position and + width (see layers.css). Everything here is panel-internal. */ +#mapillary-panel { --mly-green: #05cb63; - --mly-width: clamp(320px, 30vw, 460px); - position: fixed; - /* Sit beside the right context rail (DISPLAY / CONTEXT / CCTV), which owns - the right-hand 330px column from 26vh down, so the two never overlap. */ - right: calc(var(--right-rail-x, 52px) + 330px + 16px); - bottom: 3.4rem; - z-index: 118; - width: var(--mly-width); - max-width: calc(100vw - 32px); - max-height: calc(100vh - 7rem); - display: flex; - flex-direction: column; - pointer-events: auto; - font-family: var(--font-mono); + --mly-text: 0.62rem; + --mly-text-small: 0.6rem; } -#mapillary-dock .panel-glow { +#mapillary-panel .panel-glow { position: absolute; inset: -18px; background: radial-gradient( @@ -30,211 +20,71 @@ pointer-events: none; } -.mly-dock-inner { +.mapillary-panel-inner { position: relative; - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 14px; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.03) inset; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; + 0 0 0 1px rgba(255, 255, 255, 0.03) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; + scrollbar-width: thin; + scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06); } -.mly-header { +#mapillary-panel .panel-header { + gap: 8px; +} + +.mly-mark { + width: 14px; + height: 14px; flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; + color: var(--text-dim); + transition: color var(--transition-fast); } -.mly-title-btn { - display: flex; - align-items: center; - gap: 8px; - background: none; - border: 0; - color: var(--text-primary); - cursor: pointer; - padding: 2px 0; - font: inherit; - letter-spacing: 0.12em; - font-size: 0.72rem; +#mapillary-panel[data-mly-enabled='true'] .mly-mark { + color: var(--mly-green); + filter: drop-shadow(0 0 6px rgba(5, 203, 99, 0.7)); } -.mly-brand-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--mly-green); - box-shadow: 0 0 10px rgba(5, 203, 99, 0.8); -} - -.mly-chip { - font-size: 0.58rem; +.mly-status { + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.1em; padding: 2px 7px; border-radius: 999px; border: 1px solid var(--glass-border-hover); color: var(--text-secondary); + white-space: nowrap; } -.mly-chip.is-on { +.mly-status.is-on { color: var(--mly-green); border-color: rgba(5, 203, 99, 0.55); } -.mly-chip.is-busy { +.mly-status.is-busy { color: var(--accent); border-color: rgba(0, 212, 255, 0.55); } -.mly-chip.is-warn { +.mly-status.is-warn { color: #ffb300; border-color: rgba(255, 179, 0, 0.55); } -.mly-header-tools { - display: flex; - align-items: center; - gap: 6px; -} - -.mly-icon-btn { - width: 26px; - height: 26px; - border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(255, 255, 255, 0.04); - color: var(--text-secondary); - font: inherit; - font-size: 0.7rem; - line-height: 1; - cursor: pointer; - display: grid; - place-items: center; - transition: all var(--transition-fast); -} - -.mly-icon-btn:hover { - color: var(--text-primary); - border-color: var(--accent); - background: rgba(0, 212, 255, 0.14); -} - -/* Author display rules would otherwise beat the UA's [hidden] { display: none }. */ -.mly-viewer-wrap[hidden], -.mly-minimized[hidden], -.mly-minimized-badge[hidden], -.mly-image-link[hidden] { - display: none !important; -} - -/* ── Minimized: only the Mapillary logo remains ─ */ -.mly-minimized { - position: relative; - width: 52px; - height: 52px; - border-radius: 50%; - border: 1px solid rgba(5, 203, 99, 0.55); - background: var(--glass-bg); - backdrop-filter: blur(18px) saturate(1.4); - -webkit-backdrop-filter: blur(18px) saturate(1.4); - color: var(--mly-green); - cursor: pointer; - display: grid; - place-items: center; - box-shadow: - 0 6px 24px rgba(0, 0, 0, 0.5), - 0 0 18px rgba(5, 203, 99, 0.35); - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.mly-minimized:hover { - transform: scale(1.06); - box-shadow: - 0 8px 28px rgba(0, 0, 0, 0.55), - 0 0 26px rgba(5, 203, 99, 0.6); -} - -.mly-minimized .mly-logo { - width: 28px; - height: 28px; - display: block; -} - -.mly-minimized-badge { - position: absolute; - top: -4px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: 999px; - background: var(--accent); - color: #0a0a0f; - font-size: 0.52rem; - font-weight: 600; - letter-spacing: 0.04em; - display: grid; - place-items: center; - box-shadow: 0 0 10px rgba(0, 212, 255, 0.6); -} - -.mly-minimized.is-off { - color: var(--text-secondary); - border-color: var(--glass-border-hover); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); -} - -#mapillary-dock.minimized { - width: auto !important; - height: auto !important; - min-width: 0 !important; - min-height: 0 !important; - max-height: none !important; - align-items: flex-end; -} - -#mapillary-dock.minimized .mly-dock-inner, -#mapillary-dock.minimized .panel-glow, -#mapillary-dock.minimized .gev-resize-grip, -#mapillary-dock.minimized .gev-resize-edge { - display: none; -} - -.mly-power { - font: inherit; - font-size: 0.6rem; - letter-spacing: 0.1em; - padding: 6px 10px; - border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(255, 255, 255, 0.04); - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.mly-power[aria-pressed='true'] { - color: #0a0a0f; - background: var(--mly-green); - border-color: var(--mly-green); - box-shadow: 0 0 14px rgba(5, 203, 99, 0.45); -} - -#mapillary-dock.collapsed .mly-body { +/* Collapsed strips hide the body entirely: nothing to paint or tab into. */ +#mapillary-panel.collapsed .mly-body, +#mapillary-panel.collapsed .mly-status { display: none; } @@ -242,38 +92,50 @@ display: flex; flex-direction: column; gap: 8px; - flex: 1 1 auto; min-height: 0; } -/* Everything above the viewer scrolls; the viewer itself never does. */ -.mly-scroll { +.mly-controls { display: flex; flex-direction: column; gap: 8px; - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - padding-right: 2px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; } -/* Children keep their natural height; the region scrolls instead. */ -.mly-scroll > * { - flex-shrink: 0; +.mly-controls:disabled { + opacity: 0.45; + pointer-events: none; } -#mapillary-dock.mly-has-viewer .mly-scroll { - /* Let the imagery win the height fight when the window is short. */ - flex-shrink: 3; +/* ── Keyless empty state ─────────────────────────────────────────────────── */ +.mly-keyless { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(255, 179, 0, 0.45); + border-radius: 9px; + background: rgba(255, 179, 0, 0.06); } -.mly-kicker { - font-size: 0.56rem; - letter-spacing: 0.16em; - color: var(--accent); +.mly-keyless-text { + margin: 0; + font-family: var(--font-sans); + font-size: 0.72rem; + line-height: 1.4; + color: var(--text-primary); } +.mly-keyless-text code { + font-family: var(--font-mono); + font-size: 0.66rem; + color: #ffb300; +} + +/* ── Query ───────────────────────────────────────────────────────────────── */ .mly-query { display: flex; flex-direction: column; @@ -288,22 +150,68 @@ #mly-query-input { flex: 1; min-width: 0; - padding: 8px 10px; + padding: 9px 11px; border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(0, 212, 255, 0.45); + background: rgba(0, 212, 255, 0.06); color: var(--text-primary); font: inherit; - font-size: 0.72rem; + font-family: var(--font-mono); + font-size: 0.74rem; +} + +#mly-query-input::placeholder { + color: rgba(232, 234, 237, 0.55); } #mly-query-input:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); } -.mly-run { - min-width: 56px; +#mly-query-input:disabled { + border-color: var(--glass-border); + background: rgba(255, 255, 255, 0.03); +} + +.mly-ask { + flex: 0 0 auto; + min-width: 64px; + color: #0a0a0f; + background: var(--accent); + border-color: var(--accent); + font-weight: 600; +} + +.mly-ask:hover:not(:disabled) { + color: #0a0a0f; + background: var(--accent); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); +} + +.mly-ask.is-stop { + color: #ff8a80; + background: rgba(255, 88, 88, 0.15); + border-color: #ff7272; +} + +.mly-ask.is-stop:hover:not(:disabled) { + color: #ff8a80; + background: rgba(255, 88, 88, 0.25); + border-color: #ff7272; +} + +.mly-hint { + margin: 0; + font-family: var(--font-sans); + font-size: 0.68rem; + line-height: 1.35; + color: var(--text-secondary); +} + +.mly-hint.is-warn { + color: #ffb300; } .mly-suggestions { @@ -313,8 +221,8 @@ } .mly-suggestion { - font: inherit; - font-size: 0.56rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; padding: 3px 8px; border-radius: 999px; @@ -324,11 +232,17 @@ cursor: pointer; } -.mly-suggestion:hover { +.mly-suggestion:hover:not(:disabled) { color: var(--text-primary); border-color: var(--glass-border-hover); } +.mly-suggestion:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Answer, error, progress ─────────────────────────────────────────────── */ .mly-answer { font-family: var(--font-sans); font-size: 0.74rem; @@ -337,7 +251,14 @@ min-height: 1.2em; } -.mly-answer.is-error { +.mly-answer:empty { + display: none; +} + +.mly-error { + font-family: var(--font-sans); + font-size: 0.72rem; + line-height: 1.4; color: #ff8a80; } @@ -356,35 +277,67 @@ transition: width 160ms linear; } +.mly-progress.is-indeterminate .mly-progress-fill { + width: 40%; + animation: mly-indeterminate 1.4s ease-in-out infinite; +} + +@keyframes mly-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } +} + .mly-progress-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; - font-size: 0.55rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.1em; color: var(--text-primary); text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); } +/* ── Results ─────────────────────────────────────────────────────────────── */ +.mly-results { + display: flex; + flex-direction: column; + gap: 8px; +} + .mly-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.mly-chips.is-expanded .mly-value-chip.is-overflow { + display: inline-flex; +} + .mly-value-chip { display: inline-flex; align-items: center; gap: 6px; - font-size: 0.58rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); padding: 3px 8px; border-radius: 999px; border: 1px solid var(--glass-border); + background: transparent; color: var(--text-primary); } +.mly-value-chip.is-overflow { + display: none; +} + .mly-value-chip i { width: 8px; height: 8px; @@ -397,54 +350,212 @@ font-weight: 500; } -.mly-actions { +button.mly-value-chip { + cursor: pointer; +} + +button.mly-value-chip:hover { + border-color: var(--glass-border-hover); +} + +/* ── Filters and legend ──────────────────────────────────────────────────── */ +.mly-filters { display: flex; - gap: 6px; + align-items: center; + gap: 8px; flex-wrap: wrap; } -.mly-actions .scene-btn { - flex: 1 1 auto; +.mly-seg { + display: inline-flex; + border: 1px solid var(--glass-border-hover); + border-radius: 999px; + overflow: hidden; } -.scene-btn[aria-pressed='true'] { - color: #0a0a0f; - background: var(--accent); - border-color: var(--accent); +.mly-seg-btn { + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.08em; + padding: 4px 9px; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; } +.mly-seg-btn + .mly-seg-btn { + border-left: 1px solid var(--glass-border); +} + +.mly-seg-btn.is-active { + background: rgba(5, 203, 99, 0.22); + color: var(--text-primary); +} + +.mly-select { + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.06em; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border-hover); + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); +} + +.mly-select option { + background: #0a0a0f; +} + +.mly-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.mly-legend li { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.06em; + color: var(--text-secondary); +} + +.mly-legend-swatch { + width: 14px; + height: 3px; + border-radius: 2px; + display: inline-block; +} + +/* ── Embedded viewer ─────────────────────────────────────────────────────── */ .mly-viewer-wrap { position: relative; container-type: inline-size; display: flex; flex-direction: column; - flex: 1 1 auto; - min-height: 230px; + flex: 0 0 auto; border-radius: 12px; overflow: hidden; border: 1px solid var(--glass-border-hover); background: #000; } -.mly-viewer { - width: 100%; - flex: 1 1 auto; - min-height: 0; - /* Intrinsic height while the dock is auto-sized; grows when floating. */ - aspect-ratio: 16 / 10; +.mly-viewer-wrap[hidden] { + display: none; } -#mapillary-dock.gev-floating .mly-viewer, -.mly-viewer-wrap-expanded .mly-viewer { - aspect-ratio: auto; +.mly-viewer { + width: 100%; + aspect-ratio: 16 / 10; min-height: 180px; } -/* Caption bar, mapillary.com style: creator left, date right. */ +.mly-viewer-placeholder { + position: absolute; + inset: 0 0 26px; + display: grid; + place-items: center; + z-index: 3; + background: rgba(0, 0, 0, 0.35); +} + +.mly-viewer-placeholder[hidden] { + display: none; +} + +.mly-viewer-placeholder::after { + content: ''; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--mly-green); + animation: mly-spin 0.9s linear infinite; +} + +@keyframes mly-spin { + to { + transform: rotate(360deg); + } +} + +.mly-viewer-tools { + position: absolute; + top: 6px; + left: 6px; + /* Above MapillaryJS's overlays (z 10); its sequence bar is top-centre and + bearing/zoom/attribution bottom-right, so the top corners are free. */ + z-index: 12; + display: flex; + align-items: center; + gap: 4px; + pointer-events: none; +} + +.mly-viewer-tools > * { + pointer-events: auto; +} + +.mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 6px; +} + +.mly-viewer-tools .scene-btn { + padding: 4px 8px; + font-size: var(--mly-text-small); + background: rgba(10, 10, 15, 0.75); + flex: none; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mly-viewer-tools .mly-seg { + background: rgba(10, 10, 15, 0.75); +} + +@container (max-width: 380px) { + .mly-viewer-tools .mly-btn-text { + display: none; + } +} + +.mly-viewer-x { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(10, 10, 15, 0.8); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: all var(--transition-fast); +} + +.mly-viewer-x:hover { + color: #0a0a0f; + background: #fff; + border-color: #fff; +} + .mly-image-meta { flex: 0 0 auto; padding: 6px 10px; - font-size: 0.58rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; color: var(--text-secondary); background: rgba(0, 0, 0, 0.7); @@ -476,441 +587,29 @@ letter-spacing: 0.1em; } +.mly-image-link[hidden] { + display: none; +} + .mly-image-link:hover { text-decoration: underline; } -.mly-viewer-tools.mly-viewer-tools-right { - left: auto; - right: 6px; -} - -/* Only the buttons catch clicks; the containers let the viewer through. */ -.mly-viewer-tools { - pointer-events: none; -} - -.mly-viewer-tools > * { - pointer-events: auto; -} - -.mly-viewer-x { - width: 30px; - height: 30px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(10, 10, 15, 0.8); - color: var(--text-primary); - font: inherit; - font-size: 1.1rem; - line-height: 1; - cursor: pointer; - display: grid; - place-items: center; - transition: all var(--transition-fast); -} - -.mly-viewer-x:hover { - color: #0a0a0f; - background: #fff; - border-color: #fff; -} - -.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right { - left: auto; - right: 10px; - top: 10px; -} - -.mly-viewer-wrap-expanded .mly-viewer-x { - width: 40px; - height: 40px; - font-size: 1.5rem; -} - .mly-meta { - font-size: 0.56rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; - color: var(--text-dim); -} - -/* Match the app's chrome-hiding modes. */ -body.ui-clean-view #mapillary-dock, -body.recording-mode #mapillary-dock { - opacity: 0 !important; - visibility: hidden !important; - pointer-events: none !important; -} - -body.cockpit-mode #mapillary-dock { - display: none !important; -} - -/* Not enough room beside the rail: fall back to the corner (still draggable). */ -@media (max-width: 1100px) { - #mapillary-dock { - right: 16px; - } -} - -@media (max-width: 720px) { - #mapillary-dock { - left: 16px; - right: 16px; - width: auto; - max-width: none; - bottom: 6.4rem; - max-height: calc(100vh - 8rem); - } -} - -/* Short viewports: trim the chrome so the imagery keeps its room. */ -@media (max-height: 640px) { - .mly-dock-inner { - padding: 8px 10px; - gap: 6px; - } - - #mapillary-dock.mly-has-viewer .mly-ai-hint, - #mapillary-dock.mly-has-viewer .mly-suggestions { - display: none; - } - - .mly-viewer-wrap { - min-height: 200px; - } -} - -/* ── Prominent AI entry ─────────────────────── */ -.mly-ai-head { - display: flex; - align-items: center; - gap: 8px; -} - -.mly-ai-head .mly-kicker { - font-size: 0.66rem; - color: var(--text-primary); -} - -.mly-ai-model { - margin-left: auto; - font-size: 0.52rem; - letter-spacing: 0.08em; - color: var(--text-dim); -} - -.mly-ai-pulse { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); - animation: mly-pulse 2.2s ease-out infinite; -} - -@keyframes mly-pulse { - 0% { - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); - } - 70% { - box-shadow: 0 0 0 9px rgba(0, 212, 255, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); - } -} - -#mapillary-dock #mly-query-input { - padding: 11px 12px; - font-size: 0.8rem; - border: 1px solid rgba(0, 212, 255, 0.45); - background: rgba(0, 212, 255, 0.06); - box-shadow: 0 0 18px rgba(0, 212, 255, 0.12) inset; -} - -#mapillary-dock #mly-query-input::placeholder { - color: rgba(232, 234, 237, 0.55); -} - -#mapillary-dock #mly-query-input:focus { - background: rgba(0, 212, 255, 0.1); - box-shadow: - 0 0 0 2px var(--accent-dim), - 0 0 22px rgba(0, 212, 255, 0.25); -} - -.mly-ask { - min-width: 64px; - font: inherit; - font-size: 0.66rem; - letter-spacing: 0.12em; - border-radius: var(--btn-radius); - border: 1px solid var(--accent); - background: var(--accent); - color: #0a0a0f; - cursor: pointer; - font-weight: 600; - transition: all var(--transition-fast); -} - -.mly-ask:hover:not(:disabled) { - box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); -} - -.mly-ask:disabled { - opacity: 0.55; - cursor: progress; -} - -.mly-ai-hint { - font-family: var(--font-sans); - font-size: 0.66rem; - line-height: 1.35; color: var(--text-secondary); } -.mly-ai-hint.is-warn { - color: #ffb300; +.mly-meta:empty { + display: none; } -/* ── Imagery filters ────────────────────────── */ -.mly-filters { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.mly-filter-label { - font-size: 0.54rem; - letter-spacing: 0.14em; - color: var(--text-dim); -} - -.mly-seg { - display: inline-flex; - border: 1px solid var(--glass-border-hover); - border-radius: 999px; - overflow: hidden; -} - -.mly-seg-btn { - font: inherit; - font-size: 0.56rem; - letter-spacing: 0.08em; - padding: 4px 9px; - border: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; -} - -.mly-seg-btn + .mly-seg-btn { - border-left: 1px solid var(--glass-border); -} - -.mly-seg-btn.is-active { - background: rgba(5, 203, 99, 0.22); - color: var(--text-primary); -} - -.mly-select { - font: inherit; - font-size: 0.56rem; - letter-spacing: 0.06em; - padding: 4px 8px; - border-radius: 999px; - border: 1px solid var(--glass-border-hover); - background: rgba(0, 0, 0, 0.35); - color: var(--text-primary); -} - -.mly-select option { - background: #0a0a0f; -} - -/* ── Portable, resizable windows ────────────── */ -.gev-floating-handle { - cursor: grab; -} - -.gev-floating-dragging, -.gev-floating-dragging * { - cursor: grabbing !important; - user-select: none; -} - -.gev-floating { - position: fixed !important; - right: auto !important; - bottom: auto !important; - margin: 0 !important; - max-width: calc(100vw - 12px) !important; - z-index: 132 !important; - display: flex; - flex-direction: column; -} - -.gev-resize-grip { - position: absolute; - right: 5px; - bottom: 5px; - width: 16px; - height: 16px; - cursor: nwse-resize; - z-index: 6; - opacity: 0.6; - border-radius: 3px; - background: linear-gradient( - 135deg, - transparent 0 45%, - var(--accent) 45% 55%, - transparent 55% 70%, - var(--accent) 70% 80%, - transparent 80% - ); -} - -.gev-resize-grip:hover { - opacity: 1; -} - -/* Invisible strips along the other edges and corners. */ -.gev-resize-edge { - position: absolute; - z-index: 7; -} - -.gev-resize-edge[data-dir='n'], -.gev-resize-edge[data-dir='s'] { - left: 10px; - right: 10px; - height: 7px; - cursor: ns-resize; -} - -.gev-resize-edge[data-dir='e'], -.gev-resize-edge[data-dir='w'] { - top: 10px; - bottom: 10px; - width: 7px; - cursor: ew-resize; -} - -.gev-resize-edge[data-dir='n'] { - top: -3px; -} - -.gev-resize-edge[data-dir='s'] { - bottom: -3px; -} - -.gev-resize-edge[data-dir='e'] { - right: -3px; -} - -.gev-resize-edge[data-dir='w'] { - left: -3px; -} - -.gev-resize-edge[data-dir='ne'], -.gev-resize-edge[data-dir='nw'], -.gev-resize-edge[data-dir='sw'] { - width: 14px; - height: 14px; -} - -.gev-resize-edge[data-dir='ne'] { - top: -3px; - right: -3px; - cursor: nesw-resize; -} - -.gev-resize-edge[data-dir='nw'] { - top: -3px; - left: -3px; - cursor: nwse-resize; -} - -.gev-resize-edge[data-dir='sw'] { - bottom: -3px; - left: -3px; - cursor: nesw-resize; -} - -#mapillary-dock.gev-floating { - max-height: none !important; -} - -#mapillary-dock.gev-floating .mly-dock-inner { - flex: 1 1 auto; - min-height: 0; - max-height: none; - height: 100%; -} - -/* The CCTV panel lives in a rail until dragged; then it floats with the - width it had (set inline when lifted), never the viewport's. */ -#cctv-panel.gev-floating { - position: fixed !important; - flex: none !important; - max-height: calc(100vh - 12px) !important; -} - -#cctv-panel.gev-floating:not([style*='height']) { - height: auto !important; -} - -#cctv-panel.gev-floating .cctv-panel-inner { - flex: 1 1 auto; - min-height: 0; - height: 100%; - max-height: none; -} - -#cctv-panel.gev-floating.collapsed .cctv-panel-inner { - height: auto; -} - -/* ── Expanded street-level viewer ───────────── */ -.mly-viewer-tools { - position: absolute; - top: 6px; - left: 6px; - /* Above MapillaryJS's own overlays (z 10); the corners are free — its - sequence bar is top-centre, bearing/zoom/attribution bottom-right. */ - z-index: 12; - display: flex; - gap: 4px; -} - -.mly-viewer-tools .scene-btn { - padding: 4px 8px; - font-size: 0.56rem; - background: rgba(10, 10, 15, 0.75); - /* Hug the label: the playback bar MapillaryJS centres at the top needs - the middle of narrow viewers. */ - flex: none; - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; -} - -/* Narrow viewer: icon-only expand button so nothing meets the playback bar. */ -@container (max-width: 380px) { - .mly-viewer-tools .mly-btn-text { - display: none; - } - - .mly-viewer-tools .scene-btn { - padding: 4px 7px; - } -} - -/* The dock's backdrop-filter makes it the containing block for fixed - descendants, so the expanded viewer is re-parented to
. */ +/* ── Expanded viewer ─────────────────────────────────────────────────────── */ +/* The panel inner's backdrop-filter makes it the containing block for fixed + descendants, so the expanded viewer is re-parented to . z-index sits + above panels (≤139) and below the voice pill (150) and toasts (200). */ .mly-viewer-wrap-expanded { position: fixed; left: 4vw; @@ -923,6 +622,7 @@ body.cockpit-mode #mapillary-dock { font-family: var(--font-mono); border-color: rgba(0, 212, 255, 0.55); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); + outline: none; } .mly-viewer-wrap-expanded .mly-viewer { @@ -932,18 +632,20 @@ body.cockpit-mode #mapillary-dock { } .mly-viewer-wrap-expanded .mly-image-meta { + font-size: 0.66rem; padding: 10px 14px; } -.mly-viewer-wrap-expanded .mly-image-meta { - font-size: 0.64rem; -} - .mly-viewer-wrap-expanded .mly-viewer-tools { top: 10px; left: 10px; } +.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 10px; +} + .mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn { padding: 8px 14px; font-size: 0.7rem; @@ -953,7 +655,30 @@ body.cockpit-mode #mapillary-dock { background: rgba(10, 10, 15, 0.82); } -.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn::after { +.mly-viewer-wrap-expanded #mly-viewer-expand::after { content: ' · ESC'; color: var(--text-secondary); } + +.mly-viewer-wrap-expanded .mly-viewer-x { + width: 40px; + height: 40px; + font-size: 1.5rem; +} + +/* ── Short viewports ─────────────────────────────────────────────────────── */ +@media (max-height: 640px) { + .mapillary-panel-inner { + padding: 10px; + gap: 6px; + } + + #mapillary-panel .mly-hint, + #mapillary-panel .mly-suggestions { + display: none; + } + + .mly-viewer { + min-height: 150px; + } +} diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..910877f88 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -204,6 +204,8 @@ #right-context-rail #pp-toggles.collapsed .pp-header-row, #right-context-rail #cctv-panel, #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.collapsed, #right-context-rail #global-context-panel, diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4f41b2452..b701c3870 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -68,6 +68,107 @@ + +Street Level needs a free Mapillary client token (MAPILLARY_CLIENT_TOKEN). Coverage, photos and queries stay off until one is added.